feat: added syntax check
This commit is contained in:
@@ -52,3 +52,9 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scripts-check-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import MonacoEditor from '@monaco-editor/react';
|
import MonacoEditor, { type Monaco } from '@monaco-editor/react';
|
||||||
import type { ScriptData } from '../../../main/shared/electronApi';
|
import type { ScriptData } from '../../../main/shared/electronApi';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
|
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
|
||||||
@@ -8,6 +8,28 @@ import { useI18n } from '../../i18n';
|
|||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import './ScriptsView.css';
|
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> = {
|
const UI_DATE_LOCALE: Record<string, string> = {
|
||||||
en: 'en-US',
|
en: 'en-US',
|
||||||
de: 'de-DE',
|
de: 'de-DE',
|
||||||
@@ -35,6 +57,9 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isSaving, setIsSaving] = 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 => {
|
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -57,6 +82,87 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
return normalized || 'script';
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -162,6 +268,11 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const isSyntaxValid = await handleCheckSyntax({ notify: true });
|
||||||
|
if (!isSyntaxValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const runtimeManager = getPythonRuntimeManager();
|
const runtimeManager = getPythonRuntimeManager();
|
||||||
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, {
|
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(scriptContent, {
|
||||||
cacheKey: buildCacheKey(script, scriptContent),
|
cacheKey: buildCacheKey(script, scriptContent),
|
||||||
@@ -279,10 +390,6 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
kind: 'error',
|
kind: 'error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
useAppStore.setState({
|
|
||||||
panelVisible: true,
|
|
||||||
panelActiveTab: 'output',
|
|
||||||
});
|
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -312,6 +419,16 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
>
|
>
|
||||||
{t('scripts.run')}
|
{t('scripts.run')}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary danger"
|
className="secondary danger"
|
||||||
@@ -408,6 +525,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
theme="vs-dark"
|
theme="vs-dark"
|
||||||
value={scriptContent}
|
value={scriptContent}
|
||||||
onChange={(value) => setScriptContent(value || '')}
|
onChange={(value) => setScriptContent(value || '')}
|
||||||
|
onMount={handleEditorDidMount}
|
||||||
options={{
|
options={{
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
|
|||||||
@@ -434,6 +434,11 @@
|
|||||||
"scripts.entrypoint.main": "main",
|
"scripts.entrypoint.main": "main",
|
||||||
"scripts.entrypoint.none": "Keine Funktionen gefunden",
|
"scripts.entrypoint.none": "Keine Funktionen gefunden",
|
||||||
"scripts.field.enabled": "Aktiviert",
|
"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.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
|||||||
@@ -434,6 +434,11 @@
|
|||||||
"scripts.entrypoint.main": "main",
|
"scripts.entrypoint.main": "main",
|
||||||
"scripts.entrypoint.none": "No functions found",
|
"scripts.entrypoint.none": "No functions found",
|
||||||
"scripts.field.enabled": "Enabled",
|
"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.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
|||||||
@@ -434,6 +434,11 @@
|
|||||||
"scripts.entrypoint.main": "main",
|
"scripts.entrypoint.main": "main",
|
||||||
"scripts.entrypoint.none": "No se encontraron funciones",
|
"scripts.entrypoint.none": "No se encontraron funciones",
|
||||||
"scripts.field.enabled": "Habilitado",
|
"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.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
|||||||
@@ -434,6 +434,11 @@
|
|||||||
"scripts.entrypoint.main": "main",
|
"scripts.entrypoint.main": "main",
|
||||||
"scripts.entrypoint.none": "Aucune fonction trouvée",
|
"scripts.entrypoint.none": "Aucune fonction trouvée",
|
||||||
"scripts.field.enabled": "Activé",
|
"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.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
|||||||
@@ -434,6 +434,11 @@
|
|||||||
"scripts.entrypoint.main": "main",
|
"scripts.entrypoint.main": "main",
|
||||||
"scripts.entrypoint.none": "Nessuna funzione trovata",
|
"scripts.entrypoint.none": "Nessuna funzione trovata",
|
||||||
"scripts.field.enabled": "Abilitato",
|
"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.utility": "utility",
|
||||||
"scripts.kind.macro": "macro",
|
"scripts.kind.macro": "macro",
|
||||||
"scripts.kind.transform": "transform",
|
"scripts.kind.transform": "transform",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createPythonRuntimeWorker } from './createPythonRuntimeWorker';
|
import { createPythonRuntimeWorker } from './createPythonRuntimeWorker';
|
||||||
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
||||||
|
import type { PythonSyntaxError } from './runtimeProtocol';
|
||||||
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
|
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
|
||||||
|
|
||||||
type WorkerFactory = () => Worker;
|
type WorkerFactory = () => Worker;
|
||||||
@@ -10,9 +11,9 @@ interface InitializeDeferred {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PendingRun {
|
interface PendingRun {
|
||||||
kind: 'run' | 'macro-v1' | 'inspect-entrypoints';
|
kind: 'run' | 'macro-v1' | 'inspect-entrypoints' | 'syntax-check';
|
||||||
stdout: string;
|
stdout: string;
|
||||||
resolve: (value: PythonRunResult | PythonMacroV1Result | string[]) => void;
|
resolve: (value: PythonRunResult | PythonMacroV1Result | string[] | PythonSyntaxCheckResult) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||||
}
|
}
|
||||||
@@ -43,6 +44,10 @@ export interface PythonMacroV1Result {
|
|||||||
stdout: string;
|
stdout: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PythonSyntaxCheckResult {
|
||||||
|
errors: PythonSyntaxError[];
|
||||||
|
}
|
||||||
|
|
||||||
export class PythonRuntimeManager {
|
export class PythonRuntimeManager {
|
||||||
private worker: Worker | null = null;
|
private worker: Worker | null = null;
|
||||||
private initializingPromise: Promise<void> | 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 {
|
isReady(): boolean {
|
||||||
return this.ready;
|
return this.ready;
|
||||||
}
|
}
|
||||||
@@ -252,6 +293,15 @@ export class PythonRuntimeManager {
|
|||||||
return;
|
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 (payload.type === 'macroResult') {
|
||||||
if (pendingRun.kind !== 'macro-v1') {
|
if (pendingRun.kind !== 'macro-v1') {
|
||||||
pendingRun.reject(new Error('Invalid response type for pending run request'));
|
pendingRun.reject(new Error('Invalid response type for pending run request'));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { loadPyodide, type PyodideInterface } from 'pyodide';
|
|||||||
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
||||||
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
|
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
|
||||||
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
|
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
|
||||||
|
import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
|
||||||
|
|
||||||
let runtime: PyodideInterface | null = null;
|
let runtime: PyodideInterface | null = null;
|
||||||
let activeRequestId: string | 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> {
|
async function bootstrapRuntime(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const indexURL = resolvePyodideIndexURL(import.meta.url);
|
const indexURL = resolvePyodideIndexURL(import.meta.url);
|
||||||
@@ -217,6 +251,11 @@ self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
|
|||||||
|
|
||||||
if (request.type === 'inspectEntrypoints') {
|
if (request.type === 'inspectEntrypoints') {
|
||||||
void inspectEntrypoints(request);
|
void inspectEntrypoints(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.type === 'syntaxCheck') {
|
||||||
|
void syntaxCheck(request);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
73
src/renderer/python/pythonSyntaxCheck.ts
Normal file
73
src/renderer/python/pythonSyntaxCheck.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -20,13 +20,28 @@ export type PythonWorkerRequest =
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
code: string;
|
code: string;
|
||||||
cacheKey?: 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 =
|
export type PythonWorkerMessage =
|
||||||
| { type: 'ready' }
|
| { type: 'ready' }
|
||||||
| { type: 'error'; error: string }
|
| { type: 'error'; error: string }
|
||||||
| { type: 'stdout'; requestId: string; chunk: string }
|
| { type: 'stdout'; requestId: string; chunk: string }
|
||||||
| { type: 'runResult'; requestId: string; result: string }
|
| { type: 'runResult'; requestId: string; result: string }
|
||||||
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
|
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
|
||||||
|
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
|
||||||
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
|
| { type: 'macroResult'; requestId: string; result: MacroResultV1 }
|
||||||
| { type: 'runError'; requestId: string; error: string };
|
| { type: 'runError'; requestId: string; error: string };
|
||||||
|
|||||||
@@ -6,15 +6,31 @@ import { useAppStore } from '../../../src/renderer/store';
|
|||||||
|
|
||||||
const executeMock = vi.fn();
|
const executeMock = vi.fn();
|
||||||
const inspectEntrypointsMock = vi.fn();
|
const inspectEntrypointsMock = vi.fn();
|
||||||
|
const syntaxCheckMock = vi.fn();
|
||||||
const monacoPropsSpy = vi.fn();
|
const monacoPropsSpy = vi.fn();
|
||||||
|
const setModelMarkersMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('@monaco-editor/react', () => ({
|
vi.mock('@monaco-editor/react', () => ({
|
||||||
default: (props: {
|
default: (props: {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value?: string) => void;
|
onChange?: (value?: string) => void;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
onMount?: (editor: unknown, monaco: unknown) => void;
|
||||||
}) => {
|
}) => {
|
||||||
monacoPropsSpy(props);
|
monacoPropsSpy(props);
|
||||||
|
props.onMount?.(
|
||||||
|
{
|
||||||
|
getModel: () => ({ uri: 'inmemory://script.py' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
editor: {
|
||||||
|
setModelMarkers: setModelMarkersMock,
|
||||||
|
},
|
||||||
|
MarkerSeverity: {
|
||||||
|
Error: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Script Content"
|
aria-label="Script Content"
|
||||||
@@ -29,6 +45,7 @@ vi.mock('../../../src/renderer/python/runtimeManagerInstance', () => ({
|
|||||||
getPythonRuntimeManager: () => ({
|
getPythonRuntimeManager: () => ({
|
||||||
execute: executeMock,
|
execute: executeMock,
|
||||||
inspectEntrypoints: inspectEntrypointsMock,
|
inspectEntrypoints: inspectEntrypointsMock,
|
||||||
|
syntaxCheck: syntaxCheckMock,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -38,6 +55,7 @@ describe('ScriptsView', () => {
|
|||||||
|
|
||||||
executeMock.mockResolvedValue({ result: '2', stdout: 'hello\n' });
|
executeMock.mockResolvedValue({ result: '2', stdout: 'hello\n' });
|
||||||
inspectEntrypointsMock.mockResolvedValue(['render', 'helper']);
|
inspectEntrypointsMock.mockResolvedValue(['render', 'helper']);
|
||||||
|
syntaxCheckMock.mockResolvedValue({ errors: [] });
|
||||||
|
|
||||||
(window as any).electronAPI = {
|
(window as any).electronAPI = {
|
||||||
...(window as any).electronAPI,
|
...(window as any).electronAPI,
|
||||||
@@ -232,12 +250,81 @@ describe('ScriptsView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const state = useAppStore.getState();
|
const state = useAppStore.getState();
|
||||||
expect(state.panelVisible).toBe(true);
|
expect(state.panelVisible).toBe(false);
|
||||||
expect(state.panelActiveTab).toBe('output');
|
expect(state.panelActiveTab).toBe('tasks');
|
||||||
expect(state.panelOutputEntries.length).toBeGreaterThan(0);
|
expect(state.panelOutputEntries.length).toBeGreaterThan(0);
|
||||||
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
|
expect(state.panelOutputEntries[state.panelOutputEntries.length - 1].message).toContain('hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks syntax manually and writes editor markers for syntax errors', async () => {
|
||||||
|
syntaxCheckMock.mockResolvedValueOnce({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
line: 3,
|
||||||
|
column: 5,
|
||||||
|
endLine: 3,
|
||||||
|
endColumn: 10,
|
||||||
|
message: 'invalid syntax',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ScriptsView scriptId="script-1" />);
|
||||||
|
|
||||||
|
await screen.findByLabelText('Script Content');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Check Syntax' }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(syntaxCheckMock).toHaveBeenCalledWith('print("hello")', {
|
||||||
|
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||||
|
});
|
||||||
|
expect(setModelMarkersMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'scripts-python-syntax',
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
startLineNumber: 3,
|
||||||
|
startColumn: 5,
|
||||||
|
endLineNumber: 3,
|
||||||
|
endColumn: 10,
|
||||||
|
message: 'invalid syntax',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs syntax check automatically on save before updating script', async () => {
|
||||||
|
const updateMock = vi.fn().mockResolvedValue({
|
||||||
|
id: 'script-1',
|
||||||
|
projectId: 'default',
|
||||||
|
slug: 'hello_script',
|
||||||
|
title: 'Hello Script',
|
||||||
|
kind: 'utility',
|
||||||
|
entrypoint: 'render',
|
||||||
|
enabled: true,
|
||||||
|
version: 2,
|
||||||
|
filePath: '/tmp/hello-script.py',
|
||||||
|
content: 'print("hello")',
|
||||||
|
createdAt: '2026-02-22T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-02-22T00:01:00.000Z',
|
||||||
|
});
|
||||||
|
(window as any).electronAPI.scripts.update = updateMock;
|
||||||
|
|
||||||
|
render(<ScriptsView scriptId="script-1" />);
|
||||||
|
|
||||||
|
const titleInput = await screen.findByLabelText('Title');
|
||||||
|
fireEvent.change(titleInput, { target: { value: 'Hello Script Updated' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Script' }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(syntaxCheckMock).toHaveBeenCalledWith('print("hello")', {
|
||||||
|
cacheKey: expect.stringMatching(/^script-1:1:/),
|
||||||
|
});
|
||||||
|
expect(updateMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('runs selected non-main entrypoint function', async () => {
|
it('runs selected non-main entrypoint function', async () => {
|
||||||
render(<ScriptsView scriptId="script-1" />);
|
render(<ScriptsView scriptId="script-1" />);
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,47 @@ describe('PythonRuntimeManager', () => {
|
|||||||
await expect(inspectPromise).resolves.toEqual(['render', 'helper']);
|
await expect(inspectPromise).resolves.toEqual(['render', 'helper']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks syntax and returns structured syntax errors', async () => {
|
||||||
|
const worker = new MockWorker();
|
||||||
|
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||||
|
|
||||||
|
const initPromise = manager.initialize();
|
||||||
|
worker.emitMessage({ type: 'ready' });
|
||||||
|
await initPromise;
|
||||||
|
|
||||||
|
const syntaxPromise = manager.syntaxCheck('def broken(:\n pass');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const request = worker.postedMessages[0] as { type: string; requestId: string; code: string };
|
||||||
|
expect(request.type).toBe('syntaxCheck');
|
||||||
|
|
||||||
|
worker.emitMessage({
|
||||||
|
type: 'syntaxResult',
|
||||||
|
requestId: request.requestId,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
line: 1,
|
||||||
|
column: 11,
|
||||||
|
endLine: 1,
|
||||||
|
endColumn: 12,
|
||||||
|
message: 'invalid syntax',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(syntaxPromise).resolves.toEqual({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
line: 1,
|
||||||
|
column: 11,
|
||||||
|
endLine: 1,
|
||||||
|
endColumn: 12,
|
||||||
|
message: 'invalid syntax',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects when runtime returns run error', async () => {
|
it('rejects when runtime returns run error', async () => {
|
||||||
const worker = new MockWorker();
|
const worker = new MockWorker();
|
||||||
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
const manager = new PythonRuntimeManager(() => worker as unknown as Worker);
|
||||||
|
|||||||
43
tests/renderer/python/pythonSyntaxCheck.integration.test.ts
Normal file
43
tests/renderer/python/pythonSyntaxCheck.integration.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||||
|
import { runPythonSyntaxCheck } from '../../../src/renderer/python/pythonSyntaxCheck';
|
||||||
|
|
||||||
|
describe('pythonSyntaxCheck integration (real pyodide)', () => {
|
||||||
|
let runtime: PyodideInterface;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
runtime = await loadPyodide();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('returns no errors for syntactically valid python', async () => {
|
||||||
|
const source = [
|
||||||
|
'def normalize_blogmark(post):',
|
||||||
|
' title = (post.get("title") or "").strip()',
|
||||||
|
' if title:',
|
||||||
|
' post["title"] = title',
|
||||||
|
' return post',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const errors = await runPythonSyntaxCheck(runtime, source);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns structured syntax diagnostics for invalid python', async () => {
|
||||||
|
const source = [
|
||||||
|
'def normalize_blogmark(post):',
|
||||||
|
' if post.get("title")',
|
||||||
|
' return post',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const errors = await runPythonSyntaxCheck(runtime, source);
|
||||||
|
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors[0]).toEqual(expect.objectContaining({
|
||||||
|
line: 2,
|
||||||
|
message: expect.any(String),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user