From 74ee7e82339c25d59d8ae13e95fcf4f6ba53f0a3 Mon Sep 17 00:00:00 2001 From: Ricardo Costa Date: Wed, 7 Oct 2026 15:08:00 +0100 Subject: [PATCH] Add opt-in local study logging Co-authored-by: Codex --- CONTRIBUTING.md | 41 ++++ client/.vscode-test.mjs | 8 +- client/package.json | 23 ++ client/src/extension.ts | 3 + client/src/lsp/client.ts | 4 + client/src/services/commands.ts | 9 +- client/src/services/diagnostics.ts | 2 + client/src/services/hover.ts | 2 + client/src/services/logger.ts | 8 +- client/src/services/study-log.ts | 214 ++++++++++++++++++ client/src/services/study-writer.ts | 32 +++ client/src/services/webview.ts | 4 + client/src/test/study-log.test.ts | 139 ++++++++++++ client/src/webview/clipboard.ts | 2 + client/src/webview/diagram.ts | 2 +- client/src/webview/provider.ts | 22 ++ client/src/webview/script.spec.ts | 45 ++++ client/src/webview/script.ts | 51 ++++- .../webview/views/diagnostics/diagnostics.ts | 2 +- client/src/webview/views/sections.ts | 6 +- .../src/main/java/LJDiagnosticsService.java | 33 ++- server/src/main/java/LJLanguageClient.java | 3 + server/src/main/java/LJLanguageServer.java | 9 + .../diagnostics/VerificationEventDTO.java | 3 + 24 files changed, 645 insertions(+), 22 deletions(-) create mode 100644 client/src/services/study-log.ts create mode 100644 client/src/services/study-writer.ts create mode 100644 client/src/test/study-log.test.ts create mode 100644 server/src/main/java/dtos/diagnostics/VerificationEventDTO.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10f12bf..27befdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,3 +68,44 @@ To run the language server manually, follow these steps: ### Project Structure - `/server` - Implements the language server in Java using [LSP4J](https://github.com/eclipse/lsp4j) - `/client` - Implements the VS Code extension in TypeScript that connects to the language server via LSP + +### Local study logging + +Study logging is disabled by default. To enable it, add these settings to the study workspace's `.vscode/settings.json`: + +```json +{ + "liquidjava.study.enabled": true, + "liquidjava.study.participantId": "P01", + "liquidjava.study.logPath": ".liquidjava/study-log.jsonl" +} +``` + +The log path is relative to the first workspace folder, which is also the folder the verifier checks. Absolute paths and paths outside that folder are rejected. Logging requires a local filesystem workspace. Changes to any study setting take effect immediately; disabling logging flushes pending events and removes study editor listeners and timers. When disabled, the extension creates no log file and performs no study writes, diagnostic hashing, or server timing notifications. The configuration listener remains active so logging can be enabled later. + +Run **LiquidJava: Reveal Study Log** to flush and open the JSONL file. Each line is a JSON object with `t` (UTC ISO timestamp), `pid` (participant ID), `session` (UUID for this extension activation), `event`, and the fields below. Events use workspace-relative paths with `/` separators and **one-based** lines and columns. Logs append across launches; settings changes and server restarts retain the activation's session ID. + +| Event | Fields / meaning | +| --- | --- | +| `logging_started`, `logging_stopped` | Boundaries of each enabled logging interval; includes settings changes and normal shutdown. | +| `file_opened`, `file_focused`, `file_blurred`, `file_saved` | `file`; Java files inside the workspace only. Initial focus is recorded even if the file was already open. Switching to another file, a non-Java editor, or no editor ends focus. | +| `file_edited` | `file`, `count`; number of content changes, batched after 500 ms of quiet, and flushed on save, file switch, or shutdown. No edited text is stored. | +| `window_focus`, `window_blur` | VS Code window focus, including its initial state. | +| `verify_started` | `file`, `trigger` (`open`, `save`, `manual`), `run`; emitted when the server begins a queued verification. Run IDs remain unique after server restarts. | +| `verify_finished` | Same fields plus `durationMs` and `result` (`passed`, `failed`, `crashed`, `cancelled`). Duration excludes queue wait. Stopping the server or logging cancels pending runs; cancellations use elapsed client time. | +| `diagnostic_shown`, `diagnostic_resolved` | `file`, `line`, `column` (null if unavailable), `kind` (the diagnostic type), `category`, `key`. Repeated results do not repeat appearances. Only successful diagnostic results resolve previous errors; crashes and stops do not. | +| `view_visible`, `view_hidden` | LiquidJava sidebar visibility, including initial state when logging is enabled. | +| `tab_selected` | `tab` (`diagnostics`, `context`, `fsm`), `file` when available; includes initial selection and diagnostic context / state-machine actions. | +| `section_toggled` | `section` (`context-vars`, `context-ghosts`, `context-aliases`), `expanded`, `file`. | +| `section_shown` | `section` (`counterexample`, `vc-implications`, `hint`), `file`; these sections currently render without collapse controls. Records transitions into the rendered view; redraws of continuously visible sections are deduplicated. | +| `vc_step_selected` | `direction` (`previous`, `next`), `file`; records simplification steps, including the displayed changes between implications. | +| `diagnostic_reveal` | `file`, `line`, `column`; navigation from the webview to source. | +| `highlight` | `file`, `line`, `active` for same-file highlights; cross-file navigation also records its highlight. | +| `clipboard_copy` | `target` (`diagnostic`, `fsm`), `file`; emitted after a successful copy, without clipboard contents. | +| `hover_shown` | `file`, `line`, `column`; LiquidJava supplied a nonempty hover (VS Code does not expose whether it was ultimately displayed). | +| `codelens_clicked` | `file`, `line`; diagnostic CodeLens activation. | +| `command_run` | `command`; registered `liquidjava.*` commands, including Reveal Study Log. | + +To compute time focused per exercise, intersect `file_focused`/`file_blurred` intervals with `window_focus`/`window_blur` and `logging_started`/`logging_stopped` intervals. Edits and saves provide activity counts; the analysis can choose its own idle threshold. Match diagnostic appearances and resolutions using `key` to compute observed time-to-fix. Keys preserve the nearest previous diagnostic of the same file, kind, and category when edits move its line, which suits the study's one intended error per exercise. Multiple identical errors are matched by proximity; replacing one with another of the same kind may retain its key. An abrupt process exit has no reliable end event: treat the final interval as incomplete rather than assuming it ended at a later launch. + +No source code, expressions, diagnostic messages, counterexamples, hover contents, or clipboard text are logged. Nothing is uploaded, and the VS Code telemetry API is not used. Participants can inspect the file and hand it in themselves. Keep `.liquidjava/` out of the study repository's `.gitignore` if collecting with git. AI explanation events are deferred until the explanation UI in issue #113 exists. diff --git a/client/.vscode-test.mjs b/client/.vscode-test.mjs index fad26a8..fec54d3 100644 --- a/client/.vscode-test.mjs +++ b/client/.vscode-test.mjs @@ -7,11 +7,13 @@ const version = process.env.VSCODE_TEST_VERSION === 'minimum' ? minVersion(engines.vscode).version : 'stable'; -export default defineConfig(['failing', 'passing'].map(fixture => ({ +export default defineConfig(['failing', 'passing', 'study'].map(fixture => ({ label: fixture, - files: fixture === 'failing' ? 'out/test/**/*.test.js' : 'out/test/smoke.test.js', + files: fixture === 'study' ? 'out/test/study-log.test.js' + : fixture === 'failing' ? ['out/test/lifecycle.test.js', 'out/test/smoke.test.js'] + : 'out/test/smoke.test.js', version, - workspaceFolder: `test-fixtures/${fixture}`, + workspaceFolder: `test-fixtures/${fixture === 'study' ? 'failing' : fixture}`, extensionDevelopmentPath: '.', launchArgs: ['--disable-extensions', '--disable-workspace-trust'], mocha: { ui: 'tdd', timeout: 120_000 }, diff --git a/client/package.json b/client/package.json index b5891b5..d36cc79 100644 --- a/client/package.json +++ b/client/package.json @@ -60,6 +60,24 @@ "default": "off", "description": "Traces the communication between VSCode and the liquidJavaServer service." }, + "liquidjava.study.enabled": { + "type": "boolean", + "default": false, + "scope": "resource", + "description": "Record LiquidJava study interactions locally in the first workspace folder. Disabled by default." + }, + "liquidjava.study.participantId": { + "type": "string", + "default": "", + "scope": "resource", + "description": "Participant ID included in every local study event." + }, + "liquidjava.study.logPath": { + "type": "string", + "default": ".liquidjava/study-log.jsonl", + "scope": "resource", + "description": "Study log file path relative to the first workspace folder." + }, "liquidjava.applyItalicOverlay": { "type": "boolean", "default": true, @@ -121,6 +139,11 @@ "title": "Restart", "category": "LiquidJava" }, + { + "command": "liquidjava.study.revealLog", + "title": "Reveal Study Log", + "category": "LiquidJava" + }, { "command": "liquidjava.verify", "title": "Verify", diff --git a/client/src/extension.ts b/client/src/extension.ts index 3bb3d20..d550d44 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -13,6 +13,7 @@ import { refreshCodeLenses, registerCodeLens } from "./services/codelens"; import { runLanguageServer, stopLanguageServer } from "./lsp/server"; import { runClient, stopClient } from "./lsp/client"; import type { LiquidJavaTestApi } from "./types/test-api"; +import { registerStudyLog, stopStudyLog } from './services/study-log'; /** * Activates the LiquidJava extension @@ -21,6 +22,7 @@ import type { LiquidJavaTestApi } from "./types/test-api"; export async function activate(context: vscode.ExtensionContext): Promise { context.subscriptions.push(extension.diagnosticsEmitter, extension.failureEmitter); registerLogger(context); + registerStudyLog(context); extension.logger!.client.info("Activating LiquidJava extension..."); registerStatusBar(context); @@ -59,6 +61,7 @@ export async function deactivate() { extension.logger?.client.info("Deactivating LiquidJava extension..."); await stopClient("Extension was deactivated"); await stopLanguageServer(); + await stopStudyLog(); resetExtension(); } diff --git a/client/src/lsp/client.ts b/client/src/lsp/client.ts index c2734b7..bd2aeff 100644 --- a/client/src/lsp/client.ts +++ b/client/src/lsp/client.ts @@ -8,6 +8,7 @@ import { onActiveFileChange } from '../services/events'; import type { LJDiagnostic } from "../types/diagnostics"; import { LJContext } from '../types/context'; import { handleContext } from '../services/context'; +import { isStudyEnabled, handleStudyVerification, studyVerificationCancelled } from '../services/study-log'; /** * Starts the client and connects it to the language server @@ -29,12 +30,14 @@ export async function runClient(context: vscode.ExtensionContext, port: number) }; const clientOptions: LanguageClientOptions = { documentSelector: [{ language: "java" }], + initializationOptions: { studyLogging: isStudyEnabled() }, }; extension.client = new LanguageClient("liquidJavaServer", "LiquidJava Server", serverOptions, clientOptions); context.subscriptions.push(extension.client); // disposed on deactivation try { + extension.client.onNotification('liquidjava/verification', handleStudyVerification); await extension.client.start(); extension.logger!.client.info("Extension is ready"); @@ -73,6 +76,7 @@ export async function runClient(context: vscode.ExtensionContext, port: number) * @param reason The reason for stopping the client */ export async function stopClient(reason: string) { + studyVerificationCancelled(); if (!extension.client && !extension.serverProcess && !extension.socket) { extension.logger!.client.info("Extension already stopped"); return; diff --git a/client/src/services/commands.ts b/client/src/services/commands.ts index 9998b15..b6b1867 100644 --- a/client/src/services/commands.ts +++ b/client/src/services/commands.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { startExtension, stopExtension, restartExtension } from "../extension"; import { verify } from "./diagnostics"; +import { logStudy } from './study-log'; const commandIcons: Record = { "liquidjava.showLogs": "$(output)", @@ -31,7 +32,10 @@ export function registerCommands(context: vscode.ExtensionContext) { const handler = commandHandlers[cmd.command]; if (handler) { context.subscriptions.push( - vscode.commands.registerCommand(cmd.command, () => handler(context)) + vscode.commands.registerCommand(cmd.command, () => { + logStudy('command_run', { command: cmd.command }); + return handler(context); + }) ); } }); @@ -39,6 +43,7 @@ export function registerCommands(context: vscode.ExtensionContext) { // register command to show all commands context.subscriptions.push( vscode.commands.registerCommand("liquidjava.showCommands", async () => { + logStudy('command_run', { command: 'liquidjava.showCommands' }); const quickPickItems = commands .filter(cmd => cmd.command !== "liquidjava.showCommands") .map(cmd => ({ @@ -51,4 +56,4 @@ export function registerCommands(context: vscode.ExtensionContext) { if (selected) vscode.commands.executeCommand(selected.command); }) ); -} \ No newline at end of file +} diff --git a/client/src/services/diagnostics.ts b/client/src/services/diagnostics.ts index 4631457..d5255bd 100644 --- a/client/src/services/diagnostics.ts +++ b/client/src/services/diagnostics.ts @@ -4,12 +4,14 @@ import { LJDiagnostic } from "../types/diagnostics"; import { updateStatusBar } from "./status-bar"; import { updateErrorAtCursor } from "./context"; import { refreshCodeLenses } from "./codelens"; +import { studyDiagnostics } from './study-log'; /** * Handles LiquidJava diagnostics received from the language server * @param diagnostics The array of diagnostics received */ export function handleLJDiagnostics(diagnostics: LJDiagnostic[]) { + studyDiagnostics(diagnostics); const containsError = diagnostics.some(d => d.category === "error"); const statusBarState: ExtensionStatus = containsError ? "failed" : "passed"; updateStatusBar(statusBarState); diff --git a/client/src/services/hover.ts b/client/src/services/hover.ts index cd96ea9..632fe3c 100644 --- a/client/src/services/hover.ts +++ b/client/src/services/hover.ts @@ -5,6 +5,7 @@ import { getSelectionContextVariables } from './context'; import { getOriginalVariableName, normalizeFilePath } from '../utils/utils'; import { definitionMatchesClass, getDefinitions } from './definition'; import { isExtensionRunning } from '../extension'; +import { logStudy } from './study-log'; /** * Initializes hover provider for LiquidJava diagnostics @@ -30,6 +31,7 @@ export function registerHover() { } if (hoverContent.value.length === 0) return null; + logStudy('hover_shown', { file: document.uri.fsPath, line: position.line + 1, column: position.character + 1 }); return new vscode.Hover(hoverContent); } }); diff --git a/client/src/services/logger.ts b/client/src/services/logger.ts index 305869f..1f3388b 100644 --- a/client/src/services/logger.ts +++ b/client/src/services/logger.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { OutputChannel } from "vscode"; import { extension } from "../state"; +import { logStudy } from './study-log'; enum LogLevel { INFO = "INFO", @@ -77,5 +78,8 @@ export function registerLogger(context: vscode.ExtensionContext) { extension.logger = createLogger(outputChannel); context.subscriptions.push(outputChannel); context.subscriptions.push(extension.logger); - context.subscriptions.push(vscode.commands.registerCommand("liquidjava.showLogs", () => outputChannel.show(true))); -} \ No newline at end of file + context.subscriptions.push(vscode.commands.registerCommand("liquidjava.showLogs", () => { + logStudy('command_run', { command: 'liquidjava.showLogs' }); + outputChannel.show(true); + })); +} diff --git a/client/src/services/study-log.ts b/client/src/services/study-log.ts new file mode 100644 index 0000000..e56d619 --- /dev/null +++ b/client/src/services/study-log.ts @@ -0,0 +1,214 @@ +import * as vscode from 'vscode'; +import { createHash, randomUUID } from 'node:crypto'; +import * as path from 'node:path'; +import { StudyWriter } from './study-writer'; +import { extension } from '../state'; +import type { LJDiagnostic } from '../types/diagnostics'; + +let study: StudyLog | undefined; +let pendingFlush = Promise.resolve(); +let launchSession: string | undefined; + +export function logStudy(event: string, data?: Record) { + study?.log(event, data); +} + +export function isStudyEnabled() { + return Boolean(study); +} + +type VerificationEvent = { phase: string; uri: string; trigger: string; run: string; durationMs: number; result: string }; + +export function handleStudyVerification(event: VerificationEvent) { + study?.verification(event); +} + +export function studyDiagnostics(diagnostics: LJDiagnostic[]) { + study?.updateDiagnostics(diagnostics); +} + +export function studyVerificationCancelled() { + study?.cancelVerification(); +} + +export async function flushStudyLog() { + await pendingFlush; + await study?.writer.flush(); +} + +export async function stopStudyLog() { + const previous = study; + study = undefined; + await pendingFlush; + await previous?.stop(); +} + +export function registerStudyLog(context: vscode.ExtensionContext) { + const configure = () => { + const previous = study; + study = undefined; + if (previous) pendingFlush = Promise.all([pendingFlush, previous.stop()]).then(() => {}); + // the language server verifies the first workspace folder + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder || folder.uri.scheme !== 'file') return; + const config = vscode.workspace.getConfiguration('liquidjava.study', folder.uri); + if (!config.get('enabled', false)) return; + const relativePath = config.get('logPath', '.liquidjava/study-log.jsonl'); + const root = folder.uri.fsPath; + const destination = path.resolve(root, relativePath); + if (!relativePath || path.isAbsolute(relativePath) || !isInside(root, destination)) { + void vscode.window.showWarningMessage('LiquidJava study logPath must be a file path inside the first workspace folder.'); + return; + } + study = new StudyLog(root, destination, config.get('participantId', '')); + }; + const reconfigure = () => { + configure(); + void extension.client?.sendNotification('liquidjava/studyLogging', isStudyEnabled()); + extension.webview?.sendMessage({ type: 'study', enabled: isStudyEnabled() }); + }; + configure(); + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('liquidjava.study')) reconfigure(); + }), + vscode.workspace.onDidChangeWorkspaceFolders(reconfigure), + new vscode.Disposable(() => { + if (study) pendingFlush = Promise.all([pendingFlush, study.stop()]).then(() => {}); + study = undefined; + }), + vscode.commands.registerCommand('liquidjava.study.revealLog', async () => { + logStudy('command_run', { command: 'liquidjava.study.revealLog' }); + if (!study) { + void vscode.window.showInformationMessage('LiquidJava study logging is disabled.'); + return; + } + const writer = study.writer; + await flushStudyLog(); + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(vscode.Uri.file(writer.path))); + }), + ); +} + +function isInside(root: string, file: string) { + const relative = path.relative(root, file); + return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +class StudyLog { + readonly writer: StudyWriter; + private subscriptions: vscode.Disposable[] = []; + private focusedFile?: string; + private edits = new Map(); + private editTimer?: ReturnType; + private diagnostics = new Map>(); + private runs = new Map(); + + constructor(private readonly root: string, destination: string, pid: string) { + this.writer = new StudyWriter(destination, pid, launchSession ??= randomUUID(), () => { + extension.logger?.client.error('Could not write LiquidJava study log.'); + void vscode.window.showWarningMessage('LiquidJava could not write the study log. Check the workspace log path and permissions.'); + }, pendingFlush); + this.log('logging_started'); + this.log(vscode.window.state.focused ? 'window_focus' : 'window_blur'); + this.focus(vscode.window.activeTextEditor); + this.updateDiagnostics(extension.diagnostics ?? []); + if (extension.webview) this.log(extension.webview.isVisible() ? 'view_visible' : 'view_hidden'); + this.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(editor => this.focus(editor)), + vscode.window.onDidChangeWindowState(state => this.log(state.focused ? 'window_focus' : 'window_blur')), + vscode.workspace.onDidOpenTextDocument(document => { + if (document.uri.scheme === 'file' && document.languageId === 'java') this.log('file_opened', { file: document.uri.fsPath }); + }), + vscode.workspace.onDidSaveTextDocument(document => { + if (document.uri.scheme === 'file' && document.languageId === 'java') { + this.flushEdits(); + this.log('file_saved', { file: document.uri.fsPath }); + } + }), + vscode.workspace.onDidChangeTextDocument(event => { + const file = event.document.uri.fsPath; + if (event.document.uri.scheme !== 'file' || event.document.languageId !== 'java' || !isInside(this.root, file) || !event.contentChanges.length) return; + this.edits.set(file, (this.edits.get(file) ?? 0) + event.contentChanges.length); + clearTimeout(this.editTimer); + this.editTimer = setTimeout(() => this.flushEdits(), 500); + }), + ); + } + + log(event: string, data: Record = {}) { + if (typeof data.file === 'string') { + if (!isInside(this.root, data.file)) return; + data = { ...data, file: path.relative(this.root, data.file).split(path.sep).join('/') }; + } + this.writer.log(event, data); + } + + private focus(editor?: vscode.TextEditor) { + const file = editor?.document.languageId === 'java' && editor.document.uri.scheme === 'file' + && isInside(this.root, editor.document.uri.fsPath) ? editor.document.uri.fsPath : undefined; + if (file === this.focusedFile) return; + this.flushEdits(); + if (this.focusedFile) this.log('file_blurred', { file: this.focusedFile }); + this.focusedFile = file; + if (file) this.log('file_focused', { file }); + } + + private flushEdits() { + clearTimeout(this.editTimer); + this.editTimer = undefined; + for (const [file, count] of this.edits) this.log('file_edited', { file, count }); + this.edits.clear(); + } + + verification(event: VerificationEvent) { + const file = vscode.Uri.parse(event.uri).fsPath; + if (!isInside(this.root, file)) return; + if (event.phase === 'started') { + this.runs.set(event.run, { file, trigger: event.trigger, started: Date.now() }); + this.log('verify_started', { file, trigger: event.trigger, run: event.run }); + } else if (event.phase === 'finished' && this.runs.delete(event.run)) { + this.log('verify_finished', { file, trigger: event.trigger, run: event.run, durationMs: event.durationMs, result: event.result }); + } + } + + cancelVerification() { + for (const [run, data] of this.runs) this.log('verify_finished', { + file: data.file, trigger: data.trigger, run, + durationMs: Date.now() - data.started, result: 'cancelled', + }); + this.runs.clear(); + } + + updateDiagnostics(diagnostics: LJDiagnostic[]) { + const next = new Map>(); + const unmatched = new Map(this.diagnostics); + for (const diagnostic of diagnostics) { + if (!isInside(this.root, diagnostic.file)) continue; + const line = diagnostic.position ? diagnostic.position.lineStart + 1 : null; + const column = diagnostic.position ? diagnostic.position.colStart + 1 : null; + // preserve identity when edits move an existing error to another line + const previous = [...unmatched.entries()] + .filter(([, data]) => data.file === diagnostic.file && data.kind === diagnostic.type && data.category === diagnostic.category) + .sort(([, a], [, b]) => Math.abs(Number(a.line) - Number(line)) - Math.abs(Number(b.line) - Number(line)))[0]; + const key = previous?.[0] ?? createHash('sha256').update(JSON.stringify([ + path.relative(this.root, diagnostic.file).split(path.sep).join('/'), diagnostic.type, diagnostic.category, line, column, next.size, + ])).digest('hex'); + unmatched.delete(key); + const data = { key, file: diagnostic.file, line, column, kind: diagnostic.type, category: diagnostic.category }; + next.set(key, data); + if (!this.diagnostics.has(key)) this.log('diagnostic_shown', data); + } + for (const [, data] of unmatched) this.log('diagnostic_resolved', data); + this.diagnostics = next; + } + + stop() { + this.subscriptions.forEach(subscription => subscription.dispose()); + this.flushEdits(); + if (this.focusedFile) this.log('file_blurred', { file: this.focusedFile }); + this.cancelVerification(); + this.log('logging_stopped'); + return this.writer.flush(); + } +} diff --git a/client/src/services/study-writer.ts b/client/src/services/study-writer.ts new file mode 100644 index 0000000..8523c23 --- /dev/null +++ b/client/src/services/study-writer.ts @@ -0,0 +1,32 @@ +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +// serialize batches so shutdown and configuration changes cannot reorder events +export class StudyWriter { + private lines: string[] = []; + private timer?: ReturnType; + private writes: Promise; + + constructor(readonly path: string, private readonly pid: string, readonly session: string, private readonly onError: () => void, previousWrites = Promise.resolve()) { + this.writes = previousWrites; + } + + log(event: string, data: object = {}) { + this.lines.push(JSON.stringify({ ...data, t: new Date().toISOString(), pid: this.pid, session: this.session, event }) + '\n'); + if (!this.timer) this.timer = setTimeout(() => { void this.flush(); }, 250); + } + + flush(): Promise { + clearTimeout(this.timer); + this.timer = undefined; + if (this.lines.length) { + const batch = this.lines.join(''); + this.lines = []; + this.writes = this.writes.then(async () => { + await mkdir(dirname(this.path), { recursive: true }); + await appendFile(this.path, batch, 'utf8'); + }).catch(this.onError); + } + return this.writes; + } +} diff --git a/client/src/services/webview.ts b/client/src/services/webview.ts index c342595..414902e 100644 --- a/client/src/services/webview.ts +++ b/client/src/services/webview.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { LiquidJavaWebviewProvider } from "../webview/provider"; import { extension } from "../state"; import type { DiagnosticRevealTarget } from "../types/diagnostics"; +import { logStudy, isStudyEnabled } from './study-log'; /** * Initializes the webview panel for the extension @@ -19,6 +20,8 @@ export function registerWebview(context: vscode.ExtensionContext) { // show view command context.subscriptions.push( vscode.commands.registerCommand("liquidjava.showView", async (diagnostic?: DiagnosticRevealTarget) => { + logStudy('command_run', { command: 'liquidjava.showView' }); + if (diagnostic) logStudy('codelens_clicked', { file: diagnostic.file, line: diagnostic.position.lineStart + 1 }); const isVisible = extension.webview?.isVisible(); await vscode.commands.executeCommand("liquidJavaView.focus"); if (!diagnostic) return; @@ -34,6 +37,7 @@ export function registerWebview(context: vscode.ExtensionContext) { context.subscriptions.push( extension.webview.onDidReceiveMessage(message => { if (message.type === "ready") { + extension.webview?.sendMessage({ type: 'study', enabled: isStudyEnabled() }); if (extension.file) extension.webview?.sendMessage({ type: "file", file: extension.file }); if (extension.diagnostics) extension.webview?.sendMessage({ type: "diagnostics", diagnostics: extension.diagnostics }); if (extension.context) extension.webview?.sendMessage({ type: "context", context: extension.context , errorAtCursor: extension.errorAtCursor }); diff --git a/client/src/test/study-log.test.ts b/client/src/test/study-log.test.ts new file mode 100644 index 0000000..6f27caa --- /dev/null +++ b/client/src/test/study-log.test.ts @@ -0,0 +1,139 @@ +import * as assert from 'node:assert/strict'; +import { readFile, writeFile, rm, rmdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { StudyWriter } from '../services/study-writer'; +import * as vscode from 'vscode'; +import type { LiquidJavaTestApi } from '../types/test-api'; + +suite('Opt-in LiquidJava study logging', () => { + test('logs local metadata, stable diagnostic identity, and paired verification results', async () => { + const installed = vscode.extensions.getExtension('AlcidesFonseca.liquid-java')!; + const api = await installed.activate(); + await api.ready; + const folder = vscode.workspace.workspaceFolders![0]; + const config = vscode.workspace.getConfiguration('liquidjava.study', folder.uri); + const settings = vscode.Uri.joinPath(folder.uri, '.vscode/settings.json'); + const originalSettings = await readFile(settings.fsPath).catch(() => undefined); + const source = vscode.Uri.joinPath(folder.uri, 'src/main/java/FailingRefinement.java'); + const original = await readFile(source.fsPath, 'utf8'); + const log = vscode.Uri.joinPath(folder.uri, '.liquidjava/study-log.jsonl'); + const logExists = await readFile(log.fsPath).then(() => true, () => false); + assert.equal(logExists, false, 'the isolated fixture must start without a study log'); + const readEvents = async (): Promise => { + const text = await readFile(log.fsPath, 'utf8').catch(() => ''); + return text.trim() ? text.trim().split('\n').map(line => JSON.parse(line)) : []; + }; + const waitFor = async (predicate: (events: any[]) => boolean) => { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const events = await readEvents(); + if (predicate(events)) return events; + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.fail(`missing expected study events: ${JSON.stringify(await readEvents())}`); + }; + const verify = async () => { + const result = new Promise((resolve, reject) => { + const diagnostics = api.onDiagnostics(() => { diagnostics.dispose(); failure.dispose(); resolve(); }); + const failure = api.onFailure(() => { diagnostics.dispose(); failure.dispose(); reject(new Error('verifier crashed')); }); + }); + await vscode.commands.executeCommand('liquidjava.verify'); + await result; + }; + try { + assert.equal(config.get('enabled'), false); + await vscode.commands.executeCommand('liquidjava.showView'); + assert.equal(await readFile(log.fsPath).then(() => true, () => false), false); + + await config.update('participantId', 'P01', vscode.ConfigurationTarget.WorkspaceFolder); + await config.update('enabled', true, vscode.ConfigurationTarget.WorkspaceFolder); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await vscode.commands.executeCommand('liquidjava.stop'); + await vscode.commands.executeCommand('liquidjava.start'); + const document = await vscode.workspace.openTextDocument(source); + await vscode.window.showTextDocument(document); + await waitFor(events => events.some(event => event.event === 'verify_finished' && event.trigger === 'open')); + await verify(); + let events = await waitFor(events => events.some(event => event.event === 'verify_finished' && event.trigger === 'manual')); + const shown = events.find(event => event.event === 'diagnostic_shown' && event.kind === 'refinement-error'); + assert.ok(shown, 'an error must appear in the log'); + + // moving the same diagnostic must not look like fixing it + const move = new vscode.WorkspaceEdit(); + move.insert(source, new vscode.Position(0, 0), '\n'); + await vscode.workspace.applyEdit(move); + await document.save(); + events = await waitFor(events => events.some(event => event.event === 'verify_finished' && event.trigger === 'save')); + assert.equal(events.filter(event => event.event === 'diagnostic_resolved').length, 0); + assert.equal(events.filter(event => event.event === 'diagnostic_shown' && event.kind === 'refinement-error').length, 1); + + const fix = new vscode.WorkspaceEdit(); + fix.replace(source, new vscode.Range(0, 0, document.lineCount, 0), original.replace('positive = -1', 'positive = 1')); + await vscode.workspace.applyEdit(fix); + await document.save(); + events = await waitFor(events => events.some(event => event.event === 'verify_finished' && event.result === 'passed')); + const resolved = events.find(event => event.event === 'diagnostic_resolved'); + assert.equal(resolved.key, shown.key); + assert.ok(Date.parse(resolved.t) >= Date.parse(shown.t)); + assert.ok(events.some(event => event.event === 'file_edited' && event.count > 0)); + assert.ok(events.some(event => event.event === 'file_saved')); + assert.ok(events.some(event => event.event === 'file_focused')); + assert.ok(events.some(event => event.event === 'window_focus' || event.event === 'window_blur')); + for (const finished of events.filter(event => event.event === 'verify_finished')) { + assert.ok(events.some(event => event.event === 'verify_started' && event.run === finished.run)); + assert.ok(finished.durationMs >= 0); + } + await vscode.commands.executeCommand('liquidjava.study.revealLog'); + assert.equal(vscode.window.activeTextEditor?.document.uri.fsPath, log.fsPath); + await config.update('enabled', false, vscode.ConfigurationTarget.WorkspaceFolder); + events = await waitFor(events => events.some(event => event.event === 'logging_stopped')); + const before = await readFile(log.fsPath, 'utf8'); + await vscode.commands.executeCommand('liquidjava.showView'); + await new Promise(resolve => setTimeout(resolve, 600)); + assert.equal(await readFile(log.fsPath, 'utf8'), before); + assert.ok(events.every(event => event.pid === 'P01' && event.session && event.t)); + assert.equal(new Set(events.map(event => event.session)).size, 1); + assert.ok(events.every(event => !event.file || event.file === 'src/main/java/FailingRefinement.java')); + assert.ok(!before.includes('_ > 0') && !before.includes('positive') && !before.includes('int valid')); + } finally { + await config.update('enabled', undefined, vscode.ConfigurationTarget.WorkspaceFolder); + await config.update('participantId', undefined, vscode.ConfigurationTarget.WorkspaceFolder); + await writeFile(source.fsPath, original); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await rm(log.fsPath, { force: true }); + await rmdir(vscode.Uri.joinPath(folder.uri, '.liquidjava').fsPath).catch(() => {}); + if (originalSettings) await writeFile(settings.fsPath, originalSettings); + else { + await rm(settings.fsPath, { force: true }); + await rmdir(vscode.Uri.joinPath(folder.uri, '.vscode').fsPath).catch(() => {}); + } + } + }); + + test('serializes final writes before a replacement logger starts writing', async () => { + const folder = await mkdtemp(join(tmpdir(), 'liquidjava-study-')); + const destination = join(folder, 'study.jsonl'); + let release: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const onError = () => assert.fail('study log write failed'); + const first = new StudyWriter(destination, 'P01', 'session', onError, gate); + first.log('logging_stopped'); + const stopped = first.flush(); + const second = new StudyWriter(destination, 'P01', 'session', onError, stopped); + second.log('logging_started'); + const started = second.flush(); + try { + await new Promise(resolve => setTimeout(resolve, 300)); + assert.equal(await readFile(destination).then(() => true, () => false), false); + release!(); + await started; + const events = (await readFile(destination, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + assert.deepEqual(events.map(event => event.event), ['logging_stopped', 'logging_started']); + } finally { + release!(); + await Promise.all([stopped, started]); + await rm(folder, { recursive: true, force: true }); + } + }); +}); diff --git a/client/src/webview/clipboard.ts b/client/src/webview/clipboard.ts index 8a40025..8ab5323 100644 --- a/client/src/webview/clipboard.ts +++ b/client/src/webview/clipboard.ts @@ -7,8 +7,10 @@ export async function copyToClipboard(button: HTMLButtonElement, text: string) { button.disabled = true; await navigator.clipboard.writeText(text); button.setAttribute('title', 'Copied!'); + return true; } catch (e) { button.setAttribute('title', 'Copy failed'); + return false; } finally { setTimeout(() => { if (originalTitle !== null) { diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index e9d3e64..c9368fb 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -292,5 +292,5 @@ export function registerPanListeners(document: any) { } export async function copyDiagramToClipboard(target: any, diagram: string) { - await copyToClipboard(target, diagram); + return copyToClipboard(target, diagram); } diff --git a/client/src/webview/provider.ts b/client/src/webview/provider.ts index 0f861a7..4b677c3 100644 --- a/client/src/webview/provider.ts +++ b/client/src/webview/provider.ts @@ -2,6 +2,8 @@ import * as vscode from 'vscode'; import { getHtml } from './html'; import { highlightRange, openFile } from '../services/editor'; import type { WebviewMessage } from '../types/test-api'; +import { logStudy, isStudyEnabled } from '../services/study-log'; +import { extension } from '../state'; /** * Webview provider for the LiquidJava extension @@ -23,6 +25,9 @@ export class LiquidJavaWebviewProvider implements vscode.WebviewViewProvider, vs _token: vscode.CancellationToken ) { this.view = webviewView; + logStudy(webviewView.visible ? 'view_visible' : 'view_hidden'); + webviewView.onDidChangeVisibility(() => logStudy(webviewView.visible ? 'view_visible' : 'view_hidden')); + webviewView.onDidDispose(() => logStudy('view_hidden')); webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] @@ -37,10 +42,27 @@ export class LiquidJavaWebviewProvider implements vscode.WebviewViewProvider, vs // handle message if (message.type === "openFile") { + logStudy('diagnostic_reveal', { file: message.filePath, line: message.line + 1, column: message.character + 1 }); + if (message.highlightRange) logStudy('highlight', { file: message.filePath, line: message.highlightRange.lineStart + 1 }); openFile(message.filePath, message.line, message.character, message.highlightRange); } else if (message.type === "highlight") { + logStudy('highlight', { file: extension.file, line: message.range ? message.range.lineStart + 1 : null, active: Boolean(message.range) }); // highlight the specified range in the current editor highlightRange(vscode.window.activeTextEditor, message.range); + } else if (message.type === 'log' && isStudyEnabled()) { + // accept only metadata; never persist arbitrary webview payloads + if (message.event === 'tab_selected' && ['diagnostics', 'context', 'fsm'].includes(message.tab)) { + logStudy('tab_selected', { tab: message.tab, file: extension.file }); + } else if (message.event === 'section_toggled' && typeof message.section === 'string' && + ['context-vars', 'context-ghosts', 'context-aliases', 'vc-changes', 'vc-implications'].includes(message.section)) { + logStudy('section_toggled', { section: message.section, expanded: Boolean(message.expanded), file: extension.file }); + } else if (message.event === 'clipboard_copy' && ['diagnostic', 'fsm'].includes(message.target)) { + logStudy('clipboard_copy', { target: message.target, file: extension.file }); + } else if (message.event === 'vc_step_selected' && ['previous', 'next'].includes(message.direction)) { + logStudy('vc_step_selected', { direction: message.direction, file: extension.file }); + } else if (message.event === 'section_shown' && ['counterexample', 'vc-implications', 'hint'].includes(message.section)) { + logStudy('section_shown', { section: message.section, file: extension.file }); + } } }); } diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 25dfdca..1ca8618 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -226,4 +226,49 @@ describe('webview script', () => { highlightRange: range, }]]); }); + + it('logs tab and section interactions only while study logging is enabled', () => { + showDiagnostics([diagnostic(currentFile, 'Error')]); + receive({ type: 'context', context: context() }); + click('[data-tab="context"]'); + expect(postMessage.mock.calls.some(([message]) => (message as any).type === 'log')).toBe(false); + + receive({ type: 'study', enabled: true }); + postMessage.mockClear(); + click('[data-context-toggle="context-vars"]'); + click('[data-tab="diagnostics"]'); + expect(postMessage.mock.calls).toContainEqual([{ type: 'log', event: 'section_toggled', section: 'context-vars', expanded: false }]); + expect(postMessage.mock.calls).toContainEqual([{ type: 'log', event: 'tab_selected', tab: 'diagnostics' }]); + + receive({ type: 'study', enabled: false }); + postMessage.mockClear(); + click('[data-tab="context"]'); + expect(postMessage.mock.calls.some(([message]) => (message as any).type === 'log')).toBe(false); + }); + + it('records successful clipboard copies without the copied diagnostic content', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + showDiagnostics([diagnostic(currentFile, 'Private diagnostic text')]); + receive({ type: 'study', enabled: true }); + postMessage.mockClear(); + click('.copy-diagnostic-btn'); + await vi.waitFor(() => expect(postMessage.mock.calls).toContainEqual([{ type: 'log', event: 'clipboard_copy', target: 'diagnostic' }])); + expect(writeText).toHaveBeenCalledOnce(); + expect(JSON.stringify(postMessage.mock.calls)).not.toContain('Private diagnostic text'); + }); + + it('deduplicates continuously visible sections across diagnostic redraws', () => { + const error = { ...diagnostic(currentFile, 'Error'), hint: 'Private hint' }; + showDiagnostics([error]); + receive({ type: 'study', enabled: true }); + postMessage.mockClear(); + receive({ type: 'diagnostics', diagnostics: [error] }); + expect(postMessage.mock.calls.some(([message]) => (message as any).event === 'section_shown')).toBe(false); + receive({ type: 'context', context: context() }); + click('[data-tab="context"]'); + click('[data-tab="diagnostics"]'); + expect(postMessage.mock.calls).toContainEqual([{ type: 'log', event: 'section_shown', section: 'hint' }]); + expect(JSON.stringify(postMessage.mock.calls)).not.toContain('Private hint'); + }); }); diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index 05b6ed0..ed9b42b 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -36,6 +36,15 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) let context: LJContext; let errorAtCursor: RefinementMismatchError; let selectedTab: NavTab = 'diagnostics'; + let studyEnabled = false; + let visibleSections = new Set(); + const log = (event: string, data: object) => { + if (studyEnabled) vscode.postMessage({ type: 'log', event, ...data }); + }; + const selectTab = (tab: NavTab) => { + selectedTab = tab; + log('tab_selected', { tab }); + }; let status: ExtensionStatus = 'loading'; let diagramOrientation: "LR" | "TB" = "TB"; let showDiagramConditions = false; @@ -76,6 +85,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const isExpanded = contextToggleButton.getAttribute('aria-expanded') !== 'false'; const nextExpanded = !isExpanded; + log('section_toggled', { section: sectionId, expanded: nextExpanded }); if (sectionId === 'context-vars') contextSectionState.vars = nextExpanded; if (sectionId === 'context-ghosts') contextSectionState.ghosts = nextExpanded; if (sectionId === 'context-aliases') contextSectionState.aliases = nextExpanded; @@ -143,7 +153,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) .filter(d => d.category === 'error')[errorIndex]; if (diagnostic?.type !== 'state-refinement-error' || !diagnostic.stateMachine) return; - selectedTab = 'fsm'; + selectTab('fsm'); diagnosticStateMachine = diagnostic.stateMachine; diagnosticStateMachineFile = diagnostic.file; showDiagramConditions = false; @@ -156,10 +166,11 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const vcImplicationStepButton = target.closest?.('.vc-step-btn'); if (vcImplicationStepButton) { e.stopPropagation(); - handleVCImplicationStepClick(vcImplicationStepButton, () => { + const changed = handleVCImplicationStepClick(vcImplicationStepButton, () => { root.querySelector('.highlight-var-btn.selected')?.classList.remove('selected'); vscode.postMessage({ type: 'highlight', range: null }); }); + if (changed) log('vc_step_selected', { direction: vcImplicationStepButton.getAttribute('data-vc-step') }); return; } @@ -225,7 +236,9 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (copyDiagramButton) { e.stopPropagation(); if (!currentDiagram) return; - copyDiagramToClipboard(copyDiagramButton, currentDiagram); + void copyDiagramToClipboard(copyDiagramButton, currentDiagram).then(copied => { + if (copied) log('clipboard_copy', { target: 'fsm' }); + }); return; } @@ -274,7 +287,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) diagnosticStateMachine = undefined; diagnosticStateMachineFile = undefined; } - selectedTab = tab; + selectTab(tab); updateView(); } return; @@ -285,7 +298,9 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (diagnosticCopyBtn) { e.preventDefault(); e.stopPropagation(); - copyDiagnosticToClipboard(diagnosticCopyBtn, getDisplayDiagnostics(diagnostics || [], showAllDiagnostics, currentFile)); + void copyDiagnosticToClipboard(diagnosticCopyBtn, getDisplayDiagnostics(diagnostics || [], showAllDiagnostics, currentFile)).then(copied => { + if (copied) log('clipboard_copy', { target: 'diagnostic' }); + }); return; } }); @@ -335,6 +350,14 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) window.addEventListener('message', event => { const msg = event.data; switch (msg.type) { + case 'study': + studyEnabled = msg.enabled === true; + visibleSections.clear(); + if (studyEnabled) { + log('tab_selected', { tab: selectedTab }); + logVisibleSections(); + } + break; case 'status': status = msg.status as ExtensionStatus; updateView(); @@ -384,11 +407,13 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) */ function updateView() { if (status === 'stopped' || status === 'crashed') { + visibleSections.clear(); currentDiagram = ''; root.innerHTML = renderStopped(status); return; } if (status === 'loading') { + visibleSections.clear(); currentDiagram = ''; root.innerHTML = renderLoading(); return; @@ -412,10 +437,22 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) root.innerHTML = renderContextView(context, currentFile, contextSectionState, errorAtCursor); break; } + logVisibleSections(); + } + + function logVisibleSections() { + if (!studyEnabled) return; + const next = new Set(); + for (const section of Array.from(root.querySelectorAll('[data-study-section]'))) { + const name = section.dataset.studySection!; + next.add(name); + if (!visibleSections.has(name)) log('section_shown', { section: name }); + } + visibleSections = next; } function revealDiagnostic(target: DiagnosticRevealTarget) { - selectedTab = 'diagnostics'; + selectTab('diagnostics'); const isVisibleInCurrentFile = showAllDiagnostics || !target.file || target.file.toLowerCase() === currentFile?.toLowerCase(); if (!isVisibleInCurrentFile) { @@ -441,7 +478,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) } function revealContextForDiagnostic(target: DiagnosticRevealTarget) { - selectedTab = 'context'; + selectTab('context'); vscode.postMessage({ type: 'openFile', filePath: target.file, diff --git a/client/src/webview/views/diagnostics/diagnostics.ts b/client/src/webview/views/diagnostics/diagnostics.ts index 92332ac..8e9b516 100644 --- a/client/src/webview/views/diagnostics/diagnostics.ts +++ b/client/src/webview/views/diagnostics/diagnostics.ts @@ -85,7 +85,7 @@ export async function copyDiagnosticToClipboard(button: any, displayDiagnostics: if (!diagnostic) return; const diagnosticText = formatDiagnosticForClipboard(diagnostic); - await copyToClipboard(button, diagnosticText); + return copyToClipboard(button, diagnosticText); } export function formatDiagnosticForClipboard(diagnostic: LJDiagnostic): string { diff --git a/client/src/webview/views/sections.ts b/client/src/webview/views/sections.ts index df94ec5..a6409db 100644 --- a/client/src/webview/views/sections.ts +++ b/client/src/webview/views/sections.ts @@ -12,8 +12,10 @@ export const renderMainHeader = (title: string, selectedTab: NavTab): string => `; -export const renderCustomSection = (title: string, body: string): string => /*html*/ - `
${title}:
${body}
`; +export const renderCustomSection = (title: string, body: string): string => { + const studySection = ({ Counterexample: 'counterexample', Found: 'vc-implications', Hint: 'hint' } as Record)[title]; + return `
${title}:
${body}
`; +}; export const renderSection = (title: string, body: string): string => /*html*/ renderCustomSection(title, `
${body}
`); diff --git a/server/src/main/java/LJDiagnosticsService.java b/server/src/main/java/LJDiagnosticsService.java index 6ee9ed7..576f5a3 100644 --- a/server/src/main/java/LJDiagnosticsService.java +++ b/server/src/main/java/LJDiagnosticsService.java @@ -24,12 +24,20 @@ import utils.ContextHistoryConverter; import utils.DiagnosticConverter; import utils.PathUtils; +import dtos.diagnostics.VerificationEventDTO; public class LJDiagnosticsService implements TextDocumentService, WorkspaceService { private LJLanguageClient client; private String workspaceRoot; private boolean initialVerification; + private volatile boolean studyLogging; + private long verificationRun; + private String verificationSession; + + public void setStudyLogging(boolean enabled) { + studyLogging = enabled; + } private final Set publishedDiagnosticUris = new HashSet<>(); private final ExecutorService diagnosticsExecutor = Executors.newSingleThreadExecutor(r -> { Thread thread = new Thread(r, "liquidjava-diagnostics"); @@ -63,7 +71,16 @@ public void sendDiagnosticsNotification(List diagnostics) { * Generates diagnostics for the given URI and publishes them to the client * @param uri the URI of the document */ - public void generateDiagnostics(String uri) { + private void generateDiagnostics(String uri, String trigger) { + boolean logging = studyLogging && client != null; + String run = null; + if (logging) { + if (verificationSession == null) verificationSession = java.util.UUID.randomUUID().toString(); + run = verificationSession + ":" + ++verificationRun; + } + long started = logging ? System.nanoTime() : 0; + String result = "crashed"; + if (logging) client.sendVerification(new VerificationEventDTO("started", uri, trigger, run, null, null)); String path = PathUtils.extractBasePath(uri); clearPublishedDiagnostics(uri); @@ -79,10 +96,14 @@ 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())); + result = ljDiagnostics.errors().isEmpty() ? "passed" : "failed"; } catch (Exception e) { System.err.println("LiquidJava internal error: " + e.getMessage()); clearPublishedDiagnostics(uri); this.client.sendFailure(); + } finally { + if (logging && studyLogging) client.sendVerification(new VerificationEventDTO( + "finished", uri, trigger, run, (System.nanoTime() - started) / 1_000_000, result)); } } @@ -92,7 +113,11 @@ public void generateDiagnostics(String uri) { * @return a future that completes when diagnostics are published */ public CompletableFuture generateDiagnosticsAsync(String uri) { - return CompletableFuture.runAsync(() -> generateDiagnostics(uri), diagnosticsExecutor); + return generateDiagnosticsAsync(uri, "manual"); + } + + private CompletableFuture generateDiagnosticsAsync(String uri, String trigger) { + return CompletableFuture.runAsync(() -> generateDiagnostics(uri, trigger), diagnosticsExecutor); } /** @@ -130,7 +155,7 @@ public void didOpen(DidOpenTextDocumentParams params) { if (!PathUtils.isFileInDirectory(uri, workspaceRoot) || initialVerification) return; initialVerification = true; System.out.println("First document opened — checking diagnostics"); - generateDiagnosticsAsync(uri); + generateDiagnosticsAsync(uri, "open"); } /** @@ -143,7 +168,7 @@ public void didSave(DidSaveTextDocumentParams params) { if (!PathUtils.isFileInDirectory(uri, workspaceRoot)) return; System.out.println("Document saved — checking diagnostics"); clearDiagnostic(uri); - generateDiagnosticsAsync(uri); + generateDiagnosticsAsync(uri, "save"); } /** diff --git a/server/src/main/java/LJLanguageClient.java b/server/src/main/java/LJLanguageClient.java index fe194f3..680d841 100644 --- a/server/src/main/java/LJLanguageClient.java +++ b/server/src/main/java/LJLanguageClient.java @@ -3,6 +3,7 @@ import org.eclipse.lsp4j.services.LanguageClient; import dtos.context.ContextHistoryDTO; +import dtos.diagnostics.VerificationEventDTO; import org.eclipse.lsp4j.jsonrpc.services.JsonNotification; @@ -10,6 +11,8 @@ * Language client interface to specify custom notifications */ public interface LJLanguageClient extends LanguageClient { + @JsonNotification("liquidjava/verification") + void sendVerification(VerificationEventDTO event); /** * Sends custom diagnostics notification to the client diff --git a/server/src/main/java/LJLanguageServer.java b/server/src/main/java/LJLanguageServer.java index 0c3876f..c25b392 100644 --- a/server/src/main/java/LJLanguageServer.java +++ b/server/src/main/java/LJLanguageServer.java @@ -17,6 +17,7 @@ import dtos.uri.Uri; import liquidjava.fsm.StateMachine; import liquidjava.fsm.StateMachineParser; +import com.google.gson.JsonObject; public class LJLanguageServer implements LanguageServer { @@ -38,6 +39,9 @@ public LJLanguageServer() { * @return CompletableFuture with the InitializeResult */ public CompletableFuture initialize(InitializeParams params) { + if (params.getInitializationOptions() instanceof JsonObject options) { + diagnosticsService.setStudyLogging(options.has("studyLogging") && options.get("studyLogging").getAsBoolean()); + } CompletableFuture completableFuture = new CompletableFuture<>(); ServerCapabilities capabilities = new ServerCapabilities(); WorkspaceServerCapabilities workspaceServerCapabilities = new WorkspaceServerCapabilities(); @@ -101,4 +105,9 @@ public CompletableFuture fsm(Uri uri) { public void verify(Uri uri) { diagnosticsService.generateDiagnosticsAsync(uri.uri()); } + + @JsonNotification("liquidjava/studyLogging") + public void studyLogging(boolean enabled) { + diagnosticsService.setStudyLogging(enabled); + } } diff --git a/server/src/main/java/dtos/diagnostics/VerificationEventDTO.java b/server/src/main/java/dtos/diagnostics/VerificationEventDTO.java new file mode 100644 index 0000000..a0e5410 --- /dev/null +++ b/server/src/main/java/dtos/diagnostics/VerificationEventDTO.java @@ -0,0 +1,3 @@ +package dtos.diagnostics; + +public record VerificationEventDTO(String phase, String uri, String trigger, String run, Long durationMs, String result) {}