feat: added syntax check

This commit is contained in:
2026-02-23 21:38:12 +01:00
parent e68e845e70
commit 7cc2f7cab2
14 changed files with 507 additions and 10 deletions

View File

@@ -52,3 +52,9 @@
font-size: 12px;
border-radius: 4px;
}
.scripts-check-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import MonacoEditor from '@monaco-editor/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import MonacoEditor, { type Monaco } from '@monaco-editor/react';
import type { ScriptData } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store';
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
@@ -8,6 +8,28 @@ import { useI18n } from '../../i18n';
import { showToast } from '../Toast';
import './ScriptsView.css';
type ScriptMonacoEditor = {
getModel: () => unknown;
};
type ScriptMonacoRuntime = {
editor: {
setModelMarkers: (model: unknown, owner: string, markers: Array<{
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
message: string;
severity: number;
}>) => void;
};
MarkerSeverity: {
Error: number;
};
};
const SCRIPT_SYNTAX_MARKER_OWNER = 'scripts-python-syntax';
const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
@@ -35,6 +57,9 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isCheckingSyntax, setIsCheckingSyntax] = useState(false);
const editorRef = useRef<ScriptMonacoEditor | null>(null);
const monacoRef = useRef<ScriptMonacoRuntime | null>(null);
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
let hash = 0;
@@ -57,6 +82,87 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
return normalized || 'script';
};
const applySyntaxMarkers = useCallback((errors: Array<{
line: number;
column: number;
endLine: number;
endColumn: number;
message: string;
}>) => {
const model = editorRef.current?.getModel?.();
const monacoRuntime = monacoRef.current;
if (!model || !monacoRuntime) {
return;
}
const markers = errors.map((error) => {
const startLineNumber = Math.max(1, Math.floor(error.line || 1));
const startColumn = Math.max(1, Math.floor(error.column || 1));
const endLineNumber = Math.max(startLineNumber, Math.floor(error.endLine || startLineNumber));
const fallbackEndColumn = startColumn + 1;
const endColumn = Math.max(startColumn, Math.floor(error.endColumn || fallbackEndColumn));
return {
startLineNumber,
startColumn,
endLineNumber,
endColumn,
message: error.message,
severity: monacoRuntime.MarkerSeverity.Error,
};
});
monacoRuntime.editor.setModelMarkers(model, SCRIPT_SYNTAX_MARKER_OWNER, markers);
}, []);
const handleEditorDidMount = useCallback((editor: unknown, monaco: Monaco) => {
editorRef.current = editor as ScriptMonacoEditor;
monacoRef.current = monaco as unknown as ScriptMonacoRuntime;
}, []);
const handleCheckSyntax = useCallback(async (options: { notify: boolean } = { notify: true }): Promise<boolean> => {
if (!script || isCheckingSyntax) {
return false;
}
setIsCheckingSyntax(true);
try {
const runtimeManager = getPythonRuntimeManager();
const syntax = await runtimeManager.syntaxCheck(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent),
});
applySyntaxMarkers(syntax.errors);
if (syntax.errors.length > 0) {
if (options.notify) {
showToast.error(t('scripts.syntax.invalid', { count: syntax.errors.length }));
}
return false;
}
if (options.notify) {
showToast.success(t('scripts.syntax.valid'));
}
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
appendPanelOutputEntry({
id: `output-${Date.now()}-syntax-error`,
message,
createdAt: new Date().toISOString(),
kind: 'error',
});
if (options.notify) {
showToast.error(t('scripts.syntax.checkFailed'));
}
return false;
} finally {
setIsCheckingSyntax(false);
}
}, [appendPanelOutputEntry, applySyntaxMarkers, isCheckingSyntax, script, scriptContent, t]);
useEffect(() => {
let cancelled = false;
@@ -162,6 +268,11 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
setIsSaving(true);
try {
const isSyntaxValid = await handleCheckSyntax({ notify: true });
if (!isSyntaxValid) {
return;
}
const runtimeManager = getPythonRuntimeManager();
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, {
cacheKey: buildCacheKey(script, scriptContent),
@@ -279,10 +390,6 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
kind: 'error',
});
} finally {
useAppStore.setState({
panelVisible: true,
panelActiveTab: 'output',
});
setIsRunning(false);
}
};
@@ -312,6 +419,16 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
>
{t('scripts.run')}
</button>
<button
type="button"
className="scripts-check-button"
onClick={() => {
void handleCheckSyntax({ notify: true });
}}
disabled={!script || isCheckingSyntax || isSaving}
>
{isCheckingSyntax ? t('scripts.syntax.checking') : t('scripts.syntax.check')}
</button>
<button
type="button"
className="secondary danger"
@@ -408,6 +525,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
theme="vs-dark"
value={scriptContent}
onChange={(value) => setScriptContent(value || '')}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: false },
wordWrap: 'on',

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Keine Funktionen gefunden",
"scripts.field.enabled": "Aktiviert",
"scripts.syntax.check": "Syntax prüfen",
"scripts.syntax.checking": "Prüfe...",
"scripts.syntax.valid": "Python-Syntax ist gültig",
"scripts.syntax.invalid": "Python-Syntaxfehler: {count}",
"scripts.syntax.checkFailed": "Python-Syntaxprüfung fehlgeschlagen",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "No functions found",
"scripts.field.enabled": "Enabled",
"scripts.syntax.check": "Check Syntax",
"scripts.syntax.checking": "Checking...",
"scripts.syntax.valid": "Python syntax is valid",
"scripts.syntax.invalid": "Python syntax errors: {count}",
"scripts.syntax.checkFailed": "Python syntax check failed",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "No se encontraron funciones",
"scripts.field.enabled": "Habilitado",
"scripts.syntax.check": "Comprobar sintaxis",
"scripts.syntax.checking": "Comprobando...",
"scripts.syntax.valid": "La sintaxis de Python es válida",
"scripts.syntax.invalid": "Errores de sintaxis de Python: {count}",
"scripts.syntax.checkFailed": "La comprobación de sintaxis de Python falló",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Aucune fonction trouvée",
"scripts.field.enabled": "Activé",
"scripts.syntax.check": "Vérifier la syntaxe",
"scripts.syntax.checking": "Vérification...",
"scripts.syntax.valid": "La syntaxe Python est valide",
"scripts.syntax.invalid": "Erreurs de syntaxe Python : {count}",
"scripts.syntax.checkFailed": "Échec de la vérification de la syntaxe Python",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",

View File

@@ -434,6 +434,11 @@
"scripts.entrypoint.main": "main",
"scripts.entrypoint.none": "Nessuna funzione trovata",
"scripts.field.enabled": "Abilitato",
"scripts.syntax.check": "Controlla sintassi",
"scripts.syntax.checking": "Controllo...",
"scripts.syntax.valid": "La sintassi Python è valida",
"scripts.syntax.invalid": "Errori di sintassi Python: {count}",
"scripts.syntax.checkFailed": "Controllo della sintassi Python non riuscito",
"scripts.kind.utility": "utility",
"scripts.kind.macro": "macro",
"scripts.kind.transform": "transform",

View File

@@ -1,5 +1,6 @@
import { createPythonRuntimeWorker } from './createPythonRuntimeWorker';
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
import type { PythonSyntaxError } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
type WorkerFactory = () => Worker;
@@ -10,9 +11,9 @@ interface InitializeDeferred {
}
interface PendingRun {
kind: 'run' | 'macro-v1' | 'inspect-entrypoints';
kind: 'run' | 'macro-v1' | 'inspect-entrypoints' | 'syntax-check';
stdout: string;
resolve: (value: PythonRunResult | PythonMacroV1Result | string[]) => void;
resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
}
@@ -43,6 +44,10 @@ export interface PythonMacroV1Result {
stdout: string;
}
export interface PythonSyntaxCheckResult {
errors: PythonSyntaxError[];
}
export class PythonRuntimeManager {
private worker: Worker | null = null;
private initializingPromise: Promise<void> | null = null;
@@ -197,6 +202,42 @@ export class PythonRuntimeManager {
});
}
async syntaxCheck(code: string, options?: PythonExecuteOptions): Promise<PythonSyntaxCheckResult> {
await this.initialize();
if (!this.worker || !this.ready) {
throw new Error('Python runtime is not ready');
}
const requestId = this.nextRequestId();
const timeoutMs = options?.timeoutMs ?? 5000;
return new Promise<PythonSyntaxCheckResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingRuns.delete(requestId);
this.resetRuntime(`Python script execution timed out after ${timeoutMs}ms`);
reject(new Error(`Python script execution timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pendingRuns.set(requestId, {
kind: 'syntax-check',
stdout: '',
resolve: (value) => resolve(value as PythonSyntaxCheckResult),
reject,
timeoutId,
});
const message: PythonWorkerRequest = {
type: 'syntaxCheck',
requestId,
code,
cacheKey: options?.cacheKey,
};
this.worker!.postMessage(message);
});
}
isReady(): boolean {
return this.ready;
}
@@ -252,6 +293,15 @@ export class PythonRuntimeManager {
return;
}
if (payload.type === 'syntaxResult') {
if (pendingRun.kind !== 'syntax-check') {
pendingRun.reject(new Error('Invalid response type for pending syntax check request'));
return;
}
pendingRun.resolve({ errors: payload.errors });
return;
}
if (payload.type === 'macroResult') {
if (pendingRun.kind !== 'macro-v1') {
pendingRun.reject(new Error('Invalid response type for pending run request'));

View File

@@ -2,6 +2,7 @@ import { loadPyodide, type PyodideInterface } from 'pyodide';
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
let runtime: PyodideInterface | null = null;
let activeRequestId: string | null = null;
@@ -181,6 +182,39 @@ json.dumps(__bds_entrypoints)
}
}
async function syntaxCheck(request: PythonWorkerRequest): Promise<void> {
if (request.type !== 'syntaxCheck') {
return;
}
if (!runtime) {
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is not ready' });
return;
}
if (activeRequestId) {
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: 'Python runtime is busy' });
return;
}
activeRequestId = request.requestId;
try {
const errors = await runPythonSyntaxCheck(runtime, request.code);
postRuntimeMessage({
type: 'syntaxResult',
requestId: request.requestId,
errors,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
} finally {
activeRequestId = null;
}
}
async function bootstrapRuntime(): Promise<void> {
try {
const indexURL = resolvePyodideIndexURL(import.meta.url);
@@ -217,6 +251,11 @@ self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
if (request.type === 'inspectEntrypoints') {
void inspectEntrypoints(request);
return;
}
if (request.type === 'syntaxCheck') {
void syntaxCheck(request);
}
};

View File

@@ -0,0 +1,73 @@
import type { PyodideInterface } from 'pyodide';
import type { PythonSyntaxError } from './runtimeProtocol';
const SYNTAX_CHECK_SCRIPT = [
'import ast',
'import json',
'',
'__bds_syntax_errors = []',
'try:',
' ast.parse(__bds_syntax_source)',
'except SyntaxError as exc:',
' line = exc.lineno or 1',
' column = exc.offset or 1',
' end_line = getattr(exc, "end_lineno", None) or line',
' end_column = getattr(exc, "end_offset", None) or (column + 1)',
' __bds_syntax_errors.append({',
' "line": line,',
' "column": column,',
' "endLine": end_line,',
' "endColumn": end_column,',
' "message": exc.msg or "invalid syntax",',
' })',
'__bds_syntax_result_json = json.dumps({"errors": __bds_syntax_errors})',
'__bds_syntax_result_json',
].join('\n');
function toResultString(result: unknown): string {
if (typeof result === 'string') {
return result;
}
if (result === null || result === undefined) {
return '';
}
return String(result);
}
function parseSyntaxErrors(rawJsonResult: unknown): PythonSyntaxError[] {
const rawText = toResultString(rawJsonResult).trim();
if (rawText.length === 0) {
throw new Error('Python syntax check returned no JSON result');
}
const parsed = JSON.parse(rawText) as { errors?: unknown };
if (!Array.isArray(parsed.errors)) {
return [];
}
return parsed.errors
.filter((item): item is PythonSyntaxError => {
return item
&& typeof item === 'object'
&& Number.isFinite((item as { line?: number }).line)
&& Number.isFinite((item as { column?: number }).column)
&& Number.isFinite((item as { endLine?: number }).endLine)
&& Number.isFinite((item as { endColumn?: number }).endColumn)
&& typeof (item as { message?: unknown }).message === 'string';
})
.map((item) => ({
line: Math.max(1, Math.floor(item.line)),
column: Math.max(1, Math.floor(item.column)),
endLine: Math.max(1, Math.floor(item.endLine)),
endColumn: Math.max(1, Math.floor(item.endColumn)),
message: item.message,
}));
}
export async function runPythonSyntaxCheck(runtime: PyodideInterface, sourceCode: string): Promise<PythonSyntaxError[]> {
runtime.globals.set('__bds_syntax_source', sourceCode);
const rawJsonResult = await runtime.runPythonAsync(SYNTAX_CHECK_SCRIPT);
return parseSyntaxErrors(rawJsonResult);
}

View File

@@ -20,13 +20,28 @@ export type PythonWorkerRequest =
requestId: string;
code: string;
cacheKey?: string;
}
| {
type: 'syntaxCheck';
requestId: string;
code: string;
cacheKey?: string;
};
export interface PythonSyntaxError {
line: number;
column: number;
endLine: number;
endColumn: number;
message: string;
}
export type PythonWorkerMessage =
| { type: 'ready' }
| { type: 'error'; error: string }
| { type: 'stdout'; requestId: string; chunk: string }
| { type: 'runResult'; requestId: string; result: string }
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
| { type: 'runError'; requestId: string; error: string };