All files / services/notebook notebook-controller.ts

86.99% Statements 107/123
84.44% Branches 38/45
84.61% Functions 11/13
86.99% Lines 107/123

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345                      2x     20x   20x   20x           20x   20x       20x 20x 20x   20x 20x 20x 20x   20x   20x 20x     20x                   4x   4x 2x   2x 2x     1x         15x 15x         15x       15x 4x 4x         11x 7x   4x                           4x 4x           4x   4x         4x 1x   3x         4x   4x 4x   4x 4x 4x       4x 4x         4x   4x 1x 1x 1x 1x     3x   3x           3x   3x       3x                                 1x   1x 1x   1x     1x       1x                                           3x   3x 3x     3x       3x     3x   3x   1x 1x     2x 2x   2x 2x   2x   2x 1x 1x 1x     2x               2x 2x                       5x 2x     3x 3x             3x                       8x   8x 8x         8x   8x       8x 8x 8x   8x   7x         5x       2x 2x   2x           7x   1x       1x        
import * as vscode from 'vscode';
import { compile, isTemplate } from 'triplate';
import { ISparqlQueryService } from '@src/languages/sparql/services';
import { QuadsResult, SparqlQueryExecutionState, getQueryStateWithoutRawResponse } from '@src/languages/sparql/services/sparql-query-state';
import { formatQueryResult } from '@src/languages/sparql/services/bindings-formatter';
import { getResultsCopyFormat, getResultsExportTarget, getResultsIriFormat } from '@src/languages/sparql/services/query-results-format';
import { IDocumentContextService } from '@src/services/document';
import { ShaclValidationService } from '@src/services/validation/shacl-validation-service';
import { renderTemplateInteractively } from '@src/languages/triplate/triplate-prompt';
import { ExecuteCommandMessage } from '@src/views/webviews/webview-messaging';
 
export const NOTEBOOK_TYPE = 'mentor-notebook';
 
export class NotebookController implements vscode.Disposable {
	private readonly _id = 'mentor-notebook-controller';
 
	private readonly _label = 'Mentor Notebook';
 
	private readonly _supportedLanguages = ['sparql', 'turtle', 'trig', 'ntriples', 'nquads', 'xml'];
 
	private readonly _controller: vscode.NotebookController;
 
	private readonly _messaging: vscode.NotebookRendererMessaging;
 
	private readonly _subscriptions: vscode.Disposable[] = [];
 
	private _executionOrder = 0;
 
	constructor(
		context: vscode.ExtensionContext,
		private readonly _contextService: IDocumentContextService,
		private readonly _validationService: ShaclValidationService,
		private readonly _queryService: ISparqlQueryService
	) {
		this._controller = vscode.notebooks.createNotebookController(this._id, NOTEBOOK_TYPE, this._label);
		this._controller.executeHandler = this._executeAll.bind(this);
		this._controller.supportedLanguages = this._supportedLanguages;
		this._controller.supportsExecutionOrder = true;
 
		this._subscriptions.push(this._controller);
 
		this._messaging = vscode.notebooks.createRendererMessaging('mentor.notebook.sparqlResultsRenderer');
		this._messaging.onDidReceiveMessage(this._onDidReceiveMessage, this, this._subscriptions);
 
		// Self-register with the extension context for automatic disposal
		context.subscriptions.push(this);
	}
 
	dispose(): void {
		for (const subscription of this._subscriptions) {
			subscription.dispose();
		}
	}
 
	private _onDidReceiveMessage(e: { message: ExecuteCommandMessage | { id: string } }) {
		const message = e.message;
 
		if (message.id === 'ExecuteCommand') {
			const { command, args } = message as ExecuteCommandMessage;
 
			vscode.commands.executeCommand(command, ...(args ?? []));
		} else if (message.id === 'GetResultsExportTarget') {
			// The renderer runs in an iframe and cannot read settings, so it asks for the
			// default export target of its toolbar.
			this._messaging.postMessage({ id: 'PostResultsExportTarget', target: getResultsExportTarget() });
		}
	}
 
	private _executeAll(cells: vscode.NotebookCell[], _notebook: vscode.NotebookDocument, _controller: vscode.NotebookController): void {
		for (const cell of cells) {
			this._doExecution(cell);
		}
	}
 
	private async _doExecution(cell: vscode.NotebookCell): Promise<void> {
		const text = cell.document.getText();
 
		// Triplate template cells are rendered first, then executed (SPARQL) or validated
		// (RDF) so their output lands in the cell like an ordinary query/validation cell.
		if (isTemplate(text)) {
			await this._executeTemplate(cell, text);
			return;
		}
 
		// SPARQL cells run their query; all other RDF-data cells (turtle, trig,
		// ntriples, nquads, xml) validate against their configured SHACL shapes.
		if (cell.document.languageId === 'sparql') {
			await this.executeQueryInCell(cell);
		} else {
			await this._validateCell(cell);
		}
	}
 
	/**
	 * Compiles and renders a triplate template cell, prompting for parameter values,
	 * then runs the cell's primary action on the rendered output: SPARQL templates are
	 * executed and RDF templates are SHACL-validated, matching the native play button's
	 * behaviour for non-template cells. (Code-lens Run/Run-example render a Turtle preview
	 * instead — see routeRenderedTriplate.)
	 */
	private async _executeTemplate(cell: vscode.NotebookCell, text: string): Promise<void> {
		let compiled: ReturnType<typeof compile>;
 
		try {
			compiled = compile(text);
		} catch (error) {
			vscode.window.showErrorMessage(`Failed to compile template: ${(error as Error).message}`);
			return;
		}
 
		const rendered = await renderTemplateInteractively(compiled);
 
		Iif (rendered === undefined) {
			// The user cancelled, or rendering failed (already reported).
			return;
		}
 
		if (cell.document.languageId === 'sparql') {
			await this.executeQueryInCell(cell, rendered);
		} else {
			await this.validateContentInCell(cell, rendered);
		}
	}
 
	private async _validateCell(cell: vscode.NotebookCell) {
		const execution = this._controller.createNotebookCellExecution(cell);
 
		execution.executionOrder = ++this._executionOrder;
		execution.start(Date.now());
 
		try {
			const contextService = this._contextService;
			const validationService = this._validationService;
 
			// The validation service operates on the document context by URI, so make
			// sure the cell has been loaded before validating.
			Eif (!contextService.contexts[cell.document.uri.toString()]) {
				await contextService.loadDocument(cell.document);
			}
 
			// Mirror the validateDocument command: when no shapes are configured, open
			// the shape-configuration flow instead of validating.
			const shapeGraphs = validationService.getEffectiveShapeGraphs(cell.document.uri);
 
			if (shapeGraphs.length === 0) {
				await vscode.commands.executeCommand('mentor.command.manageShaclShapes');
				await execution.clearOutput();
				execution.end(undefined, Date.now());
				return;
			}
 
			const result = await validationService.validateDocument(cell.document.uri);
 
			Iif (!result) {
				await execution.clearOutput();
				execution.end(undefined, Date.now());
				return;
			}
 
			const summary = this._getValidationSummary(validationService, cell.document.uri, result);
 
			await execution.replaceOutput([new vscode.NotebookCellOutput([
				vscode.NotebookCellOutputItem.text(summary, 'text/plain')
			])]);
 
			execution.end(result.conforms, Date.now());
		} catch (error: any) {
			await execution.replaceOutput([new vscode.NotebookCellOutput([
				vscode.NotebookCellOutputItem.error(error as Error)
			])]);
 
			execution.end(false, Date.now());
		}
	}
 
	/**
	 * Writes a rendered RDF template result to the cell output as `text/turtle`, the same
	 * way CONSTRUCT/DESCRIBE query results are shown. No SHACL validation is performed.
	 * @param cell The notebook cell whose output is replaced.
	 * @param content The rendered RDF content to display.
	 */
	async renderContentInCell(cell: vscode.NotebookCell, content: string): Promise<void> {
		const execution = this._controller.createNotebookCellExecution(cell);
 
		execution.executionOrder = ++this._executionOrder;
		execution.start(Date.now());
 
		try {
			// Mirror how CONSTRUCT/DESCRIBE query results are surfaced: the rendered RDF is
			// the end result and is shown as the cell output.
			await execution.replaceOutput([new vscode.NotebookCellOutput([
				vscode.NotebookCellOutputItem.text(content, 'text/turtle')
			])]);
 
			execution.end(true, Date.now());
		} catch (error: any) {
			await execution.replaceOutput([new vscode.NotebookCellOutput([
				vscode.NotebookCellOutputItem.error(error as Error)
			])]);
 
			execution.end(false, Date.now());
		}
	}
 
	/**
	 * Shows a rendered RDF template result as the cell output and SHACL-validates it against the
	 * cell's configured shapes. The rendered Turtle is the cell output; conformance is surfaced via
	 * snackbars (mirroring the Validate Document command) and the cell's pass/fail run status rather
	 * than as redundant in-cell text. Validation runs against a hidden temporary document (never
	 * shown) so the template cell's own text is left untouched.
	 * Used by the native play button for RDF template cells; the code-lens path uses
	 * {@link renderContentInCell} instead.
	 * @param cell The notebook cell whose shape configuration and output are used.
	 * @param content The rendered RDF content to show and validate.
	 */
	async validateContentInCell(cell: vscode.NotebookCell, content: string): Promise<void> {
		const execution = this._controller.createNotebookCellExecution(cell);
 
		execution.executionOrder = ++this._executionOrder;
		execution.start(Date.now());
 
		// The rendered RDF is the cell output; SHACL conformance is reported via snackbars below.
		await execution.replaceOutput([new vscode.NotebookCellOutput([
			vscode.NotebookCellOutputItem.text(content, 'text/turtle')
		])]);
 
		const validationService = this._validationService;
 
		// Shape configuration lives on the cell, not on the temporary rendered document.
		const shapeGraphs = validationService.getEffectiveShapeGraphs(cell.document.uri);
 
		if (shapeGraphs.length === 0) {
			// No shapes configured — the rendered output is the result; nothing to validate.
			execution.end(true, Date.now());
			return;
		}
 
		const contextService = this._contextService;
		const document = await vscode.workspace.openTextDocument({ content, language: cell.document.languageId });
 
		try {
			await contextService.loadDocument(document, true);
 
			const result = await validationService.validateDocument(document.uri, shapeGraphs);
 
			if (result && !result.conforms) {
				vscode.window.showWarningMessage(`SHACL validation: ${result.results.length} issue(s) found.`);
			} else Eif (result) {
				vscode.window.showInformationMessage('SHACL validation: No issues found.');
			}
 
			execution.end(result?.conforms ?? true, Date.now());
		} catch (error: any) {
			vscode.window.showErrorMessage(`SHACL validation failed: ${(error as Error).message}`);
			execution.end(false, Date.now());
		} finally {
			// Validation ran against a hidden temporary document — clear its diagnostics so they
			// don't linger in the Problems panel as a phantom untitled file, then drop its
			// context and store graphs.
			validationService.clearDiagnostics(document.uri);
			contextService.handleDocumentClosed(document);
		}
	}
 
	/**
	 * Serializes a query result to the configured copy format, for the plain text output item that
	 * backs the built-in 'Copy Cell Output' command. Serializing is best effort: a failure here must
	 * not turn an otherwise successful query into a failed cell execution, so it yields empty text.
	 * @param result The result of the executed query.
	 * @returns The serialized result, or an empty string if it cannot be serialized.
	 */
	private _getCopyText(result: SparqlQueryExecutionState['result']): string {
		if (result?.type !== 'bindings' && result?.type !== 'boolean') {
			return '';
		}
 
		try {
			return formatQueryResult(result, { format: getResultsCopyFormat(), iriForm: getResultsIriFormat() });
		} catch {
			return '';
		}
	}
 
	private _getValidationSummary(validationService: ShaclValidationService, documentUri: vscode.Uri, result: { conforms: boolean; results: unknown[] }): string {
		return result.conforms
			? 'SHACL validation: Conforms — no issues found.'
			: validationService.getReportAsText(documentUri)
				?? `SHACL validation: ${result.results.length} issue(s) found.`;
	}
 
	/**
	 * Executes a SPARQL query in a notebook cell and renders the result in the cell output.
	 * @param cell The notebook cell whose connection and output are used.
	 * @param query The SPARQL query string. Defaults to the cell's own text.
	 */
	async executeQueryInCell(cell: vscode.NotebookCell, query: string = cell.document.getText()): Promise<void> {
		const execution = this._controller.createNotebookCellExecution(cell);
 
		execution.executionOrder = ++this._executionOrder;
		execution.start(Date.now());
 
		// We need a cancellation token source so we can request cancellation
		// from anywhere in Mentor. The execution.token is only cancelled when
		// the user explicitly cancels the cell execution in the notebook.
		const tokenSource = new vscode.CancellationTokenSource();
 
		execution.token.onCancellationRequested(() => {
			tokenSource.cancel();
		});
 
		try {
			const queryService = this._queryService;
			let queryState = queryService.createQuery(cell, query);
 
			queryState = await queryService.executeQuery(queryState, tokenSource);
 
			if (queryState.queryType === 'bindings' || queryState.queryType === 'boolean') {
				// The renderer mime type comes first so it stays the preferred representation. The
				// plain text item is not rendered; it gives the built-in 'Copy Cell Output' command
				// something to serialize, which it cannot do for a custom mime type on its own.
				// The renderer never reads the raw response, so it stays out of the output.
				await execution.replaceOutput([new vscode.NotebookCellOutput([
					vscode.NotebookCellOutputItem.json(getQueryStateWithoutRawResponse(queryState), 'application/sparql-results+json'),
					vscode.NotebookCellOutputItem.text(this._getCopyText(queryState.result), 'text/plain')
				])]);
			} else Eif (queryState.queryType === 'quads') {
				const result = queryState.result as QuadsResult;
 
				await execution.replaceOutput([new vscode.NotebookCellOutput([
					vscode.NotebookCellOutputItem.text(result?.document, 'text/turtle'),
					vscode.NotebookCellOutputItem.text(result?.document, 'text/plain')
				])]);
			}
 
			execution.end(true, Date.now());
		} catch (error: any) {
			await execution.replaceOutput([new vscode.NotebookCellOutput([
				vscode.NotebookCellOutputItem.error(error as Error)
			])]);
 
			execution.end(false, Date.now());
		}
	}
}