From 5deb1cb0e473aa7018fb72b633081acd6bc34b83 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 01:45:32 +0100 Subject: [PATCH 01/22] H-5839: Extract Monaco setup into dedicated module with async loading Replace the monolithic useMonacoGlobalTypings hook with a monaco/ module that loads Monaco and @monaco-editor/react asynchronously, disables all built-in TS workers, and provides a custom completion provider. CodeEditor uses React 19 Suspense with use() to show a loading placeholder until Monaco is ready. Co-Authored-By: Claude Opus 4.6 --- .../src/hooks/use-monaco-global-typings.ts | 380 ------------------ .../{components => monaco}/code-editor.tsx | 67 +-- .../petrinaut/src/monaco/context.ts | 12 + .../petrinaut/src/monaco/provider.tsx | 108 +++++ libs/@hashintel/petrinaut/src/petrinaut.tsx | 36 +- .../differential-equation-properties.tsx | 4 +- .../PropertiesPanel/place-properties.tsx | 46 +-- .../PropertiesPanel/transition-properties.tsx | 11 +- 8 files changed, 194 insertions(+), 470 deletions(-) delete mode 100644 libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts rename libs/@hashintel/petrinaut/src/{components => monaco}/code-editor.tsx (63%) create mode 100644 libs/@hashintel/petrinaut/src/monaco/context.ts create mode 100644 libs/@hashintel/petrinaut/src/monaco/provider.tsx diff --git a/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts b/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts deleted file mode 100644 index 9dbfe496bde..00000000000 --- a/libs/@hashintel/petrinaut/src/hooks/use-monaco-global-typings.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { loader } from "@monaco-editor/react"; -import type * as Monaco from "monaco-editor"; -import { use, useEffect, useState } from "react"; - -import type { - Color, - DifferentialEquation, - Parameter, - Place, - Transition, -} from "../core/types/sdcpn"; -import { EditorContext } from "../state/editor-context"; -import { SDCPNContext } from "../state/sdcpn-context"; - -interface ReactTypeDefinitions { - react: string; - reactJsxRuntime: string; - reactDom: string; -} - -/** - * Fetch React type definitions from unpkg CDN - */ -async function fetchReactTypes(): Promise { - const [react, reactJsxRuntime, reactDom] = await Promise.all([ - fetch("https://unpkg.com/@types/react@18/index.d.ts").then((response) => - response.text(), - ), - fetch("https://unpkg.com/@types/react@18/jsx-runtime.d.ts").then( - (response) => response.text(), - ), - fetch("https://unpkg.com/@types/react-dom@18/index.d.ts").then((response) => - response.text(), - ), - ]); - - return { react, reactJsxRuntime, reactDom }; -} - -/** - * Convert a transition to a TypeScript definition string - */ -function transitionToTsDefinitionString( - transition: Transition, - placeIdToNameMap: Map, - placeIdToTypeMap: Map, -): string { - const input = - transition.inputArcs.length === 0 - ? "never" - : ` - {${transition.inputArcs - // Only include arcs whose places have defined types - .filter((arc) => placeIdToTypeMap.get(arc.placeId)) - .map((arc) => { - const placeTokenType = `SDCPNPlaces['${arc.placeId}']['type']['object']`; - return `"${placeIdToNameMap.get(arc.placeId)!}": [${Array.from({ length: arc.weight }).fill(placeTokenType).join(", ")}]`; - }) - .join(", ")} - }`; - - const output = - transition.outputArcs.length === 0 - ? "never" - : `{ - ${transition.outputArcs - // Only include arcs whose places have defined types - .filter((arc) => placeIdToTypeMap.get(arc.placeId)) - .map((arc) => { - const placeTokenType = `SDCPNPlaces['${arc.placeId}']['type']['object']`; - return `"${placeIdToNameMap.get(arc.placeId)!}": [${Array.from({ length: arc.weight }).fill(placeTokenType).join(", ")}]`; - }) - .join(", ")} - }`; - - return `{ - name: "${transition.name}"; - lambdaType: "${transition.lambdaType}"; - lambdaInputFn: (input: ${input}, parameters: SDCPNParametersValues) => ${transition.lambdaType === "predicate" ? "boolean" : "number"}; - transitionKernelFn: (input: ${input}, parameters: SDCPNParametersValues) => ${output}; - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN types - */ -function generateTypesDefinition(types: Color[]): string { - return `declare interface SDCPNTypes { - ${types - .map( - (type) => `"${type.id}": { - tuple: [${type.elements.map((el) => `${el.name}: ${el.type === "boolean" ? "boolean" : "number"}`).join(", ")}]; - object: { - ${type.elements - .map( - (el) => - `${el.name}: ${el.type === "boolean" ? "boolean" : "number"};`, - ) - .join("\n")} - }; - dynamicsFn: (input: SDCPNTypes["${type.id}"]["object"][], parameters: SDCPNParametersValues) => SDCPNTypes["${type.id}"]["object"][]; - }`, - ) - .join("\n")} - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN places - */ -function generatePlacesDefinition(places: Place[]): string { - return `declare interface SDCPNPlaces { - ${places - .map( - (place) => `"${place.id}": { - name: ${JSON.stringify(place.name)}; - type: ${place.colorId ? `SDCPNTypes["${place.colorId}"]` : "null"}; - dynamicsEnabled: ${place.dynamicsEnabled ? "true" : "false"}; - };`, - ) - .join("\n")}}`; -} - -/** - * Generate TypeScript type definitions for SDCPN transitions - */ -function generateTransitionsDefinition( - transitions: Transition[], - placeIdToNameMap: Map, - placeIdToTypeMap: Map, -): string { - return `declare interface SDCPNTransitions { - ${transitions - .map( - (transition) => - `"${transition.id}": ${transitionToTsDefinitionString(transition, placeIdToNameMap, placeIdToTypeMap)};`, - ) - .join("\n")} - }`; -} - -/** - * Generate TypeScript type definitions for SDCPN differential equations - */ -function generateDifferentialEquationsDefinition( - differentialEquations: DifferentialEquation[], -): string { - return `declare interface SDCPNDifferentialEquations { - ${differentialEquations - .map( - (diffEq) => `"${diffEq.id}": { - name: ${JSON.stringify(diffEq.name)}; - typeId: "${diffEq.colorId}"; - type: SDCPNTypes["${diffEq.colorId}"]; - };`, - ) - .join("\n")} - }`; -} - -function generateParametersDefinition(parameters: Parameter[]): string { - return `{${parameters - .map( - (param) => - `"${param.variableName}": ${param.type === "boolean" ? "boolean" : "number"}`, - ) - .join(", ")}}`; -} - -/** - * Generate complete SDCPN type definitions - */ -function generateSDCPNTypings( - types: Color[], - places: Place[], - transitions: Transition[], - differentialEquations: DifferentialEquation[], - parameters: Parameter[], - currentlySelectedItemId?: string, -): string { - // Generate a map from place IDs to names for easier reference - const placeIdToNameMap = new Map( - places.map((place) => [place.id, place.name]), - ); - const typeIdToTypeMap = new Map(types.map((type) => [type.id, type])); - const placeIdToTypeMap = new Map( - places.map((place) => [ - place.id, - place.colorId ? typeIdToTypeMap.get(place.colorId) : undefined, - ]), - ); - - const parametersDefinition = generateParametersDefinition(parameters); - const globalTypesDefinition = generateTypesDefinition(types); - const placesDefinition = generatePlacesDefinition(places); - const transitionsDefinition = generateTransitionsDefinition( - transitions, - placeIdToNameMap, - placeIdToTypeMap, - ); - const differentialEquationsDefinition = - generateDifferentialEquationsDefinition(differentialEquations); - - return ` -declare type SDCPNParametersValues = ${parametersDefinition}; - -${globalTypesDefinition} - -${placesDefinition} - -${transitionsDefinition} - -${differentialEquationsDefinition} - -// Define Lambda and TransitionKernel functions - -declare type SDCPNTransitionID = keyof SDCPNTransitions; - -${ - currentlySelectedItemId - ? `type __SelectedTransitionID = "${currentlySelectedItemId}"` - : `type __SelectedTransitionID = SDCPNTransitionID` -}; - -declare function Lambda(fn: SDCPNTransitions[TransitionId]['lambdaInputFn']): void; - -declare function TransitionKernel(fn: SDCPNTransitions[TransitionId]['transitionKernelFn']): void; - - -// Define Dynamics function - -type SDCPNDiffEqID = keyof SDCPNDifferentialEquations; - -${ - currentlySelectedItemId - ? `type __SelectedDiffEqID = "${currentlySelectedItemId}"` - : `type __SelectedDiffEqID = SDCPNDiffEqID` -}; - -declare function Dynamics(fn: SDCPNDifferentialEquations[DiffEqId]['type']['dynamicsFn']): void; - - -// Define Visualizer function - -type SDCPNPlaceID = keyof SDCPNPlaces; - -${ - currentlySelectedItemId - ? `type __SelectedPlaceID = "${currentlySelectedItemId}"` - : `type __SelectedPlaceID = SDCPNPlaceID` -}; - -declare function Visualization(fn: (props: { tokens: SDCPNPlaces[PlaceId]['type']['object'][], parameters: SDCPNParametersValues }) => React.JSX.Element): void; - - `.trim(); -} - -/** - * Configure Monaco TypeScript compiler options - */ -function configureMonacoCompilerOptions(monaco: typeof Monaco): void { - const ts = monaco.typescript; - - ts.typescriptDefaults.setCompilerOptions({ - target: ts.ScriptTarget.ES2020, - allowNonTsExtensions: true, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - module: ts.ModuleKind.ESNext, - noEmit: true, - esModuleInterop: true, - jsx: ts.JsxEmit.ReactJSX, - allowJs: false, - checkJs: false, - typeRoots: ["node_modules/@types"], - }); - - ts.javascriptDefaults.setCompilerOptions({ - target: ts.ScriptTarget.ES2020, - allowNonTsExtensions: true, - noEmit: true, - allowJs: true, - checkJs: false, - }); - - ts.typescriptDefaults.setDiagnosticsOptions({ - noSemanticValidation: false, - noSyntaxValidation: false, - }); -} - -/** - * Global hook to update Monaco's TypeScript context with SDCPN-derived typings. - * Should be called once at the app level to avoid race conditions. - */ -export function useMonacoGlobalTypings() { - const { - petriNetDefinition: { - types, - transitions, - parameters, - places, - differentialEquations, - }, - } = use(SDCPNContext); - - const { selectedResourceId: currentlySelectedItemId } = use(EditorContext); - - const [reactTypes, setReactTypes] = useState( - null, - ); - - // Configure Monaco and load React types once at startup - useEffect(() => { - void loader.init().then((monaco: typeof Monaco) => { - // Configure compiler options - configureMonacoCompilerOptions(monaco); - - // Fetch and set React types once - void fetchReactTypes().then((rTypes) => { - setReactTypes(rTypes); - - // Set React types as base extra libs - this is done only once - monaco.typescript.typescriptDefaults.setExtraLibs([ - { - content: rTypes.react, - filePath: "inmemory://sdcpn/node_modules/@types/react/index.d.ts", - }, - { - content: rTypes.reactJsxRuntime, - filePath: - "inmemory://sdcpn/node_modules/@types/react/jsx-runtime.d.ts", - }, - { - content: rTypes.reactDom, - filePath: - "inmemory://sdcpn/node_modules/@types/react-dom/index.d.ts", - }, - ]); - }); - }); - }, []); // Empty deps - run only once at startup - - // Update SDCPN typings whenever the model changes - useEffect(() => { - if (!reactTypes) { - return; // Wait for React types to load first - } - - void loader.init().then((monaco: typeof Monaco) => { - const sdcpnTypings = generateSDCPNTypings( - types, - places, - transitions, - differentialEquations, - parameters, - currentlySelectedItemId ?? undefined, - ); - - // Create or update SDCPN typings model - const sdcpnTypingsUri = monaco.Uri.parse( - "inmemory://sdcpn/sdcpn-globals.d.ts", - ); - const existingModel = monaco.editor.getModel(sdcpnTypingsUri); - - if (existingModel) { - existingModel.setValue(sdcpnTypings); - } else { - monaco.editor.createModel(sdcpnTypings, "typescript", sdcpnTypingsUri); - } - }); - }, [ - reactTypes, - types, - parameters, - places, - transitions, - differentialEquations, - currentlySelectedItemId, - ]); -} diff --git a/libs/@hashintel/petrinaut/src/components/code-editor.tsx b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx similarity index 63% rename from libs/@hashintel/petrinaut/src/components/code-editor.tsx rename to libs/@hashintel/petrinaut/src/monaco/code-editor.tsx index 2c0a62d349b..8ccef730cb4 100644 --- a/libs/@hashintel/petrinaut/src/components/code-editor.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/code-editor.tsx @@ -1,10 +1,10 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import type { EditorProps, Monaco } from "@monaco-editor/react"; -import MonacoEditor from "@monaco-editor/react"; import type { editor } from "monaco-editor"; -import { useCallback, useRef } from "react"; +import { Suspense, use, useCallback, useRef } from "react"; -import { Tooltip } from "./tooltip"; +import { Tooltip } from "../components/tooltip"; +import { MonacoContext } from "./context"; const containerStyle = cva({ base: { @@ -24,31 +24,34 @@ const containerStyle = cva({ }, }); +const loadingStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "2", + height: "full", + color: "fg.muted", + bg: "bg.subtle", + fontSize: "base", +}); + type CodeEditorProps = Omit & { tooltip?: string; }; -/** - * Code editor component that wraps Monaco Editor. - * - * @param tooltip - Optional tooltip to show when hovering over the editor. - * In read-only mode, the tooltip also appears when attempting to edit. - */ -export const CodeEditor: React.FC = ({ - tooltip, +const CodeEditorInner: React.FC = ({ options, - height, onMount, ...props }) => { - const isReadOnly = options?.readOnly === true; + const { Editor } = use(use(MonacoContext)); + const editorRef = useRef(null); const handleMount = useCallback( - (editorInstance: editor.IStandaloneCodeEditor, monaco: Monaco) => { + (editorInstance: editor.IStandaloneCodeEditor, monacoInstance: Monaco) => { editorRef.current = editorInstance; - // Call the original onMount if provided - onMount?.(editorInstance, monaco); + onMount?.(editorInstance, monacoInstance); }, [onMount], ); @@ -67,19 +70,35 @@ export const CodeEditor: React.FC = ({ ...options, }; + return ( + + ); +}; + +export const CodeEditor: React.FC = ({ + tooltip, + options, + height, + ...props +}) => { + const isReadOnly = options?.readOnly === true; + const editorElement = (
- + Loading editor...
} + > + + ); - // Regular tooltip for non-read-only mode (if tooltip is provided) if (tooltip) { return ( ; +}; + +export const MonacoContext = createContext>( + null as never, +); diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx new file mode 100644 index 00000000000..b09b0644aea --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -0,0 +1,108 @@ +import type * as Monaco from "monaco-editor"; + +import type { MonacoContextValue } from "./context"; +import { MonacoContext } from "./context"; + +interface LanguageDefaults { + setModeConfiguration(config: Record): void; +} + +interface TypeScriptNamespace { + typescriptDefaults: LanguageDefaults; + javascriptDefaults: LanguageDefaults; +} + +/** + * Disable all built-in TypeScript language worker features. + * Syntax highlighting (Monarch tokenizer) is retained since it runs client-side. + */ +function disableBuiltInTypeScriptFeatures(monaco: typeof Monaco) { + // The `typescript` namespace is marked deprecated in newer type definitions + // but the runtime API still exists and is the only way to control the TS worker. + const ts = monaco.languages.typescript as unknown as TypeScriptNamespace; + + const modeConfiguration: Record = { + completionItems: false, + hovers: false, + documentSymbols: false, + definitions: false, + references: false, + documentHighlights: false, + rename: false, + diagnostics: false, + documentRangeFormattingEdits: false, + signatureHelp: false, + onTypeFormattingEdits: false, + codeActions: false, + inlayHints: false, + }; + + ts.typescriptDefaults.setModeConfiguration(modeConfiguration); + ts.javascriptDefaults.setModeConfiguration(modeConfiguration); +} + +function registerCompletionProvider(monaco: typeof Monaco) { + monaco.languages.registerCompletionItemProvider("typescript", { + provideCompletionItems(model, position) { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + // eslint-disable-next-line no-console + console.log("Completion requested", { + position: { line: position.lineNumber, column: position.column }, + word: word.word, + range, + }); + + return { + suggestions: [ + { + label: "transition", + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: "transition", + range, + }, + ], + }; + }, + }); +} + +async function initMonaco(): Promise { + await new Promise((resolve) => setTimeout(resolve, 4000)); + + // Disable all workers — no worker files will be shipped or loaded. + (globalThis as Record).MonacoEnvironment = { + getWorker: undefined, + }; + + const [monaco, monacoReact] = await Promise.all([ + import("monaco-editor") as Promise, + import("@monaco-editor/react"), + ]); + + // Use local Monaco instance — no CDN fetch. + monacoReact.loader.config({ monaco }); + + disableBuiltInTypeScriptFeatures(monaco); + registerCompletionProvider(monaco); + return { monaco, Editor: monacoReact.default }; +} + +export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + // Stable promise reference — created once, never changes. + const monacoPromise = initMonaco(); + + return ( + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index c56d600da35..ad1d83d5008 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -11,7 +11,7 @@ import type { SDCPN, Transition, } from "./core/types/sdcpn"; -import { useMonacoGlobalTypings } from "./hooks/use-monaco-global-typings"; +import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; @@ -33,15 +33,6 @@ export type { Transition, }; -/** - * Internal component to initialize Monaco global typings. - * Must be inside SDCPNProvider to access the store. - */ -const MonacoSetup: React.FC = () => { - useMonacoGlobalTypings(); - return null; -}; - export type PetrinautProps = { /** * Nets other than this one which are available for selection, e.g. to switch to or to link from a transition. @@ -107,18 +98,19 @@ export const Petrinaut = ({ return ( - - - - - - - - - - + + + + + + + + + + + ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx index d2c5db0bcfb..330dbc1e7c4 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx @@ -5,7 +5,6 @@ import { useState } from "react"; import { TbDotsVertical, TbSparkles } from "react-icons/tb"; import { Button } from "../../../../components/button"; -import { CodeEditor } from "../../../../components/code-editor"; import { Input } from "../../../../components/input"; import { Menu } from "../../../../components/menu"; import { Tooltip } from "../../../../components/tooltip"; @@ -19,6 +18,7 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; const containerStyle = css({ @@ -476,6 +476,7 @@ export const DifferentialEquationProperties: React.FC< )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx index c80ae9f7119..7758dde9c67 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties.tsx @@ -1,6 +1,5 @@ /* eslint-disable id-length */ import { css } from "@hashintel/ds-helpers/css"; -import MonacoEditor from "@monaco-editor/react"; import { use, useEffect, useMemo, useRef, useState } from "react"; import { TbArrowRight, @@ -28,6 +27,7 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; import { PlaybackContext } from "../../../../playback/context"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; @@ -142,12 +142,6 @@ const codeHeaderLabelStyle = css({ fontSize: "[12px]", }); -const editorBorderStyle = css({ - border: "[1px solid rgba(0, 0, 0, 0.1)]", - borderRadius: "[4px]", - overflow: "hidden", -}); - const aiMenuItemStyle = css({ display: "flex", alignItems: "center", @@ -549,33 +543,17 @@ export const PlaceProperties: React.FC = ({ ]} /> -
- { - updatePlace(place.id, (existingPlace) => { - existingPlace.visualizerCode = value ?? ""; - }); - }} - theme="vs-light" - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - fontSize: 12, - lineNumbers: "off", - folding: true, - glyphMargin: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: 3, - padding: { top: 8, bottom: 8 }, - fixedOverflowWidgets: true, - }} - /> -
+ { + updatePlace(place.id, (existingPlace) => { + existingPlace.visualizerCode = value ?? ""; + }); + }} + /> )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx index 22201b754ee..f3bd01ef93e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx @@ -19,7 +19,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbDotsVertical, TbSparkles, TbTrash } from "react-icons/tb"; -import { CodeEditor } from "../../../../components/code-editor"; import { IconButton } from "../../../../components/icon-button"; import { Input } from "../../../../components/input"; import { Menu } from "../../../../components/menu"; @@ -31,6 +30,7 @@ import { generateDefaultTransitionKernelCode, } from "../../../../core/default-codes"; import type { Color, Place, Transition } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; @@ -480,12 +480,12 @@ export const TransitionProperties: React.FC = ({ )} `${a.placeId}:${a.weight}`) .join("-")}`} language="typescript" value={transition.lambdaCode || ""} - path={`inmemory://sdcpn/transitions/${transition.id}/lambda.ts`} height={340} onChange={(value) => { updateTransition(transition.id, (existingTransition) => { @@ -583,14 +583,9 @@ export const TransitionProperties: React.FC = ({ )} `${a.placeId}:${a.weight}`) - .join("-")}-${transition.outputArcs - .map((a) => `${a.placeId}:${a.weight}`) - .join("-")}`} + path={`inmemory://sdcpn/transitions/${transition.id}/transition-kernel.ts`} language="typescript" value={transition.transitionKernelCode || ""} - path={`inmemory://sdcpn/transitions/${transition.id}/transition-kernel.ts`} height={400} onChange={(value) => { updateTransition(transition.id, (existingTransition) => { From 16ab5396ffd7f4c640badaa7878174ef858b7ca1 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 14:21:33 +0100 Subject: [PATCH 02/22] H-5839: Consolidate checker into self-contained module Move core/checker/ to checker/lib/ and state/checker-context + checker-provider into checker/context and checker/provider, mirroring the monaco/ module structure. Add rollup-plugin-visualizer and externalize @monaco-editor/react from the bundle. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/package.json | 1 + .../checker-context.ts => checker/context.ts} | 2 +- .../checker => checker/lib}/checker.test.ts | 0 .../{core/checker => checker/lib}/checker.ts | 0 .../lib}/create-language-service-host.ts | 0 .../lib}/create-sdcpn-language-service.ts | 0 .../checker => checker/lib}/file-paths.ts | 0 .../lib}/helper/create-sdcpn.ts | 0 .../provider.tsx} | 6 ++--- libs/@hashintel/petrinaut/src/petrinaut.tsx | 10 +++---- .../components/BottomBar/bottom-bar.tsx | 2 +- .../BottomBar/diagnostics-indicator.tsx | 2 +- .../src/views/Editor/subviews/diagnostics.tsx | 2 +- yarn.lock | 27 +++++++++++++++++-- 14 files changed, 38 insertions(+), 14 deletions(-) rename libs/@hashintel/petrinaut/src/{state/checker-context.ts => checker/context.ts} (89%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/checker.test.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/checker.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/create-language-service-host.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/create-sdcpn-language-service.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/file-paths.ts (100%) rename libs/@hashintel/petrinaut/src/{core/checker => checker/lib}/helper/create-sdcpn.ts (100%) rename libs/@hashintel/petrinaut/src/{state/checker-provider.tsx => checker/provider.tsx} (77%) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index c07cdbc5008..78fa44ebf01 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -77,6 +77,7 @@ "jsdom": "24.1.3", "react": "19.2.3", "react-dom": "19.2.3", + "rollup-plugin-visualizer": "6.0.5", "vite": "8.0.0-beta.14", "vite-plugin-dts": "4.5.4", "vitest": "4.0.18" diff --git a/libs/@hashintel/petrinaut/src/state/checker-context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts similarity index 89% rename from libs/@hashintel/petrinaut/src/state/checker-context.ts rename to libs/@hashintel/petrinaut/src/checker/context.ts index 1ebf40f6f67..e7aa5988812 100644 --- a/libs/@hashintel/petrinaut/src/state/checker-context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -1,6 +1,6 @@ import { createContext } from "react"; -import type { SDCPNCheckResult } from "../core/checker/checker"; +import type { SDCPNCheckResult } from "./lib/checker"; export type CheckResult = SDCPNCheckResult; diff --git a/libs/@hashintel/petrinaut/src/core/checker/checker.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/checker.test.ts rename to libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/checker.ts rename to libs/@hashintel/petrinaut/src/checker/lib/checker.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts rename to libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts rename to libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/file-paths.ts b/libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/file-paths.ts rename to libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts rename to libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts diff --git a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx similarity index 77% rename from libs/@hashintel/petrinaut/src/state/checker-provider.tsx rename to libs/@hashintel/petrinaut/src/checker/provider.tsx index e4ba7b2c337..97b5a01ab11 100644 --- a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,8 +1,8 @@ import { use } from "react"; -import { checkSDCPN } from "../core/checker/checker"; -import { CheckerContext } from "./checker-context"; -import { SDCPNContext } from "./sdcpn-context"; +import { SDCPNContext } from "../state/sdcpn-context"; +import { checkSDCPN } from "./lib/checker"; +import { CheckerContext } from "./context"; export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index ad1d83d5008..022f4f91816 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -15,7 +15,7 @@ import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; -import { CheckerProvider } from "./state/checker-provider"; +import { CheckerProvider } from "./checker/provider"; import { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; import { EditorView } from "./views/Editor/editor-view"; @@ -98,8 +98,8 @@ export const Petrinaut = ({ return ( - - + + @@ -109,8 +109,8 @@ export const Petrinaut = ({ - - + + ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx index 0fca9f54bce..e82175b8e04 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx @@ -3,7 +3,7 @@ import { refractive } from "@hashintel/refractive"; import { use, useCallback, useEffect } from "react"; import { FaChevronDown, FaChevronUp } from "react-icons/fa6"; -import { CheckerContext } from "../../../../state/checker-context"; +import { CheckerContext } from "../../../../checker/context"; import { EditorContext, type EditorState, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx index 145fb52b49e..648a2b0ffa4 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx @@ -2,7 +2,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaCheck, FaXmark } from "react-icons/fa6"; -import { CheckerContext } from "../../../../state/checker-context"; +import { CheckerContext } from "../../../../checker/context"; import { ToolbarButton } from "./toolbar-button"; const iconContainerStyle = cva({ diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index b966ef21413..dafd2c85cfe 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -4,7 +4,7 @@ import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; import ts from "typescript"; import type { SubView } from "../../../components/sub-view/types"; -import { CheckerContext } from "../../../state/checker-context"; +import { CheckerContext } from "../../../checker/context"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; diff --git a/yarn.lock b/yarn.lock index 408032f7a54..36d17909c59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7824,6 +7824,7 @@ __metadata: react-dom: "npm:19.2.3" react-icons: "npm:5.5.0" reactflow: "npm:11.11.4" + rollup-plugin-visualizer: "npm:6.0.5" typescript: "npm:5.9.3" uuid: "npm:13.0.0" vite: "npm:8.0.0-beta.14" @@ -36724,7 +36725,7 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.0.4, open@npm:^8.4.2": +"open@npm:^8.0.0, open@npm:^8.0.4, open@npm:^8.4.2": version: 8.4.2 resolution: "open@npm:8.4.2" dependencies: @@ -40631,6 +40632,28 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-visualizer@npm:6.0.5": + version: 6.0.5 + resolution: "rollup-plugin-visualizer@npm:6.0.5" + dependencies: + open: "npm:^8.0.0" + picomatch: "npm:^4.0.2" + source-map: "npm:^0.7.4" + yargs: "npm:^17.5.1" + peerDependencies: + rolldown: 1.x || ^1.0.0-beta + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + bin: + rollup-plugin-visualizer: dist/bin/cli.js + checksum: 10c0/3824626e97d5033fbb3aa1bbe93c8c17a8569bc47e33c941bde6b90404f2cae70b26fec1b623bd393c3e076338014196c91726ed2c96218edc67e1f21676f7ef + languageName: node + linkType: hard + "rollup@npm:4.57.1, rollup@npm:^4.34.9, rollup@npm:^4.35.0, rollup@npm:^4.43.0": version: 4.57.1 resolution: "rollup@npm:4.57.1" @@ -46397,7 +46420,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: From 61de26200b55681699320d831989c12ee3e2e987 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 14:38:09 +0100 Subject: [PATCH 03/22] H-5839: Fix import paths after checker move and remove debug delay Update relative imports in checker/lib/ to reach core/types/sdcpn at the new depth. Sort imports per linter rules. Remove the 4s debug setTimeout from Monaco init. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/src/checker/lib/checker.ts | 2 +- .../petrinaut/src/checker/lib/create-sdcpn-language-service.ts | 2 +- .../@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts | 2 +- libs/@hashintel/petrinaut/src/checker/provider.tsx | 2 +- libs/@hashintel/petrinaut/src/monaco/provider.tsx | 2 -- libs/@hashintel/petrinaut/src/petrinaut.tsx | 2 +- .../petrinaut/src/views/Editor/subviews/diagnostics.tsx | 2 +- 7 files changed, 6 insertions(+), 8 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts index 33d170423ae..349b3aeddf1 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts @@ -1,6 +1,6 @@ import type ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; import { getItemFilePath } from "./file-paths"; diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index 87d16b0274e..33a05d29a34 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -1,6 +1,6 @@ import ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { createLanguageServiceHost, type VirtualFile, diff --git a/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts index eec76175ef0..644c469521d 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/helper/create-sdcpn.ts @@ -5,7 +5,7 @@ import type { Place, SDCPN, Transition, -} from "../../types/sdcpn"; +} from "../../../core/types/sdcpn"; type PartialColor = Omit, "elements"> & { elements?: Array>; diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index 97b5a01ab11..c39d4d24807 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,8 +1,8 @@ import { use } from "react"; import { SDCPNContext } from "../state/sdcpn-context"; -import { checkSDCPN } from "./lib/checker"; import { CheckerContext } from "./context"; +import { checkSDCPN } from "./lib/checker"; export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index b09b0644aea..7922ec5fce5 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -74,8 +74,6 @@ function registerCompletionProvider(monaco: typeof Monaco) { } async function initMonaco(): Promise { - await new Promise((resolve) => setTimeout(resolve, 4000)); - // Disable all workers — no worker files will be shipped or loaded. (globalThis as Record).MonacoEnvironment = { getWorker: undefined, diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index 022f4f91816..df237136b33 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -1,6 +1,7 @@ import "reactflow/dist/style.css"; import "./index.css"; +import { CheckerProvider } from "./checker/provider"; import type { Color, DifferentialEquation, @@ -15,7 +16,6 @@ import { MonacoProvider } from "./monaco/provider"; import { NotificationsProvider } from "./notifications/notifications-provider"; import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; -import { CheckerProvider } from "./checker/provider"; import { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; import { EditorView } from "./views/Editor/editor-view"; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index dafd2c85cfe..f2e3f8bd2f1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -3,8 +3,8 @@ import { use, useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; import ts from "typescript"; -import type { SubView } from "../../../components/sub-view/types"; import { CheckerContext } from "../../../checker/context"; +import type { SubView } from "../../../components/sub-view/types"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; From 27509b8e83ea14f538fe8d7a60ea4b7a2e5e2b84 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 15:54:29 +0100 Subject: [PATCH 04/22] H-5839: Move checker to WebWorker with JSON-RPC protocol Run TypeScript validation off the main thread to keep the UI responsive. The checker worker communicates via JSON-RPC 2.0 over postMessage, with diagnostics serialized (messageText flattened, ts.SourceFile stripped) before crossing the worker boundary. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/checker/context.ts | 10 +-- .../petrinaut/src/checker/provider.tsx | 27 +++++- .../src/checker/worker/checker.worker.ts | 62 +++++++++++++ .../petrinaut/src/checker/worker/protocol.ts | 63 ++++++++++++++ .../src/checker/worker/use-checker-worker.ts | 86 +++++++++++++++++++ .../src/views/Editor/subviews/diagnostics.tsx | 22 +---- 6 files changed, 242 insertions(+), 28 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts create mode 100644 libs/@hashintel/petrinaut/src/checker/worker/protocol.ts create mode 100644 libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts diff --git a/libs/@hashintel/petrinaut/src/checker/context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts index e7aa5988812..c312b234f96 100644 --- a/libs/@hashintel/petrinaut/src/checker/context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -1,13 +1,11 @@ import { createContext } from "react"; -import type { SDCPNCheckResult } from "./lib/checker"; - -export type CheckResult = SDCPNCheckResult; +import type { CheckerResult } from "./worker/protocol"; export interface CheckerContextValue { - /** The result of the last SDCPN check */ - checkResult: SDCPNCheckResult; - /** Total count of all diagnostics across all items */ + /** Result of the last SDCPN validation run. */ + checkResult: CheckerResult; + /** Total number of diagnostics across all items. */ totalDiagnosticsCount: number; } diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index c39d4d24807..4e5388cb950 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,15 +1,36 @@ -import { use } from "react"; +import { use, useEffect, useState } from "react"; import { SDCPNContext } from "../state/sdcpn-context"; import { CheckerContext } from "./context"; -import { checkSDCPN } from "./lib/checker"; +import type { CheckerResult } from "./worker/protocol"; +import { useCheckerWorker } from "./worker/use-checker-worker"; + +const EMPTY_RESULT: CheckerResult = { + isValid: true, + itemDiagnostics: [], +}; export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { petriNetDefinition } = use(SDCPNContext); + const { checkSDCPN } = useCheckerWorker(); + + const [checkResult, setCheckerResult] = useState(EMPTY_RESULT); + + useEffect(() => { + let cancelled = false; + + void checkSDCPN(petriNetDefinition).then((result) => { + if (!cancelled) { + setCheckerResult(result); + } + }); - const checkResult = checkSDCPN(petriNetDefinition); + return () => { + cancelled = true; + }; + }, [petriNetDefinition, checkSDCPN]); const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( (sum, item) => sum + item.diagnostics.length, diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts new file mode 100644 index 00000000000..bbc0f96ad6c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-restricted-globals */ +/** + * Checker WebWorker — runs TypeScript validation off the main thread. + * + * Receives JSON-RPC requests via `postMessage`, delegates to `checkSDCPN`, + * serializes the diagnostics (flatten messageText, strip non-cloneable fields), + * and posts the result back. + */ +import ts from "typescript"; + +import { checkSDCPN } from "../lib/checker"; +import type { + CheckerDiagnostic, + CheckerItemDiagnostics, + CheckerResult, + JsonRpcRequest, + JsonRpcResponse, +} from "./protocol"; + +/** Strip `ts.SourceFile` and flatten `DiagnosticMessageChain` for structured clone. */ +function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { + return { + category: diag.category, + code: diag.code, + messageText: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), + start: diag.start, + length: diag.length, + }; +} + +self.onmessage = ({ data: { id, params } }: MessageEvent) => { + try { + const raw = checkSDCPN(params.sdcpn); + + const result: CheckerResult = { + isValid: raw.isValid, + itemDiagnostics: raw.itemDiagnostics.map( + (item): CheckerItemDiagnostics => ({ + itemId: item.itemId, + itemType: item.itemType, + filePath: item.filePath, + diagnostics: item.diagnostics.map(serializeDiagnostic), + }), + ), + }; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + } catch (err) { + self.postMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }, + } satisfies JsonRpcResponse); + } +}; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts new file mode 100644 index 00000000000..fc63218f0a3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts @@ -0,0 +1,63 @@ +/** + * JSON-RPC 2.0 protocol types for the checker WebWorker. + * + * These types define the contract between the main thread and the worker. + * Diagnostic types are serializable (no `ts.SourceFile` references) so they + * can cross the postMessage boundary via structured clone. + */ +import type { SDCPN } from "../../core/types/sdcpn"; + +// --------------------------------------------------------------------------- +// Diagnostics — serializable variants of ts.Diagnostic +// --------------------------------------------------------------------------- + +/** A single TypeScript diagnostic, safe for structured clone. */ +export type CheckerDiagnostic = { + /** @see ts.DiagnosticCategory */ + category: number; + /** TypeScript error code (e.g. 2322 for type mismatch). */ + code: number; + /** Human-readable message, pre-flattened from `ts.DiagnosticMessageChain`. */ + messageText: string; + /** Character offset in user code where the error starts. */ + start: number | undefined; + /** Length of the error span in characters. */ + length: number | undefined; +}; + +/** Diagnostics grouped by SDCPN item (one transition function or differential equation). */ +export type CheckerItemDiagnostics = { + /** ID of the transition or differential equation. */ + itemId: string; + /** Which piece of code was checked. */ + itemType: "transition-lambda" | "transition-kernel" | "differential-equation"; + /** Path in the virtual file system used by the TS LanguageService. */ + filePath: string; + /** All diagnostics found in this item's code. */ + diagnostics: CheckerDiagnostic[]; +}; + +/** Result of validating an entire SDCPN model. */ +export type CheckerResult = { + /** `true` when every item compiles without errors. */ + isValid: boolean; + /** Per-item diagnostics (empty when valid). */ + itemDiagnostics: CheckerItemDiagnostics[]; +}; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 +// --------------------------------------------------------------------------- + +/** A JSON-RPC request sent from the main thread to the worker. */ +export type JsonRpcRequest = { + jsonrpc: "2.0"; + id: number; + method: "checkSDCPN"; + params: { sdcpn: SDCPN }; +}; + +/** A JSON-RPC response sent from the worker back to the main thread. */ +export type JsonRpcResponse = + | { jsonrpc: "2.0"; id: number; result: Result } + | { jsonrpc: "2.0"; id: number; error: { code: number; message: string } }; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts new file mode 100644 index 00000000000..e5627c34cd6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts @@ -0,0 +1,86 @@ +import { useEffect, useRef } from "react"; + +import type { SDCPN } from "../../core/types/sdcpn"; +import type { + CheckerResult, + JsonRpcRequest, + JsonRpcResponse, +} from "./protocol"; + +type Pending = { + resolve: (result: CheckerResult) => void; + reject: (error: Error) => void; +}; + +/** Methods exposed by the checker WebWorker. */ +export type CheckerWorkerApi = { + /** Validate all user code in an SDCPN model. Runs off the main thread. */ + checkSDCPN: (sdcpn: SDCPN) => Promise; +}; + +/** + * Spawn a checker WebWorker and return a Promise-based API to interact with it. + * The worker is created on mount and terminated on unmount. + */ +export function useCheckerWorker(): CheckerWorkerApi { + const workerRef = useRef(null); + const pendingRef = useRef(new Map()); + const nextId = useRef(0); + + useEffect(() => { + const worker = new Worker(new URL("./checker.worker.ts", import.meta.url), { + type: "module", + }); + + worker.onmessage = ( + event: MessageEvent>, + ) => { + const response = event.data; + const pending = pendingRef.current.get(response.id); + if (!pending) { + return; + } + pendingRef.current.delete(response.id); + + if ("error" in response) { + pending.reject(new Error(response.error.message)); + } else { + pending.resolve(response.result); + } + }; + + workerRef.current = worker; + const pending = pendingRef.current; + + return () => { + worker.terminate(); + workerRef.current = null; + for (const entry of pending.values()) { + entry.reject(new Error("Worker terminated")); + } + pending.clear(); + }; + }, []); + + const checkSDCPN = (sdcpn: SDCPN): Promise => { + const worker = workerRef.current; + if (!worker) { + return Promise.reject(new Error("Worker not initialized")); + } + + const id = nextId.current++; + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method: "checkSDCPN", + params: { sdcpn }, + }; + + return new Promise((resolve, reject) => { + pendingRef.current.set(id, { resolve, reject }); + worker.postMessage(request); + }); + }; + + return { checkSDCPN }; +} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index f2e3f8bd2f1..0eac7e15e5c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -1,9 +1,9 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; -import ts from "typescript"; import { CheckerContext } from "../../../checker/context"; +import type { CheckerDiagnostic } from "../../../checker/worker/protocol"; import type { SubView } from "../../../components/sub-view/types"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; @@ -94,22 +94,6 @@ const positionStyle = css({ marginLeft: "[8px]", }); -// --- Helpers --- - -/** - * Formats a TypeScript diagnostic message to a readable string - */ -function formatDiagnosticMessage( - messageText: string | ts.DiagnosticMessageChain, -): string { - if (typeof messageText === "string") { - return messageText; - } - return ts.flattenDiagnosticMessageText(messageText, "\n"); -} - -// --- Types --- - type EntityType = "transition" | "differential-equation"; interface GroupedDiagnostics { @@ -119,7 +103,7 @@ interface GroupedDiagnostics { errorCount: number; items: Array<{ subType: "lambda" | "kernel" | null; - diagnostics: ts.Diagnostic[]; + diagnostics: CheckerDiagnostic[]; }>; } @@ -269,7 +253,7 @@ const DiagnosticsContent: React.FC = () => { className={diagnosticButtonStyle} > - {formatDiagnosticMessage(diagnostic.messageText)} + {diagnostic.messageText} {diagnostic.start !== undefined && ( (pos: {diagnostic.start}) From 3eb9d44f1194009428ec05ac2859e866a4508256 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 17:10:00 +0100 Subject: [PATCH 05/22] H-5839: Bundle TypeScript into worker and remove CDN/polyfill workarounds TypeScript now runs exclusively in the checker WebWorker, so: - Move `typescript` from main bundle externals into worker bundle - Add process/isNodeLikeSystem replacements to eliminate Node.js code paths - Remove Node.js polyfill fallbacks from next.config.js (fs, path, os, etc.) - Remove CDN CSP exceptions since Monaco is loaded locally Co-Authored-By: Claude Opus 4.6 --- apps/hash-frontend/next.config.js | 14 -------------- apps/hash-frontend/src/lib/csp.ts | 13 +------------ libs/@hashintel/petrinaut/vite.config.ts | 12 +++++++++++- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 606f4cd6a5f..d6d6bef9f1d 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -219,20 +219,6 @@ export default withSentryConfig( // eslint-disable-next-line no-param-reassign webpackConfig.resolve.alias.canvas = false; - if (!isServer) { - // Stub Node.js built-ins for browser — needed by `typescript` (used by - // @hashintel/petrinaut's in-browser language service) - // eslint-disable-next-line no-param-reassign - webpackConfig.resolve.fallback = { - ...webpackConfig.resolve.fallback, - module: false, - fs: false, - path: false, - os: false, - perf_hooks: false, - }; - } - webpackConfig.plugins.push( new DefinePlugin({ __SENTRY_DEBUG__: false, diff --git a/apps/hash-frontend/src/lib/csp.ts b/apps/hash-frontend/src/lib/csp.ts index 171adfb0a5b..4b327029a49 100644 --- a/apps/hash-frontend/src/lib/csp.ts +++ b/apps/hash-frontend/src/lib/csp.ts @@ -24,9 +24,6 @@ export const buildCspHeader = (nonce: string): string => { "https://apis.google.com", // Vercel toolbar / live preview widget "https://vercel.live", - // @todo FE-488 will make this unnecessary - // Monaco Editor loaded from CDN by @monaco-editor/react (used by petrinaut) - "https://cdn.jsdelivr.net", ], "style-src": [ @@ -34,9 +31,6 @@ export const buildCspHeader = (nonce: string): string => { // Required for Emotion/MUI CSS-in-JS inline style injection. // @todo Use nonce-based approach via Emotion's cache `nonce` option. "'unsafe-inline'", - // @todo FE-488 will make this unnecessary - // Monaco Editor stylesheet loaded from CDN by @monaco-editor/react (used by petrinaut) - "https://cdn.jsdelivr.net", ], "img-src": [ @@ -51,12 +45,7 @@ export const buildCspHeader = (nonce: string): string => { ...(process.env.NODE_ENV === "development" ? ["http:"] : []), ], - "font-src": [ - "'self'", - // @todo FE-488 will make this unnecessary - // Monaco Editor CSS embeds the Codicon icon font as an inline base64 data URI - "data:", - ], + "font-src": ["'self'"], "connect-src": [ "'self'", diff --git a/libs/@hashintel/petrinaut/vite.config.ts b/libs/@hashintel/petrinaut/vite.config.ts index 744ed2004b9..d5b64395b11 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -21,7 +21,6 @@ export default defineConfig({ "react", "react-dom", "reactflow", - "typescript", "monaco-editor", "@babel/standalone", ], @@ -48,7 +47,17 @@ export default defineConfig({ // This causes crashes in Web Workers, since `window` is not defined there. // To prevent this, we do this resolution on our side. "typeof window": '"undefined"', + // TypeScript's internals reference process, process.versions.pnp, etc. + "typeof process": "'undefined'", + "typeof process.versions.pnp": "'undefined'", }), + // Separate replacePlugin for call-expression replacements: + // 1. Empty end delimiter because \b can't match after `)` (non-word → non-word). + // 2. Negative lookbehind skips the function definition (`function isNodeLikeSystem`). + replacePlugin( + { "isNodeLikeSystem()": "false" }, + { delimiters: ["(? Date: Thu, 19 Feb 2026 17:32:52 +0100 Subject: [PATCH 06/22] H-5839: Remove @dnd-kit and feature flags Arc reordering was behind a permanently-off feature flag, so all dnd-kit usage was dead code. Strip the DndContext/SortableContext wrappers, simplify SortableArcItem to a plain ArcItem, remove @dnd-kit dependencies, and delete the now-empty feature-flags module. Co-Authored-By: Claude Opus 4.6 --- .../@hashintel/petrinaut/src/feature-flags.ts | 3 - .../PropertiesPanel/sortable-arc-item.tsx | 64 +----- .../PropertiesPanel/transition-properties.tsx | 186 +++++------------- 3 files changed, 51 insertions(+), 202 deletions(-) delete mode 100644 libs/@hashintel/petrinaut/src/feature-flags.ts diff --git a/libs/@hashintel/petrinaut/src/feature-flags.ts b/libs/@hashintel/petrinaut/src/feature-flags.ts deleted file mode 100644 index 5d4bcdf431d..00000000000 --- a/libs/@hashintel/petrinaut/src/feature-flags.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const FEATURE_FLAGS = { - REORDER_TRANSITION_ARCS: false, -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx index d68cb37ac3c..bbfa37c91ce 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx @@ -1,12 +1,8 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { css, cva } from "@hashintel/ds-helpers/css"; -import { MdDragIndicator } from "react-icons/md"; +import { css } from "@hashintel/ds-helpers/css"; import { TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../components/icon-button"; import { NumberInput } from "../../../../components/number-input"; -import { FEATURE_FLAGS } from "../../../../feature-flags"; const containerStyle = css({ display: "flex", @@ -17,28 +13,6 @@ const containerStyle = css({ borderBottom: "[1px solid rgba(0, 0, 0, 0.06)]", }); -const dragHandleStyle = cva({ - base: { - display: "flex", - alignItems: "center", - flexShrink: 0, - }, - variants: { - isDisabled: { - true: { - cursor: "default", - color: "[#ccc]", - pointerEvents: "none", - }, - false: { - cursor: "grab", - color: "[#999]", - pointerEvents: "auto", - }, - }, - }, -}); - const placeNameStyle = css({ flex: "[1]", fontSize: "[14px]", @@ -68,22 +42,16 @@ const weightInputStyle = css({ padding: "[4px 8px]", }); -/** - * SortableArcItem - A draggable arc item that displays place name and weight - */ -interface SortableArcItemProps { - id: string; +interface ArcItemProps { placeName: string; weight: number; disabled?: boolean; - /** Tooltip to show when disabled (e.g., for read-only mode) */ tooltip?: string; onWeightChange: (weight: number) => void; onDelete?: () => void; } -export const SortableArcItem: React.FC = ({ - id, +export const ArcItem: React.FC = ({ placeName, weight, disabled = false, @@ -91,32 +59,8 @@ export const SortableArcItem: React.FC = ({ onWeightChange, onDelete, }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id, disabled }); - - const transformStyle = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - return ( -
- {FEATURE_FLAGS.REORDER_TRANSITION_ARCS && ( -
- -
- )} +
{placeName}
weight diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx index f3bd01ef93e..73a4b2a1e3e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx @@ -1,20 +1,5 @@ /* eslint-disable id-length */ /* eslint-disable curly */ -import type { DragEndEvent } from "@dnd-kit/core"; -import { - closestCenter, - DndContext, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbDotsVertical, TbSparkles, TbTrash } from "react-icons/tb"; @@ -34,7 +19,7 @@ import { CodeEditor } from "../../../../monaco/code-editor"; import { EditorContext } from "../../../../state/editor-context"; import { SDCPNContext } from "../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; -import { SortableArcItem } from "./sortable-arc-item"; +import { ArcItem } from "./sortable-arc-item"; const containerStyle = css({ display: "flex", @@ -182,55 +167,6 @@ export const TransitionProperties: React.FC = ({ const isReadOnly = useIsReadOnly(); const { globalMode } = use(EditorContext); - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const handleInputArcDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = transition.inputArcs.findIndex( - (arc) => arc.placeId === active.id, - ); - const newIndex = transition.inputArcs.findIndex( - (arc) => arc.placeId === over.id, - ); - - updateTransition(transition.id, (existingTransition) => { - existingTransition.inputArcs = arrayMove( - existingTransition.inputArcs, - oldIndex, - newIndex, - ); - }); - } - }; - - const handleOutputArcDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = transition.outputArcs.findIndex( - (arc) => arc.placeId === active.id, - ); - const newIndex = transition.outputArcs.findIndex( - (arc) => arc.placeId === over.id, - ); - - updateTransition(transition.id, (existingTransition) => { - existingTransition.outputArcs = arrayMove( - existingTransition.outputArcs, - oldIndex, - newIndex, - ); - }); - } - }; - const handleDeleteInputArc = (placeId: string) => { updateTransition(transition.id, (existingTransition) => { const index = existingTransition.inputArcs.findIndex( @@ -308,43 +244,29 @@ export const TransitionProperties: React.FC = ({
) : (
- - arc.placeId)} - strategy={verticalListSortingStrategy} - > - {transition.inputArcs.map((arc) => { - const place = places.find( - (placeItem) => placeItem.id === arc.placeId, - ); - return ( - { - onArcWeightUpdate( - transition.id, - "input", - arc.placeId, - weight, - ); - }} - onDelete={() => handleDeleteInputArc(arc.placeId)} - /> - ); - })} - - + {transition.inputArcs.map((arc) => { + const place = places.find( + (placeItem) => placeItem.id === arc.placeId, + ); + return ( + { + onArcWeightUpdate( + transition.id, + "input", + arc.placeId, + weight, + ); + }} + onDelete={() => handleDeleteInputArc(arc.placeId)} + /> + ); + })}
)}
@@ -357,43 +279,29 @@ export const TransitionProperties: React.FC = ({
) : (
- - arc.placeId)} - strategy={verticalListSortingStrategy} - > - {transition.outputArcs.map((arc) => { - const place = places.find( - (placeItem) => placeItem.id === arc.placeId, - ); - return ( - { - onArcWeightUpdate( - transition.id, - "output", - arc.placeId, - weight, - ); - }} - onDelete={() => handleDeleteOutputArc(arc.placeId)} - /> - ); - })} - - + {transition.outputArcs.map((arc) => { + const place = places.find( + (placeItem) => placeItem.id === arc.placeId, + ); + return ( + { + onArcWeightUpdate( + transition.id, + "output", + arc.placeId, + weight, + ); + }} + onDelete={() => handleDeleteOutputArc(arc.placeId)} + /> + ); + })}
)} From 806b1c72dd339fd1052e649c9d0917e68a88958d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 17:43:19 +0100 Subject: [PATCH 07/22] H-5839: Remove rollup-plugin-visualizer dependency Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/package.json | 1 - yarn.lock | 27 ++------------------------ 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 78fa44ebf01..c07cdbc5008 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -77,7 +77,6 @@ "jsdom": "24.1.3", "react": "19.2.3", "react-dom": "19.2.3", - "rollup-plugin-visualizer": "6.0.5", "vite": "8.0.0-beta.14", "vite-plugin-dts": "4.5.4", "vitest": "4.0.18" diff --git a/yarn.lock b/yarn.lock index 36d17909c59..408032f7a54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7824,7 +7824,6 @@ __metadata: react-dom: "npm:19.2.3" react-icons: "npm:5.5.0" reactflow: "npm:11.11.4" - rollup-plugin-visualizer: "npm:6.0.5" typescript: "npm:5.9.3" uuid: "npm:13.0.0" vite: "npm:8.0.0-beta.14" @@ -36725,7 +36724,7 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.0.0, open@npm:^8.0.4, open@npm:^8.4.2": +"open@npm:^8.0.4, open@npm:^8.4.2": version: 8.4.2 resolution: "open@npm:8.4.2" dependencies: @@ -40632,28 +40631,6 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-visualizer@npm:6.0.5": - version: 6.0.5 - resolution: "rollup-plugin-visualizer@npm:6.0.5" - dependencies: - open: "npm:^8.0.0" - picomatch: "npm:^4.0.2" - source-map: "npm:^0.7.4" - yargs: "npm:^17.5.1" - peerDependencies: - rolldown: 1.x || ^1.0.0-beta - rollup: 2.x || 3.x || 4.x - peerDependenciesMeta: - rolldown: - optional: true - rollup: - optional: true - bin: - rollup-plugin-visualizer: dist/bin/cli.js - checksum: 10c0/3824626e97d5033fbb3aa1bbe93c8c17a8569bc47e33c941bde6b90404f2cae70b26fec1b623bd393c3e076338014196c91726ed2c96218edc67e1f21676f7ef - languageName: node - linkType: hard - "rollup@npm:4.57.1, rollup@npm:^4.34.9, rollup@npm:^4.35.0, rollup@npm:^4.43.0": version: 4.57.1 resolution: "rollup@npm:4.57.1" @@ -46420,7 +46397,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: From 56eba32298c248fb3c543f71d113967df5ed2122 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 19 Feb 2026 18:49:09 +0100 Subject: [PATCH 08/22] H-5839: Wire checker diagnostics into Monaco editors as inline markers Bridge CheckerContext diagnostics to Monaco model markers so CodeEditor instances show squiggly underlines for type errors. Centralizes editor model paths in a single getEditorPath function used by both CodeEditor path props and the new DiagnosticsSync component. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/monaco/diagnostics-sync.tsx | 108 ++++++++++++++++++ .../petrinaut/src/monaco/editor-paths.ts | 16 +++ .../petrinaut/src/monaco/provider.tsx | 2 + .../differential-equation-properties.tsx | 3 +- .../PropertiesPanel/transition-properties.tsx | 5 +- 5 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx create mode 100644 libs/@hashintel/petrinaut/src/monaco/editor-paths.ts diff --git a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx new file mode 100644 index 00000000000..9bd907d02d5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -0,0 +1,108 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect, useRef } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerDiagnostic } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { getEditorPath } from "./editor-paths"; + +const OWNER = "checker"; + +/** Convert ts.DiagnosticCategory number to Monaco MarkerSeverity. */ +function toMarkerSeverity( + category: number, + monaco: typeof Monaco, +): Monaco.MarkerSeverity { + switch (category) { + case 0: + return monaco.MarkerSeverity.Warning; + case 1: + return monaco.MarkerSeverity.Error; + case 2: + return monaco.MarkerSeverity.Hint; + case 3: + return monaco.MarkerSeverity.Info; + default: + return monaco.MarkerSeverity.Error; + } +} + +/** Convert CheckerDiagnostic[] to IMarkerData[] using the model for offset→position. */ +function diagnosticsToMarkers( + model: Monaco.editor.ITextModel, + diagnostics: CheckerDiagnostic[], + monaco: typeof Monaco, +): Monaco.editor.IMarkerData[] { + return diagnostics.map((diag) => { + const start = model.getPositionAt(diag.start ?? 0); + const end = model.getPositionAt((diag.start ?? 0) + (diag.length ?? 0)); + return { + severity: toMarkerSeverity(diag.category, monaco), + message: diag.messageText, + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + code: String(diag.code), + }; + }); +} + +const DiagnosticsSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { checkResult } = use(CheckerContext); + const prevPathsRef = useRef>(new Set()); + + useEffect(() => { + const currentPaths = new Set(); + + for (const item of checkResult.itemDiagnostics) { + const path = getEditorPath(item.itemType, item.itemId); + const uri = monaco.Uri.parse(path); + const model = monaco.editor.getModel(uri); + if (model) { + const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + currentPaths.add(path); + } + + // Clear markers from models that no longer have diagnostics + for (const path of prevPathsRef.current) { + if (!currentPaths.has(path)) { + const uri = monaco.Uri.parse(path); + const model = monaco.editor.getModel(uri); + if (model) { + monaco.editor.setModelMarkers(model, OWNER, []); + } + } + } + + prevPathsRef.current = currentPaths; + + // Handle models created after diagnostics arrived + const disposable = monaco.editor.onDidCreateModel((model) => { + const modelUri = model.uri.toString(); + const item = checkResult.itemDiagnostics.find( + (i) => + monaco.Uri.parse(getEditorPath(i.itemType, i.itemId)).toString() === + modelUri, + ); + if (item) { + const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + }); + + return () => disposable.dispose(); + }, [checkResult, monaco]); + + return null; +}; + +/** Renders nothing visible — syncs CheckerContext diagnostics to Monaco model markers. */ +export const DiagnosticsSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts new file mode 100644 index 00000000000..cef45bd237d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts @@ -0,0 +1,16 @@ +import type { CheckerItemDiagnostics } from "../checker/worker/protocol"; + +/** Generates the Monaco model path for a given SDCPN item. */ +export function getEditorPath( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, +): string { + switch (itemType) { + case "transition-lambda": + return `inmemory://sdcpn/transitions/${itemId}/lambda.ts`; + case "transition-kernel": + return `inmemory://sdcpn/transitions/${itemId}/kernel.ts`; + case "differential-equation": + return `inmemory://sdcpn/differential-equations/${itemId}.ts`; + } +} diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index 7922ec5fce5..ce18102f437 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -2,6 +2,7 @@ import type * as Monaco from "monaco-editor"; import type { MonacoContextValue } from "./context"; import { MonacoContext } from "./context"; +import { DiagnosticsSync } from "./diagnostics-sync"; interface LanguageDefaults { setModeConfiguration(config: Record): void; @@ -100,6 +101,7 @@ export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ return ( + {children} ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx index 330dbc1e7c4..6556c3f2712 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx @@ -19,6 +19,7 @@ import type { Place, } from "../../../../core/types/sdcpn"; import { CodeEditor } from "../../../../monaco/code-editor"; +import { getEditorPath } from "../../../../monaco/editor-paths"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; const containerStyle = css({ @@ -476,7 +477,7 @@ export const DifferentialEquationProperties: React.FC< )} = ({ )} `${a.placeId}:${a.weight}`) .join("-")}`} @@ -491,7 +492,7 @@ export const TransitionProperties: React.FC = ({ )} Date: Fri, 20 Feb 2026 02:43:49 +0100 Subject: [PATCH 09/22] H-5839: Add completions, hover, and signature help providers to Monaco editors Wire TypeScript LanguageService features through the checker WebWorker to Monaco editors via JSON-RPC. The LanguageServiceHost is now mutable (per-file version tracking) so completions reflect current editor state. - Completions: registerCompletionItemProvider with ScriptElementKind mapping - Hover: registerHoverProvider backed by getQuickInfoAtPosition - Signature help: registerSignatureHelpProvider backed by getSignatureHelpItems - All three adjust offsets to account for injected declaration prefixes - Add tests for completions and updateFileContent in the language service Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/checker/context.ts | 29 +- .../petrinaut/src/checker/lib/checker.ts | 13 +- .../lib/create-language-service-host.ts | 23 +- .../lib/create-sdcpn-language-service.test.ts | 311 ++++++++++++++++++ .../lib/create-sdcpn-language-service.ts | 37 ++- .../petrinaut/src/checker/provider.tsx | 10 +- .../src/checker/worker/checker.worker.ts | 214 ++++++++++-- .../petrinaut/src/checker/worker/protocol.ts | 101 +++++- .../src/checker/worker/use-checker-worker.ts | 121 +++++-- .../petrinaut/src/monaco/completion-sync.tsx | 127 +++++++ .../petrinaut/src/monaco/editor-paths.ts | 30 ++ .../petrinaut/src/monaco/hover-sync.tsx | 58 ++++ .../petrinaut/src/monaco/provider.tsx | 39 +-- .../src/monaco/signature-help-sync.tsx | 73 ++++ 14 files changed, 1093 insertions(+), 93 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts create mode 100644 libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx create mode 100644 libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx create mode 100644 libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx diff --git a/libs/@hashintel/petrinaut/src/checker/context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts index c312b234f96..d95dea43201 100644 --- a/libs/@hashintel/petrinaut/src/checker/context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -1,12 +1,36 @@ import { createContext } from "react"; -import type { CheckerResult } from "./worker/protocol"; +import type { + CheckerCompletionResult, + CheckerItemDiagnostics, + CheckerQuickInfoResult, + CheckerResult, + CheckerSignatureHelpResult, +} from "./worker/protocol"; export interface CheckerContextValue { /** Result of the last SDCPN validation run. */ checkResult: CheckerResult; /** Total number of diagnostics across all items. */ totalDiagnosticsCount: number; + /** Request completions at a position within an SDCPN item. */ + getCompletions: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request quick info (hover) at a position within an SDCPN item. */ + getQuickInfo: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request signature help at a position within an SDCPN item. */ + getSignatureHelp: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; } const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { @@ -15,6 +39,9 @@ const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { itemDiagnostics: [], }, totalDiagnosticsCount: 0, + getCompletions: () => Promise.resolve({ items: [] }), + getQuickInfo: () => Promise.resolve(null), + getSignatureHelp: () => Promise.resolve(null), }; export const CheckerContext = createContext( diff --git a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts index 349b3aeddf1..61a5d27799a 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts @@ -1,7 +1,10 @@ import type ts from "typescript"; import type { SDCPN } from "../../core/types/sdcpn"; -import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import { + createSDCPNLanguageService, + type SDCPNLanguageService, +} from "./create-sdcpn-language-service"; import { getItemFilePath } from "./file-paths"; export type SDCPNDiagnostic = { @@ -29,8 +32,12 @@ export type SDCPNCheckResult = { * @param sdcpn - The SDCPN to check * @returns A result object indicating validity and any diagnostics */ -export function checkSDCPN(sdcpn: SDCPN): SDCPNCheckResult { - const languageService = createSDCPNLanguageService(sdcpn); +export function checkSDCPN( + sdcpn: SDCPN, + existingLanguageService?: SDCPNLanguageService, +): SDCPNCheckResult { + const languageService = + existingLanguageService ?? createSDCPNLanguageService(sdcpn); const itemDiagnostics: SDCPNDiagnostic[] = []; // Check all differential equations diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts index 3e85556c015..4e22135e8ce 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts @@ -42,9 +42,12 @@ export type VirtualFile = { * * Creates a TypeScript LanguageServiceHost for virtual SDCPN files */ -export function createLanguageServiceHost( - files: Map, -): ts.LanguageServiceHost { +export function createLanguageServiceHost(files: Map): { + host: ts.LanguageServiceHost; + updateFileContent: (fileName: string, content: string) => void; +} { + const versions = new Map(); + const getFileContent = (fileName: string): string | undefined => { const entry = files.get(fileName); if (entry) { @@ -62,10 +65,18 @@ export function createLanguageServiceHost( return undefined; }; - return { + const updateFileContent = (fileName: string, content: string) => { + const entry = files.get(fileName); + if (entry) { + entry.content = content; + versions.set(fileName, (versions.get(fileName) ?? 0) + 1); + } + }; + + const host: ts.LanguageServiceHost = { getScriptFileNames: () => [...files.keys()], getCompilationSettings: () => COMPILER_OPTIONS, - getScriptVersion: () => "0", + getScriptVersion: (fileName) => String(versions.get(fileName) ?? 0), getCurrentDirectory: () => "/", getDefaultLibFileName: () => "/lib.es2015.core.d.ts", @@ -82,4 +93,6 @@ export function createLanguageServiceHost( return getFileContent(path); }, }; + + return { host, updateFileContent }; } diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts new file mode 100644 index 00000000000..791b66f2c23 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; + +import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import { getItemFilePath } from "./file-paths"; +import { createSDCPN } from "./helper/create-sdcpn"; + +/** Cursor marker used in test code strings to indicate the completion position. */ +const CURSOR = "∫"; + +/** + * Find the offset of a cursor marker in user code. + * Returns the offset (without the marker) and the clean code. + */ +function parseCursor(codeWithCursor: string): { + offset: number; + code: string; +} { + const offset = codeWithCursor.indexOf(CURSOR); + if (offset === -1) { + throw new Error(`No cursor marker \`${CURSOR}\` found in code`); + } + const code = + codeWithCursor.slice(0, offset) + codeWithCursor.slice(offset + 1); + return { offset, code }; +} + +/** Extract just the completion names from a position. */ +function getCompletionNames( + sdcpnOptions: Parameters[0], + codeWithCursor: string, + target: + | { type: "transition-lambda"; transitionId?: string } + | { type: "transition-kernel"; transitionId?: string } + | { type: "differential-equation"; deId?: string }, +): string[] { + const { offset, code } = parseCursor(codeWithCursor); + + // Patch the SDCPN to inject the clean code at the right item + const patched = { ...sdcpnOptions }; + if ( + target.type === "transition-lambda" || + target.type === "transition-kernel" + ) { + const transitionId = target.transitionId ?? "t1"; + patched.transitions = (patched.transitions ?? []).map((tr) => + (tr.id ?? "t1") === transitionId + ? { + ...tr, + ...(target.type === "transition-lambda" + ? { lambdaCode: code } + : { transitionKernelCode: code }), + } + : tr, + ); + } else { + const deId = target.deId ?? "de_1"; + patched.differentialEquations = (patched.differentialEquations ?? []).map( + (de, index) => + (de.id ?? `de_${index + 1}`) === deId ? { ...de, code } : de, + ); + } + + const sdcpn = createSDCPN(patched); + const ls = createSDCPNLanguageService(sdcpn); + + let filePath: string; + if (target.type === "transition-lambda") { + filePath = getItemFilePath("transition-lambda-code", { + transitionId: target.transitionId ?? "t1", + }); + } else if (target.type === "transition-kernel") { + filePath = getItemFilePath("transition-kernel-code", { + transitionId: target.transitionId ?? "t1", + }); + } else { + filePath = getItemFilePath("differential-equation-code", { + id: target.deId ?? "de_1", + }); + } + + const completions = ls.getCompletionsAtPosition(filePath, offset, undefined); + return (completions?.entries ?? []).map((entry) => entry.name); +} + +describe("createSDCPNLanguageService completions", () => { + const baseSdcpn = { + types: [{ id: "color1", elements: [{ name: "x", type: "real" as const }] }], + places: [ + { id: "place1", name: "Source", colorId: "color1" }, + { id: "place2", name: "Target", colorId: "color1" }, + ], + parameters: [ + { id: "p1", variableName: "alpha", type: "real" as const }, + { id: "p2", variableName: "enabled", type: "boolean" as const }, + ], + transitions: [ + { + id: "t1", + lambdaType: "predicate" as const, + inputArcs: [{ placeId: "place1", weight: 1 }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode: "", + transitionKernelCode: "", + }, + ], + }; + + describe("member access completions (dot completions)", () => { + it("returns Number methods after `a.` where a is a number", () => { + const names = getCompletionNames( + baseSdcpn, + `const a = 42;\na.${CURSOR}`, + { + type: "transition-lambda", + }, + ); + + expect(names).toContain("toFixed"); + expect(names).toContain("toString"); + expect(names).toContain("valueOf"); + // Should NOT contain global scope items + expect(names).not.toContain("Array"); + expect(names).not.toContain("Lambda"); + }); + + it("returns parameter names after `parameters.`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n return parameters.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("alpha"); + expect(names).toContain("enabled"); + // Should NOT contain globals + expect(names).not.toContain("Array"); + }); + + it("returns place names after `input.`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n return input.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("Source"); + // Should NOT contain unrelated globals + expect(names).not.toContain("Array"); + }); + + it("returns token properties after `input.Source[0].`", () => { + const names = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters) => {\n const token = input.Source[0];\n return token.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("x"); + }); + + it("returns String methods after string expression", () => { + const names = getCompletionNames( + baseSdcpn, + `const s = "hello";\ns.${CURSOR}`, + { + type: "transition-lambda", + }, + ); + + expect(names).toContain("charAt"); + expect(names).toContain("indexOf"); + expect(names).not.toContain("Array"); + }); + }); + + describe("top-level completions (no dot)", () => { + it("returns globals and declared identifiers at top level", () => { + const names = getCompletionNames(baseSdcpn, `const a = 42;\n${CURSOR}`, { + type: "transition-lambda", + }); + + // Should include user-defined and prefix-declared identifiers + expect(names).toContain("a"); + expect(names).toContain("Lambda"); + // Should include globals + expect(names).toContain("Array"); + }); + }); + + describe("updateFileContent", () => { + it("returns completions for updated code, not original code", () => { + // Start with number code + const sdcpn = createSDCPN({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "const a = 42;\na.", + }, + ], + }); + + const ls = createSDCPNLanguageService(sdcpn); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // Update to string code + const { offset, code } = parseCursor(`const s = "hello";\ns.${CURSOR}`); + ls.updateFileContent(filePath, code); + + const completions = ls.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + const names = (completions?.entries ?? []).map((entry) => entry.name); + + // Should have String methods + expect(names).toContain("charAt"); + expect(names).toContain("indexOf"); + // Should NOT have Number methods + expect(names).not.toContain("toFixed"); + }); + + it("reflects new content after multiple updates", () => { + const sdcpn = createSDCPN({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "", + }, + ], + }); + + const ls = createSDCPNLanguageService(sdcpn); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // First update: number + const first = parseCursor(`const a = 42;\na.${CURSOR}`); + ls.updateFileContent(filePath, first.code); + const firstCompletions = ls.getCompletionsAtPosition( + filePath, + first.offset, + undefined, + ); + const firstNames = (firstCompletions?.entries ?? []).map( + (entry) => entry.name, + ); + expect(firstNames).toContain("toFixed"); + + // Second update: string + const second = parseCursor(`const s = "hello";\ns.${CURSOR}`); + ls.updateFileContent(filePath, second.code); + const secondCompletions = ls.getCompletionsAtPosition( + filePath, + second.offset, + undefined, + ); + const secondNames = (secondCompletions?.entries ?? []).map( + (entry) => entry.name, + ); + expect(secondNames).toContain("charAt"); + expect(secondNames).not.toContain("toFixed"); + }); + }); + + describe("differential equation completions", () => { + const deSdcpn = { + types: [ + { + id: "color1", + elements: [{ name: "velocity", type: "real" as const }], + }, + ], + parameters: [ + { id: "p1", variableName: "gravity", type: "real" as const }, + ], + differentialEquations: [ + { + id: "de1", + colorId: "color1", + code: "", + }, + ], + }; + + it("returns token properties after `tokens[0].`", () => { + const names = getCompletionNames( + deSdcpn, + `export default Dynamics((tokens, parameters) => {\n const t = tokens[0];\n return t.${CURSOR};\n});`, + { type: "differential-equation", deId: "de1" }, + ); + + expect(names).toContain("velocity"); + }); + + it("returns parameter names after `parameters.`", () => { + const names = getCompletionNames( + deSdcpn, + `export default Dynamics((tokens, parameters) => {\n return parameters.${CURSOR};\n});`, + { type: "differential-equation", deId: "de1" }, + ); + + expect(names).toContain("gravity"); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index 33a05d29a34..02064505ca5 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -7,7 +7,9 @@ import { } from "./create-language-service-host"; import { getItemFilePath } from "./file-paths"; -export type SDCPNLanguageService = ts.LanguageService; +export type SDCPNLanguageService = ts.LanguageService & { + updateFileContent: (fileName: string, content: string) => void; +}; /** * Sanitizes a color ID to be a valid TypeScript identifier. @@ -250,13 +252,15 @@ function adjustDiagnostics( */ export function createSDCPNLanguageService(sdcpn: SDCPN): SDCPNLanguageService { const files = generateVirtualFiles(sdcpn); - const host = createLanguageServiceHost(files); + const { host, updateFileContent } = createLanguageServiceHost(files); const baseService = ts.createLanguageService(host); // Proxy service to adjust positions for injected prefixes return { ...baseService, + updateFileContent, + getSemanticDiagnostics(fileName) { const entry = files.get(fileName); const prefixLength = entry?.prefix?.length ?? 0; @@ -280,5 +284,34 @@ export function createSDCPNLanguageService(sdcpn: SDCPN): SDCPNLanguageService { options, ); }, + + getQuickInfoAtPosition(fileName, position) { + const entry = files.get(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + const info = baseService.getQuickInfoAtPosition( + fileName, + position + prefixLength, + ); + if (!info) { + return undefined; + } + return { + ...info, + textSpan: { + start: info.textSpan.start - prefixLength, + length: info.textSpan.length, + }, + }; + }, + + getSignatureHelpItems(fileName, position, options) { + const entry = files.get(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + return baseService.getSignatureHelpItems( + fileName, + position + prefixLength, + options, + ); + }, }; } diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index 4e5388cb950..04ef268eef0 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -14,14 +14,15 @@ export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { petriNetDefinition } = use(SDCPNContext); - const { checkSDCPN } = useCheckerWorker(); + const { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp } = + useCheckerWorker(); const [checkResult, setCheckerResult] = useState(EMPTY_RESULT); useEffect(() => { let cancelled = false; - void checkSDCPN(petriNetDefinition).then((result) => { + void setSDCPN(petriNetDefinition).then((result) => { if (!cancelled) { setCheckerResult(result); } @@ -30,7 +31,7 @@ export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ return () => { cancelled = true; }; - }, [petriNetDefinition, checkSDCPN]); + }, [petriNetDefinition, setSDCPN]); const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( (sum, item) => sum + item.diagnostics.length, @@ -42,6 +43,9 @@ export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ value={{ checkResult, totalDiagnosticsCount, + getCompletions, + getQuickInfo, + getSignatureHelp, }} > {children} diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts index bbc0f96ad6c..caa4b826ca7 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -5,18 +5,34 @@ * Receives JSON-RPC requests via `postMessage`, delegates to `checkSDCPN`, * serializes the diagnostics (flatten messageText, strip non-cloneable fields), * and posts the result back. + * + * The LanguageService is persisted between calls so that `getCompletions` + * can reuse it without re-sending the full SDCPN model. */ import ts from "typescript"; import { checkSDCPN } from "../lib/checker"; +import { + createSDCPNLanguageService, + type SDCPNLanguageService, +} from "../lib/create-sdcpn-language-service"; +import { getItemFilePath } from "../lib/file-paths"; import type { + CheckerCompletionItem, + CheckerCompletionResult, CheckerDiagnostic, CheckerItemDiagnostics, + CheckerQuickInfoResult, CheckerResult, + CheckerSignatureHelpResult, + CheckerSignatureInfo, JsonRpcRequest, JsonRpcResponse, } from "./protocol"; +/** Persisted LanguageService — created on `setSDCPN`, reused by `getCompletions`. */ +let languageService: SDCPNLanguageService | null = null; + /** Strip `ts.SourceFile` and flatten `DiagnosticMessageChain` for structured clone. */ function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { return { @@ -28,27 +44,187 @@ function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { }; } -self.onmessage = ({ data: { id, params } }: MessageEvent) => { +/** Map (itemType, itemId) → checker virtual file path. */ +function getCheckerFilePath( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, +): string { + switch (itemType) { + case "transition-lambda": + return getItemFilePath("transition-lambda-code", { + transitionId: itemId, + }); + case "transition-kernel": + return getItemFilePath("transition-kernel-code", { + transitionId: itemId, + }); + case "differential-equation": + return getItemFilePath("differential-equation-code", { id: itemId }); + } +} + +self.onmessage = async ({ data }: MessageEvent) => { + const { id, method } = data; + try { - const raw = checkSDCPN(params.sdcpn); - - const result: CheckerResult = { - isValid: raw.isValid, - itemDiagnostics: raw.itemDiagnostics.map( - (item): CheckerItemDiagnostics => ({ - itemId: item.itemId, - itemType: item.itemType, - filePath: item.filePath, - diagnostics: item.diagnostics.map(serializeDiagnostic), - }), - ), - }; + switch (method) { + case "setSDCPN": { + const { sdcpn } = data.params; - self.postMessage({ - jsonrpc: "2.0", - id, - result, - } satisfies JsonRpcResponse); + languageService = createSDCPNLanguageService(sdcpn); + const raw = checkSDCPN(sdcpn, languageService); + + const result: CheckerResult = { + isValid: raw.isValid, + itemDiagnostics: raw.itemDiagnostics.map( + (item): CheckerItemDiagnostics => ({ + itemId: item.itemId, + itemType: item.itemType, + filePath: item.filePath, + diagnostics: item.diagnostics.map(serializeDiagnostic), + }), + ), + }; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + + case "getCompletions": { + const { itemType, itemId, offset } = data.params; + + // Wait before requesting completions, to be sure the file content is updated + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: { items: [] }, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const completions = languageService.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + + const items: CheckerCompletionItem[] = (completions?.entries ?? []).map( + (entry) => ({ + name: entry.name, + kind: entry.kind, + sortText: entry.sortText, + insertText: entry.insertText, + }), + ); + + self.postMessage({ + jsonrpc: "2.0", + id, + result: { items }, + } satisfies JsonRpcResponse); + break; + } + + case "getQuickInfo": { + const { itemType, itemId, offset } = data.params; + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: null, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const info = languageService.getQuickInfoAtPosition(filePath, offset); + + const result: CheckerQuickInfoResult = info + ? { + displayParts: ts.displayPartsToString(info.displayParts), + documentation: ts.displayPartsToString(info.documentation), + start: info.textSpan.start, + length: info.textSpan.length, + } + : null; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + + case "getSignatureHelp": { + const { itemType, itemId, offset } = data.params; + + // Wait a bit before requesting completions, to be sure the file content is updated + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + if (!languageService) { + self.postMessage({ + jsonrpc: "2.0", + id, + result: null, + } satisfies JsonRpcResponse); + break; + } + + const filePath = getCheckerFilePath(itemType, itemId); + const help = languageService.getSignatureHelpItems( + filePath, + offset, + undefined, + ); + + const result: CheckerSignatureHelpResult = help + ? { + activeSignature: help.selectedItemIndex, + activeParameter: help.argumentIndex, + signatures: help.items.map( + (item): CheckerSignatureInfo => ({ + label: [ + ...item.prefixDisplayParts, + ...item.parameters.flatMap((param, idx) => [ + ...(idx > 0 ? item.separatorDisplayParts : []), + ...param.displayParts, + ]), + ...item.suffixDisplayParts, + ] + .map((part) => part.text) + .join(""), + documentation: ts.displayPartsToString(item.documentation), + parameters: item.parameters.map((param) => ({ + label: ts.displayPartsToString(param.displayParts), + documentation: ts.displayPartsToString(param.documentation), + })), + }), + ), + } + : null; + + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies JsonRpcResponse); + break; + } + } } catch (err) { self.postMessage({ jsonrpc: "2.0", diff --git a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts index fc63218f0a3..7e52045904c 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts @@ -45,17 +45,106 @@ export type CheckerResult = { itemDiagnostics: CheckerItemDiagnostics[]; }; +// --------------------------------------------------------------------------- +// Completions — serializable variants of ts.CompletionEntry +// --------------------------------------------------------------------------- + +/** A single completion suggestion, safe for structured clone. */ +export type CheckerCompletionItem = { + name: string; + /** @see ts.ScriptElementKind */ + kind: string; + sortText: string; + insertText?: string; +}; + +/** Result of requesting completions at a position. */ +export type CheckerCompletionResult = { + items: CheckerCompletionItem[]; +}; + +// --------------------------------------------------------------------------- +// Quick Info (hover) — serializable variant of ts.QuickInfo +// --------------------------------------------------------------------------- + +/** Result of requesting quick info (hover) at a position. */ +export type CheckerQuickInfoResult = { + /** Type/signature display string. */ + displayParts: string; + /** JSDoc documentation string. */ + documentation: string; + /** Offset in user code where the hovered symbol starts. */ + start: number; + /** Length of the hovered symbol span. */ + length: number; +} | null; + +// --------------------------------------------------------------------------- +// Signature Help — serializable variant of ts.SignatureHelpItems +// --------------------------------------------------------------------------- + +/** A single parameter in a signature. */ +export type CheckerSignatureParameter = { + label: string; + documentation: string; +}; + +/** A single signature (overload). */ +export type CheckerSignatureInfo = { + label: string; + documentation: string; + parameters: CheckerSignatureParameter[]; +}; + +/** Result of requesting signature help at a position. */ +export type CheckerSignatureHelpResult = { + signatures: CheckerSignatureInfo[]; + activeSignature: number; + activeParameter: number; +} | null; + // --------------------------------------------------------------------------- // JSON-RPC 2.0 // --------------------------------------------------------------------------- /** A JSON-RPC request sent from the main thread to the worker. */ -export type JsonRpcRequest = { - jsonrpc: "2.0"; - id: number; - method: "checkSDCPN"; - params: { sdcpn: SDCPN }; -}; +export type JsonRpcRequest = + | { + jsonrpc: "2.0"; + id: number; + method: "setSDCPN"; + params: { sdcpn: SDCPN }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getCompletions"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getQuickInfo"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + } + | { + jsonrpc: "2.0"; + id: number; + method: "getSignatureHelp"; + params: { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; + offset: number; + }; + }; /** A JSON-RPC response sent from the worker back to the main thread. */ export type JsonRpcResponse = diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts index e5627c34cd6..82d63cbca62 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts @@ -1,21 +1,43 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { SDCPN } from "../../core/types/sdcpn"; import type { + CheckerCompletionResult, + CheckerItemDiagnostics, + CheckerQuickInfoResult, CheckerResult, + CheckerSignatureHelpResult, JsonRpcRequest, JsonRpcResponse, } from "./protocol"; type Pending = { - resolve: (result: CheckerResult) => void; + resolve: (result: never) => void; reject: (error: Error) => void; }; /** Methods exposed by the checker WebWorker. */ export type CheckerWorkerApi = { - /** Validate all user code in an SDCPN model. Runs off the main thread. */ - checkSDCPN: (sdcpn: SDCPN) => Promise; + /** Send an SDCPN model to the worker. Persists the LanguageService and returns diagnostics. */ + setSDCPN: (sdcpn: SDCPN) => Promise; + /** Request completions at a position within an SDCPN item. */ + getCompletions: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request quick info (hover) at a position within an SDCPN item. */ + getQuickInfo: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; + /** Request signature help at a position within an SDCPN item. */ + getSignatureHelp: ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ) => Promise; }; /** @@ -32,9 +54,7 @@ export function useCheckerWorker(): CheckerWorkerApi { type: "module", }); - worker.onmessage = ( - event: MessageEvent>, - ) => { + worker.onmessage = (event: MessageEvent) => { const response = event.data; const pending = pendingRef.current.get(response.id); if (!pending) { @@ -45,7 +65,7 @@ export function useCheckerWorker(): CheckerWorkerApi { if ("error" in response) { pending.reject(new Error(response.error.message)); } else { - pending.resolve(response.result); + pending.resolve(response.result as never); } }; @@ -62,25 +82,84 @@ export function useCheckerWorker(): CheckerWorkerApi { }; }, []); - const checkSDCPN = (sdcpn: SDCPN): Promise => { + const sendRequest = useCallback((request: JsonRpcRequest): Promise => { const worker = workerRef.current; if (!worker) { return Promise.reject(new Error("Worker not initialized")); } - const id = nextId.current++; - const request: JsonRpcRequest = { - jsonrpc: "2.0", - id, - method: "checkSDCPN", - params: { sdcpn }, - }; - - return new Promise((resolve, reject) => { - pendingRef.current.set(id, { resolve, reject }); + return new Promise((resolve, reject) => { + pendingRef.current.set(request.id, { + resolve: resolve as (result: never) => void, + reject, + }); worker.postMessage(request); }); - }; + }, []); + + const setSDCPN = useCallback( + (sdcpn: SDCPN): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "setSDCPN", + params: { sdcpn }, + }); + }, + [sendRequest], + ); + + const getCompletions = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getCompletions", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); + + const getQuickInfo = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getQuickInfo", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); + + const getSignatureHelp = useCallback( + ( + itemType: CheckerItemDiagnostics["itemType"], + itemId: string, + offset: number, + ): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "getSignatureHelp", + params: { itemType, itemId, offset }, + }); + }, + [sendRequest], + ); - return { checkSDCPN }; + return { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp }; } diff --git a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx new file mode 100644 index 00000000000..9a9cbbe0868 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx @@ -0,0 +1,127 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerCompletionItem } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +/** + * Map TypeScript `ScriptElementKind` strings to Monaco `CompletionItemKind`. + * @see https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts + */ +function toCompletionItemKind( + kind: string, + monaco: typeof Monaco, +): Monaco.languages.CompletionItemKind { + switch (kind) { + case "method": + case "construct": + return monaco.languages.CompletionItemKind.Method; + case "function": + case "local function": + return monaco.languages.CompletionItemKind.Function; + case "constructor": + return monaco.languages.CompletionItemKind.Constructor; + case "property": + case "getter": + case "setter": + return monaco.languages.CompletionItemKind.Property; + case "parameter": + case "var": + case "local var": + case "let": + return monaco.languages.CompletionItemKind.Variable; + case "const": + return monaco.languages.CompletionItemKind.Variable; + case "class": + return monaco.languages.CompletionItemKind.Class; + case "interface": + return monaco.languages.CompletionItemKind.Interface; + case "type": + case "type parameter": + case "primitive type": + case "alias": + return monaco.languages.CompletionItemKind.TypeParameter; + case "enum": + return monaco.languages.CompletionItemKind.Enum; + case "enum member": + return monaco.languages.CompletionItemKind.EnumMember; + case "module": + case "external module name": + return monaco.languages.CompletionItemKind.Module; + case "keyword": + return monaco.languages.CompletionItemKind.Keyword; + case "string": + return monaco.languages.CompletionItemKind.Value; + default: + return monaco.languages.CompletionItemKind.Text; + } +} + +function toMonacoCompletion( + entry: CheckerCompletionItem, + range: Monaco.IRange, + monaco: typeof Monaco, +): Monaco.languages.CompletionItem { + return { + label: entry.name, + kind: toCompletionItemKind(entry.kind, monaco), + insertText: entry.insertText ?? entry.name, + sortText: entry.sortText, + range, + }; +} + +const CompletionSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getCompletions } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerCompletionItemProvider( + "typescript", + { + triggerCharacters: ["."], + + async provideCompletionItems(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return { suggestions: [] }; + } + + const offset = model.getOffsetAt(position); + const result = await getCompletions( + parsed.itemType, + parsed.itemId, + offset, + ); + + const word = model.getWordUntilPosition(position); + const range: Monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + return { + suggestions: result.items.map((item) => + toMonacoCompletion(item, range, monaco), + ), + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, getCompletions]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco CompletionItemProvider backed by the checker worker. */ +export const CompletionSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts index cef45bd237d..f6f167e5ef6 100644 --- a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts +++ b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts @@ -14,3 +14,33 @@ export function getEditorPath( return `inmemory://sdcpn/differential-equations/${itemId}.ts`; } } + +const TRANSITION_LAMBDA_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/lambda\.ts$/; +const TRANSITION_KERNEL_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/kernel\.ts$/; +const DIFFERENTIAL_EQUATION_RE = + /^inmemory:\/\/sdcpn\/differential-equations\/([^/]+)\.ts$/; + +/** Extract `(itemType, itemId)` from a Monaco model URI string. */ +export function parseEditorPath(uri: string): { + itemType: CheckerItemDiagnostics["itemType"]; + itemId: string; +} | null { + let match = TRANSITION_LAMBDA_RE.exec(uri); + if (match) { + return { itemType: "transition-lambda", itemId: match[1]! }; + } + + match = TRANSITION_KERNEL_RE.exec(uri); + if (match) { + return { itemType: "transition-kernel", itemId: match[1]! }; + } + + match = DIFFERENTIAL_EQUATION_RE.exec(uri); + if (match) { + return { itemType: "differential-equation", itemId: match[1]! }; + } + + return null; +} diff --git a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx new file mode 100644 index 00000000000..06b3bd14d87 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -0,0 +1,58 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +const HoverSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getQuickInfo } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerHoverProvider("typescript", { + async provideHover(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return null; + } + + const offset = model.getOffsetAt(position); + const info = await getQuickInfo(parsed.itemType, parsed.itemId, offset); + + if (!info) { + return null; + } + + const startPos = model.getPositionAt(info.start); + const endPos = model.getPositionAt(info.start + info.length); + const range: Monaco.IRange = { + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + }; + + const contents: Monaco.IMarkdownString[] = [ + { value: `\`\`\`typescript\n${info.displayParts}\n\`\`\`` }, + ]; + if (info.documentation) { + contents.push({ value: info.documentation }); + } + + return { range, contents }; + }, + }); + + return () => disposable.dispose(); + }, [monaco, getQuickInfo]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco HoverProvider backed by the checker worker. */ +export const HoverSync: React.FC = () => ( + + + +); diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index ce18102f437..6488438e526 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -1,8 +1,11 @@ import type * as Monaco from "monaco-editor"; +import { CompletionSync } from "./completion-sync"; import type { MonacoContextValue } from "./context"; import { MonacoContext } from "./context"; import { DiagnosticsSync } from "./diagnostics-sync"; +import { HoverSync } from "./hover-sync"; +import { SignatureHelpSync } from "./signature-help-sync"; interface LanguageDefaults { setModeConfiguration(config: Record): void; @@ -42,38 +45,6 @@ function disableBuiltInTypeScriptFeatures(monaco: typeof Monaco) { ts.javascriptDefaults.setModeConfiguration(modeConfiguration); } -function registerCompletionProvider(monaco: typeof Monaco) { - monaco.languages.registerCompletionItemProvider("typescript", { - provideCompletionItems(model, position) { - const word = model.getWordUntilPosition(position); - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - }; - - // eslint-disable-next-line no-console - console.log("Completion requested", { - position: { line: position.lineNumber, column: position.column }, - word: word.word, - range, - }); - - return { - suggestions: [ - { - label: "transition", - kind: monaco.languages.CompletionItemKind.Keyword, - insertText: "transition", - range, - }, - ], - }; - }, - }); -} - async function initMonaco(): Promise { // Disable all workers — no worker files will be shipped or loaded. (globalThis as Record).MonacoEnvironment = { @@ -89,7 +60,6 @@ async function initMonaco(): Promise { monacoReact.loader.config({ monaco }); disableBuiltInTypeScriptFeatures(monaco); - registerCompletionProvider(monaco); return { monaco, Editor: monacoReact.default }; } @@ -102,6 +72,9 @@ export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ return ( + + + {children} ); diff --git a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx new file mode 100644 index 00000000000..54f71f59f8b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -0,0 +1,73 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; + +import { CheckerContext } from "../checker/context"; +import type { CheckerSignatureHelpResult } from "../checker/worker/protocol"; +import { MonacoContext } from "./context"; +import { parseEditorPath } from "./editor-paths"; + +function toMonacoSignatureHelp( + result: NonNullable, +): Monaco.languages.SignatureHelp { + return { + activeSignature: result.activeSignature, + activeParameter: result.activeParameter, + signatures: result.signatures.map((sig) => ({ + label: sig.label, + documentation: sig.documentation || undefined, + parameters: sig.parameters.map((param) => ({ + label: param.label, + documentation: param.documentation || undefined, + })), + })), + }; +} + +const SignatureHelpSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { getSignatureHelp } = use(CheckerContext); + + useEffect(() => { + const disposable = monaco.languages.registerSignatureHelpProvider( + "typescript", + { + signatureHelpTriggerCharacters: ["(", ","], + signatureHelpRetriggerCharacters: [","], + + async provideSignatureHelp(model, position) { + const parsed = parseEditorPath(model.uri.toString()); + if (!parsed) { + return null; + } + + const offset = model.getOffsetAt(position); + const result = await getSignatureHelp( + parsed.itemType, + parsed.itemId, + offset, + ); + + if (!result) { + return null; + } + + return { + value: toMonacoSignatureHelp(result), + dispose() {}, + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, getSignatureHelp]); + + return null; +}; + +/** Renders nothing visible — registers a Monaco SignatureHelpProvider backed by the checker worker. */ +export const SignatureHelpSync: React.FC = () => ( + + + +); From bc532615e72ffa3c21137ac2ff55fa3111b66c80 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 26 Feb 2026 13:13:30 +0100 Subject: [PATCH 10/22] H-5839: Adopt LSP-like protocol and reuse TS LanguageService across SDCPN changes Convert the checker communication layer from ad-hoc method names and types to an LSP-inspired protocol (initialize, sdcpn/didChange, textDocument/didChange, textDocument/completion, textDocument/hover, textDocument/signatureHelp), with push-based diagnostics via textDocument/publishDiagnostics notifications. Replace the factory function with a persistent SDCPNLanguageServer class that creates ts.createLanguageService() once and incrementally syncs virtual files (add/remove/update) when the SDCPN structure changes, instead of recreating from scratch on every mutation. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/checker/context.ts | 62 ++- .../petrinaut/src/checker/lib/checker.test.ts | 49 ++- .../petrinaut/src/checker/lib/checker.ts | 33 +- .../lib/create-language-service-host.ts | 65 +++- .../lib/create-sdcpn-language-service.test.ts | 111 +++++- .../lib/create-sdcpn-language-service.ts | 187 +++++---- .../petrinaut/src/checker/provider.tsx | 97 +++-- .../src/checker/worker/checker.worker.ts | 366 +++++++++++------- .../petrinaut/src/checker/worker/protocol.ts | 165 ++++---- .../src/checker/worker/use-checker-worker.ts | 165 -------- .../src/checker/worker/use-language-client.ts | 211 ++++++++++ .../petrinaut/src/monaco/completion-sync.tsx | 29 +- .../petrinaut/src/monaco/diagnostics-sync.tsx | 69 ++-- .../petrinaut/src/monaco/editor-paths.ts | 15 +- .../petrinaut/src/monaco/hover-sync.tsx | 17 +- .../petrinaut/src/monaco/provider.tsx | 11 +- .../src/monaco/signature-help-sync.tsx | 25 +- libs/@hashintel/petrinaut/src/petrinaut.tsx | 6 +- .../components/BottomBar/bottom-bar.tsx | 4 +- .../BottomBar/diagnostics-indicator.tsx | 4 +- .../differential-equation-properties.tsx | 7 +- .../PropertiesPanel/transition-properties.tsx | 6 +- .../src/views/Editor/subviews/diagnostics.tsx | 32 +- 23 files changed, 1044 insertions(+), 692 deletions(-) delete mode 100644 libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts create mode 100644 libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts diff --git a/libs/@hashintel/petrinaut/src/checker/context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts index d95dea43201..eeacedf8c1f 100644 --- a/libs/@hashintel/petrinaut/src/checker/context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -1,49 +1,43 @@ import { createContext } from "react"; import type { - CheckerCompletionResult, - CheckerItemDiagnostics, - CheckerQuickInfoResult, - CheckerResult, - CheckerSignatureHelpResult, + CompletionList, + Diagnostic, + DocumentUri, + Hover, + SignatureHelp, } from "./worker/protocol"; -export interface CheckerContextValue { - /** Result of the last SDCPN validation run. */ - checkResult: CheckerResult; - /** Total number of diagnostics across all items. */ +export interface LanguageClientContextValue { + /** Per-URI diagnostics pushed from the language server. */ + diagnosticsByUri: Map; + /** Total number of diagnostics across all documents. */ totalDiagnosticsCount: number; - /** Request completions at a position within an SDCPN item. */ - getCompletions: ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, + /** Notify the server that a document's content changed. */ + notifyDocumentChanged: (uri: DocumentUri, text: string) => void; + /** Request completions at a position within a document. */ + requestCompletion: ( + uri: DocumentUri, offset: number, - ) => Promise; - /** Request quick info (hover) at a position within an SDCPN item. */ - getQuickInfo: ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, + ) => Promise; + /** Request hover info at a position within a document. */ + requestHover: (uri: DocumentUri, offset: number) => Promise; + /** Request signature help at a position within a document. */ + requestSignatureHelp: ( + uri: DocumentUri, offset: number, - ) => Promise; - /** Request signature help at a position within an SDCPN item. */ - getSignatureHelp: ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, - offset: number, - ) => Promise; + ) => Promise; } -const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { - checkResult: { - isValid: true, - itemDiagnostics: [], - }, +const DEFAULT_CONTEXT_VALUE: LanguageClientContextValue = { + diagnosticsByUri: new Map(), totalDiagnosticsCount: 0, - getCompletions: () => Promise.resolve({ items: [] }), - getQuickInfo: () => Promise.resolve(null), - getSignatureHelp: () => Promise.resolve(null), + notifyDocumentChanged: () => {}, + requestCompletion: () => Promise.resolve({ items: [] }), + requestHover: () => Promise.resolve(null), + requestSignatureHelp: () => Promise.resolve(null), }; -export const CheckerContext = createContext( +export const LanguageClientContext = createContext( DEFAULT_CONTEXT_VALUE, ); diff --git a/libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts index 8140ea9f151..4fdb53700e5 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/checker.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it } from "vitest"; +import type { SDCPN } from "../../core/types/sdcpn"; import { checkSDCPN } from "./checker"; +import { SDCPNLanguageServer } from "./create-sdcpn-language-service"; import { createSDCPN } from "./helper/create-sdcpn"; +/** Create a server, sync the SDCPN, and run diagnostics. */ +function check(sdcpn: SDCPN) { + const server = new SDCPNLanguageServer(); + server.syncFiles(sdcpn); + return checkSDCPN(sdcpn, server); +} + describe("checkSDCPN", () => { describe("Color IDs with special characters", () => { it("handles UUID-style color IDs with dashes", () => { @@ -43,7 +52,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN - should be valid expect(result.isValid).toBe(true); @@ -68,7 +77,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(true); @@ -101,7 +110,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(true); @@ -130,7 +139,7 @@ describe("checkSDCPN", () => { const de = sdcpn.differentialEquations[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -166,7 +175,7 @@ describe("checkSDCPN", () => { const de = sdcpn.differentialEquations[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -193,7 +202,7 @@ describe("checkSDCPN", () => { const de = sdcpn.differentialEquations[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -225,7 +234,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(true); @@ -253,7 +262,7 @@ describe("checkSDCPN", () => { const transition = sdcpn.transitions[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -285,7 +294,7 @@ describe("checkSDCPN", () => { const transition = sdcpn.transitions[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -313,7 +322,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(true); @@ -343,7 +352,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(true); @@ -372,7 +381,7 @@ describe("checkSDCPN", () => { const transition = sdcpn.transitions[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -403,7 +412,7 @@ describe("checkSDCPN", () => { const transition = sdcpn.transitions[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -437,7 +446,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(true); @@ -472,7 +481,7 @@ describe("checkSDCPN", () => { const transition = sdcpn.transitions[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN - should error because Untyped is not in the input type expect(result.isValid).toBe(false); @@ -511,7 +520,7 @@ describe("checkSDCPN", () => { const transition = sdcpn.transitions[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN - should error because Target is missing from the output type expect(result.isValid).toBe(false); @@ -544,7 +553,7 @@ describe("checkSDCPN", () => { const transition = sdcpn.transitions[0]!; // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN - should error because nonExistentProperty doesn't exist on the token type expect(result.isValid).toBe(false); @@ -586,7 +595,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN expect(result.isValid).toBe(false); @@ -643,7 +652,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN - Should be valid because TransitionKernel is not checked when no output places expect(result.isValid).toBe(true); @@ -678,7 +687,7 @@ describe("checkSDCPN", () => { }); // WHEN - const result = checkSDCPN(sdcpn); + const result = check(sdcpn); // THEN - Should be valid because TransitionKernel is not checked when no coloured output places expect(result.isValid).toBe(true); diff --git a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts index 61a5d27799a..3c5029c5fe3 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/checker.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/checker.ts @@ -1,17 +1,19 @@ import type ts from "typescript"; import type { SDCPN } from "../../core/types/sdcpn"; -import { - createSDCPNLanguageService, - type SDCPNLanguageService, -} from "./create-sdcpn-language-service"; +import type { SDCPNLanguageServer } from "./create-sdcpn-language-service"; import { getItemFilePath } from "./file-paths"; +export type ItemType = + | "transition-lambda" + | "transition-kernel" + | "differential-equation"; + export type SDCPNDiagnostic = { /** The ID of the SDCPN item (transition or differential equation) */ itemId: string; /** The type of the item */ - itemType: "transition-lambda" | "transition-kernel" | "differential-equation"; + itemType: ItemType; /** The file path in the virtual file system */ filePath: string; /** TypeScript diagnostics for this file */ @@ -28,16 +30,11 @@ export type SDCPNCheckResult = { /** * Checks the validity of an SDCPN by running TypeScript validation * on all user-provided code (transitions and differential equations). - * - * @param sdcpn - The SDCPN to check - * @returns A result object indicating validity and any diagnostics */ export function checkSDCPN( sdcpn: SDCPN, - existingLanguageService?: SDCPNLanguageService, + server: SDCPNLanguageServer, ): SDCPNCheckResult { - const languageService = - existingLanguageService ?? createSDCPNLanguageService(sdcpn); const itemDiagnostics: SDCPNDiagnostic[] = []; // Check all differential equations @@ -45,10 +42,8 @@ export function checkSDCPN( const filePath = getItemFilePath("differential-equation-code", { id: de.id, }); - const semanticDiagnostics = - languageService.getSemanticDiagnostics(filePath); - const syntacticDiagnostics = - languageService.getSyntacticDiagnostics(filePath); + const semanticDiagnostics = server.getSemanticDiagnostics(filePath); + const syntacticDiagnostics = server.getSyntacticDiagnostics(filePath); const allDiagnostics = [...syntacticDiagnostics, ...semanticDiagnostics]; if (allDiagnostics.length > 0) { @@ -68,9 +63,9 @@ export function checkSDCPN( transitionId: transition.id, }); const lambdaSemanticDiagnostics = - languageService.getSemanticDiagnostics(lambdaFilePath); + server.getSemanticDiagnostics(lambdaFilePath); const lambdaSyntacticDiagnostics = - languageService.getSyntacticDiagnostics(lambdaFilePath); + server.getSyntacticDiagnostics(lambdaFilePath); const lambdaDiagnostics = [ ...lambdaSyntacticDiagnostics, ...lambdaSemanticDiagnostics, @@ -97,9 +92,9 @@ export function checkSDCPN( transitionId: transition.id, }); const kernelSemanticDiagnostics = - languageService.getSemanticDiagnostics(kernelFilePath); + server.getSemanticDiagnostics(kernelFilePath); const kernelSyntacticDiagnostics = - languageService.getSyntacticDiagnostics(kernelFilePath); + server.getSyntacticDiagnostics(kernelFilePath); const kernelDiagnostics = [ ...kernelSyntacticDiagnostics, ...kernelSemanticDiagnostics, diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts index 4e22135e8ce..8a5df10640e 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-language-service-host.ts @@ -37,15 +37,34 @@ export type VirtualFile = { content: string; }; +/** Controller for the virtual file system backing the LanguageServiceHost. */ +export type LanguageServiceHostController = { + host: ts.LanguageServiceHost; + /** Add a new file to the virtual file system. */ + addFile: (fileName: string, file: VirtualFile) => void; + /** Remove a file from the virtual file system. */ + removeFile: (fileName: string) => void; + /** Replace an entire file entry (prefix + content) and bump its version. */ + updateFile: (fileName: string, file: VirtualFile) => void; + /** Update only the user content of an existing file (preserves prefix). */ + updateContent: (fileName: string, content: string) => void; + /** Check whether a file exists in the virtual file system. */ + hasFile: (fileName: string) => boolean; + /** Return all file names currently in the virtual file system. */ + getFileNames: () => string[]; + /** Get the VirtualFile entry for a given file name. */ + getFile: (fileName: string) => VirtualFile | undefined; +}; + /** - * @private Used by `createSDCPNLanguageService`. + * Creates a TypeScript LanguageServiceHost backed by a virtual file system. * - * Creates a TypeScript LanguageServiceHost for virtual SDCPN files + * The returned controller allows incremental mutations (add/remove/update) + * without recreating the host or the LanguageService that consumes it. */ -export function createLanguageServiceHost(files: Map): { - host: ts.LanguageServiceHost; - updateFileContent: (fileName: string, content: string) => void; -} { +export function createLanguageServiceHost( + files: Map, +): LanguageServiceHostController { const versions = new Map(); const getFileContent = (fileName: string): string | undefined => { @@ -65,11 +84,30 @@ export function createLanguageServiceHost(files: Map): { return undefined; }; - const updateFileContent = (fileName: string, content: string) => { + const bumpVersion = (fileName: string) => { + versions.set(fileName, (versions.get(fileName) ?? 0) + 1); + }; + + const addFile = (fileName: string, file: VirtualFile) => { + files.set(fileName, file); + versions.set(fileName, 0); + }; + + const removeFile = (fileName: string) => { + files.delete(fileName); + versions.delete(fileName); + }; + + const updateFile = (fileName: string, file: VirtualFile) => { + files.set(fileName, file); + bumpVersion(fileName); + }; + + const updateContent = (fileName: string, content: string) => { const entry = files.get(fileName); if (entry) { entry.content = content; - versions.set(fileName, (versions.get(fileName) ?? 0) + 1); + bumpVersion(fileName); } }; @@ -94,5 +132,14 @@ export function createLanguageServiceHost(files: Map): { }, }; - return { host, updateFileContent }; + return { + host, + addFile, + removeFile, + updateFile, + updateContent, + hasFile: (fileName) => files.has(fileName), + getFileNames: () => [...files.keys()], + getFile: (fileName) => files.get(fileName), + }; } diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts index 791b66f2c23..68d0a6e7ccf 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import { SDCPNLanguageServer } from "./create-sdcpn-language-service"; import { getItemFilePath } from "./file-paths"; import { createSDCPN } from "./helper/create-sdcpn"; @@ -24,6 +24,16 @@ function parseCursor(codeWithCursor: string): { return { offset, code }; } +/** Create a server initialized with the given SDCPN and return it. */ +function createServer( + sdcpnOptions: Parameters[0], +): SDCPNLanguageServer { + const sdcpn = createSDCPN(sdcpnOptions); + const server = new SDCPNLanguageServer(); + server.syncFiles(sdcpn); + return server; +} + /** Extract just the completion names from a position. */ function getCompletionNames( sdcpnOptions: Parameters[0], @@ -60,8 +70,7 @@ function getCompletionNames( ); } - const sdcpn = createSDCPN(patched); - const ls = createSDCPNLanguageService(sdcpn); + const server = createServer(patched); let filePath: string; if (target.type === "transition-lambda") { @@ -78,11 +87,15 @@ function getCompletionNames( }); } - const completions = ls.getCompletionsAtPosition(filePath, offset, undefined); + const completions = server.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); return (completions?.entries ?? []).map((entry) => entry.name); } -describe("createSDCPNLanguageService completions", () => { +describe("SDCPNLanguageServer completions", () => { const baseSdcpn = { types: [{ id: "color1", elements: [{ name: "x", type: "real" as const }] }], places: [ @@ -187,10 +200,10 @@ describe("createSDCPNLanguageService completions", () => { }); }); - describe("updateFileContent", () => { + describe("updateDocumentContent", () => { it("returns completions for updated code, not original code", () => { // Start with number code - const sdcpn = createSDCPN({ + const server = createServer({ ...baseSdcpn, transitions: [ { @@ -199,17 +212,15 @@ describe("createSDCPNLanguageService completions", () => { }, ], }); - - const ls = createSDCPNLanguageService(sdcpn); const filePath = getItemFilePath("transition-lambda-code", { transitionId: "t1", }); // Update to string code const { offset, code } = parseCursor(`const s = "hello";\ns.${CURSOR}`); - ls.updateFileContent(filePath, code); + server.updateDocumentContent(filePath, code); - const completions = ls.getCompletionsAtPosition( + const completions = server.getCompletionsAtPosition( filePath, offset, undefined, @@ -224,7 +235,7 @@ describe("createSDCPNLanguageService completions", () => { }); it("reflects new content after multiple updates", () => { - const sdcpn = createSDCPN({ + const server = createServer({ ...baseSdcpn, transitions: [ { @@ -233,16 +244,14 @@ describe("createSDCPNLanguageService completions", () => { }, ], }); - - const ls = createSDCPNLanguageService(sdcpn); const filePath = getItemFilePath("transition-lambda-code", { transitionId: "t1", }); // First update: number const first = parseCursor(`const a = 42;\na.${CURSOR}`); - ls.updateFileContent(filePath, first.code); - const firstCompletions = ls.getCompletionsAtPosition( + server.updateDocumentContent(filePath, first.code); + const firstCompletions = server.getCompletionsAtPosition( filePath, first.offset, undefined, @@ -254,8 +263,8 @@ describe("createSDCPNLanguageService completions", () => { // Second update: string const second = parseCursor(`const s = "hello";\ns.${CURSOR}`); - ls.updateFileContent(filePath, second.code); - const secondCompletions = ls.getCompletionsAtPosition( + server.updateDocumentContent(filePath, second.code); + const secondCompletions = server.getCompletionsAtPosition( filePath, second.offset, undefined, @@ -268,6 +277,72 @@ describe("createSDCPNLanguageService completions", () => { }); }); + describe("syncFiles", () => { + it("updates types when SDCPN changes structurally", () => { + // Start with one color element + const server = new SDCPNLanguageServer(); + const sdcpn1 = createSDCPN({ + types: [ + { id: "color1", elements: [{ name: "x", type: "real" as const }] }, + ], + places: [{ id: "place1", name: "Source", colorId: "color1" }], + transitions: [ + { + id: "t1", + lambdaType: "predicate" as const, + inputArcs: [{ placeId: "place1", weight: 1 }], + outputArcs: [], + lambdaCode: "", + }, + ], + }); + server.syncFiles(sdcpn1); + + // Add a new element to the color + const sdcpn2 = createSDCPN({ + types: [ + { + id: "color1", + elements: [ + { name: "x", type: "real" as const }, + { name: "y", type: "real" as const }, + ], + }, + ], + places: [{ id: "place1", name: "Source", colorId: "color1" }], + transitions: [ + { + id: "t1", + lambdaType: "predicate" as const, + inputArcs: [{ placeId: "place1", weight: 1 }], + outputArcs: [], + lambdaCode: `export default Lambda((input) => {\n const t = input.Source[0];\n return t.`, + }, + ], + }); + server.syncFiles(sdcpn2); + + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + const { offset, code } = parseCursor( + `export default Lambda((input) => {\n const t = input.Source[0];\n return t.${CURSOR}`, + ); + server.updateDocumentContent(filePath, code); + + const completions = server.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + const names = (completions?.entries ?? []).map((entry) => entry.name); + + // Should now include both x and y + expect(names).toContain("x"); + expect(names).toContain("y"); + }); + }); + describe("differential equation completions", () => { const deSdcpn = { types: [ diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index 02064505ca5..e790d0effc5 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -3,14 +3,11 @@ import ts from "typescript"; import type { SDCPN } from "../../core/types/sdcpn"; import { createLanguageServiceHost, + type LanguageServiceHostController, type VirtualFile, } from "./create-language-service-host"; import { getItemFilePath } from "./file-paths"; -export type SDCPNLanguageService = ts.LanguageService & { - updateFileContent: (fileName: string, content: string) => void; -}; - /** * Sanitizes a color ID to be a valid TypeScript identifier. * Removes all characters that are not valid suffixes for TypeScript identifiers @@ -245,73 +242,123 @@ function adjustDiagnostics( } /** - * Creates a TypeScript language service for SDCPN code validation. + * Persistent TypeScript language server for SDCPN code validation. * - * @param sdcpn - The SDCPN model to create the service for - * @returns A TypeScript LanguageService instance + * Creates the `ts.LanguageService` once and reuses it across SDCPN changes + * by diffing virtual files (add/remove/update) rather than recreating everything. */ -export function createSDCPNLanguageService(sdcpn: SDCPN): SDCPNLanguageService { - const files = generateVirtualFiles(sdcpn); - const { host, updateFileContent } = createLanguageServiceHost(files); - const baseService = ts.createLanguageService(host); - - // Proxy service to adjust positions for injected prefixes - return { - ...baseService, - - updateFileContent, - - getSemanticDiagnostics(fileName) { - const entry = files.get(fileName); - const prefixLength = entry?.prefix?.length ?? 0; - const diagnostics = baseService.getSemanticDiagnostics(fileName); - return adjustDiagnostics(diagnostics, prefixLength); - }, - - getSyntacticDiagnostics(fileName) { - const entry = files.get(fileName); - const prefixLength = entry?.prefix?.length ?? 0; - const diagnostics = baseService.getSyntacticDiagnostics(fileName); - return adjustDiagnostics(diagnostics, prefixLength); - }, - - getCompletionsAtPosition(fileName, position, options) { - const entry = files.get(fileName); - const prefixLength = entry?.prefix?.length ?? 0; - return baseService.getCompletionsAtPosition( - fileName, - position + prefixLength, - options, - ); - }, - - getQuickInfoAtPosition(fileName, position) { - const entry = files.get(fileName); - const prefixLength = entry?.prefix?.length ?? 0; - const info = baseService.getQuickInfoAtPosition( - fileName, - position + prefixLength, - ); - if (!info) { - return undefined; +export class SDCPNLanguageServer { + private files: Map; + private controller: LanguageServiceHostController; + private service: ts.LanguageService; + + constructor() { + this.files = new Map(); + this.controller = createLanguageServiceHost(this.files); + this.service = ts.createLanguageService(this.controller.host); + } + + /** + * Sync virtual files to match the given SDCPN model. + * Diffs against the current state: adds new files, updates changed files, + * removes files that no longer exist. + */ + syncFiles(sdcpn: SDCPN): void { + const newFiles = generateVirtualFiles(sdcpn); + + // Remove files that no longer exist + for (const existingName of this.controller.getFileNames()) { + if (!newFiles.has(existingName)) { + this.controller.removeFile(existingName); + } + } + + // Add or update files + for (const [name, newFile] of newFiles) { + if (!this.controller.hasFile(name)) { + this.controller.addFile(name, newFile); + } else { + const existing = this.controller.getFile(name); + if ( + existing?.content !== newFile.content || + existing?.prefix !== newFile.prefix + ) { + this.controller.updateFile(name, newFile); + } } - return { - ...info, - textSpan: { - start: info.textSpan.start - prefixLength, - length: info.textSpan.length, - }, - }; - }, - - getSignatureHelpItems(fileName, position, options) { - const entry = files.get(fileName); - const prefixLength = entry?.prefix?.length ?? 0; - return baseService.getSignatureHelpItems( - fileName, - position + prefixLength, - options, - ); - }, - }; + } + } + + /** Update only the user content of a single file (e.g., when the user types in an editor). */ + updateDocumentContent(fileName: string, content: string): void { + this.controller.updateContent(fileName, content); + } + + getSemanticDiagnostics(fileName: string): ts.Diagnostic[] { + const entry = this.controller.getFile(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + const diagnostics = this.service.getSemanticDiagnostics(fileName); + return adjustDiagnostics(diagnostics, prefixLength); + } + + getSyntacticDiagnostics(fileName: string): ts.Diagnostic[] { + const entry = this.controller.getFile(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + const diagnostics = this.service.getSyntacticDiagnostics(fileName); + return adjustDiagnostics(diagnostics, prefixLength); + } + + getCompletionsAtPosition( + fileName: string, + position: number, + options: ts.GetCompletionsAtPositionOptions | undefined, + ): ts.CompletionInfo | undefined { + const entry = this.controller.getFile(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + return this.service.getCompletionsAtPosition( + fileName, + position + prefixLength, + options, + ); + } + + getQuickInfoAtPosition( + fileName: string, + position: number, + ): ts.QuickInfo | undefined { + const entry = this.controller.getFile(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + const info = this.service.getQuickInfoAtPosition( + fileName, + position + prefixLength, + ); + if (!info) { + return undefined; + } + return { + ...info, + textSpan: { + start: info.textSpan.start - prefixLength, + length: info.textSpan.length, + }, + }; + } + + getSignatureHelpItems( + fileName: string, + position: number, + options: ts.SignatureHelpItemsOptions | undefined, + ): ts.SignatureHelpItems | undefined { + const entry = this.controller.getFile(fileName); + const prefixLength = entry?.prefix?.length ?? 0; + return this.service.getSignatureHelpItems( + fileName, + position + prefixLength, + options, + ); + } + + dispose(): void { + this.service.dispose(); + } } diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index 04ef268eef0..a2c95cb3137 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,54 +1,75 @@ -import { use, useEffect, useState } from "react"; +import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SDCPNContext } from "../state/sdcpn-context"; -import { CheckerContext } from "./context"; -import type { CheckerResult } from "./worker/protocol"; -import { useCheckerWorker } from "./worker/use-checker-worker"; +import { LanguageClientContext } from "./context"; +import type { + Diagnostic, + DocumentUri, + PublishDiagnosticsParams, +} from "./worker/protocol"; +import { useLanguageClient } from "./worker/use-language-client"; -const EMPTY_RESULT: CheckerResult = { - isValid: true, - itemDiagnostics: [], -}; - -export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { +export const LanguageClientProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { const { petriNetDefinition } = use(SDCPNContext); - const { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp } = - useCheckerWorker(); + const client = useLanguageClient(); - const [checkResult, setCheckerResult] = useState(EMPTY_RESULT); + const [diagnosticsByUri, setDiagnosticsByUri] = useState< + Map + >(new Map()); - useEffect(() => { - let cancelled = false; - - void setSDCPN(petriNetDefinition).then((result) => { - if (!cancelled) { - setCheckerResult(result); - } - }); - - return () => { - cancelled = true; - }; - }, [petriNetDefinition, setSDCPN]); - - const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( - (sum, item) => sum + item.diagnostics.length, - 0, + // Subscribe to diagnostics pushed from the server + const handleDiagnostics = useCallback( + (allParams: PublishDiagnosticsParams[]) => { + setDiagnosticsByUri(() => { + const next = new Map(); + for (const param of allParams) { + if (param.diagnostics.length > 0) { + next.set(param.uri, param.diagnostics); + } + } + return next; + }); + }, + [], ); + useEffect(() => { + client.onDiagnostics(handleDiagnostics); + }, [client, handleDiagnostics]); + + // Initialize on first mount, then send incremental updates + const initializedRef = useRef(false); + useEffect(() => { + if (!initializedRef.current) { + client.initialize(petriNetDefinition); + initializedRef.current = true; + } else { + client.notifySDCPNChanged(petriNetDefinition); + } + }, [petriNetDefinition, client]); + + const totalDiagnosticsCount = useMemo(() => { + let count = 0; + for (const diagnostics of diagnosticsByUri.values()) { + count += diagnostics.length; + } + return count; + }, [diagnosticsByUri]); + return ( - {children} - + ); }; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts index caa4b826ca7..df0f1ef56f5 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -2,155 +2,270 @@ /** * Checker WebWorker — runs TypeScript validation off the main thread. * - * Receives JSON-RPC requests via `postMessage`, delegates to `checkSDCPN`, - * serializes the diagnostics (flatten messageText, strip non-cloneable fields), - * and posts the result back. + * Implements an LSP-inspired protocol over JSON-RPC 2.0: + * - Notifications: `initialize`, `sdcpn/didChange`, `textDocument/didChange` + * - Requests: `textDocument/completion`, `textDocument/hover`, `textDocument/signatureHelp` + * - Server push: `textDocument/publishDiagnostics` * - * The LanguageService is persisted between calls so that `getCompletions` - * can reuse it without re-sending the full SDCPN model. + * The LanguageService is created once and reused across SDCPN changes. */ import ts from "typescript"; +import type { SDCPN } from "../../core/types/sdcpn"; import { checkSDCPN } from "../lib/checker"; -import { - createSDCPNLanguageService, - type SDCPNLanguageService, -} from "../lib/create-sdcpn-language-service"; +import { SDCPNLanguageServer } from "../lib/create-sdcpn-language-service"; import { getItemFilePath } from "../lib/file-paths"; import type { - CheckerCompletionItem, - CheckerCompletionResult, - CheckerDiagnostic, - CheckerItemDiagnostics, - CheckerQuickInfoResult, - CheckerResult, - CheckerSignatureHelpResult, - CheckerSignatureInfo, - JsonRpcRequest, - JsonRpcResponse, + ClientMessage, + CompletionItem, + CompletionList, + Diagnostic, + DocumentUri, + Hover, + PublishDiagnosticsParams, + ServerMessage, + SignatureHelp, + SignatureInformation, } from "./protocol"; -/** Persisted LanguageService — created on `setSDCPN`, reused by `getCompletions`. */ -let languageService: SDCPNLanguageService | null = null; +// --------------------------------------------------------------------------- +// URI ↔ internal file path mapping +// --------------------------------------------------------------------------- -/** Strip `ts.SourceFile` and flatten `DiagnosticMessageChain` for structured clone. */ -function serializeDiagnostic(diag: ts.Diagnostic): CheckerDiagnostic { +const TRANSITION_LAMBDA_URI_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/lambda\.ts$/; +const TRANSITION_KERNEL_URI_RE = + /^inmemory:\/\/sdcpn\/transitions\/([^/]+)\/kernel\.ts$/; +const DE_URI_RE = /^inmemory:\/\/sdcpn\/differential-equations\/([^/]+)\.ts$/; + +/** Convert a document URI to the internal virtual file path used by the TS LanguageService. */ +function uriToFilePath(uri: DocumentUri): string | null { + let match = TRANSITION_LAMBDA_URI_RE.exec(uri); + if (match) { + return getItemFilePath("transition-lambda-code", { + transitionId: match[1]!, + }); + } + + match = TRANSITION_KERNEL_URI_RE.exec(uri); + if (match) { + return getItemFilePath("transition-kernel-code", { + transitionId: match[1]!, + }); + } + + match = DE_URI_RE.exec(uri); + if (match) { + return getItemFilePath("differential-equation-code", { id: match[1]! }); + } + + return null; +} + +const TRANSITION_LAMBDA_PATH_RE = /^\/transitions\/([^/]+)\/lambda\/code\.ts$/; +const TRANSITION_KERNEL_PATH_RE = /^\/transitions\/([^/]+)\/kernel\/code\.ts$/; +const DE_PATH_RE = /^\/differential_equations\/([^/]+)\/code\.ts$/; + +/** Convert an internal file path to the document URI used by Monaco. */ +function filePathToUri(filePath: string): DocumentUri | null { + let match = TRANSITION_LAMBDA_PATH_RE.exec(filePath); + if (match) { + return `inmemory://sdcpn/transitions/${match[1]!}/lambda.ts`; + } + + match = TRANSITION_KERNEL_PATH_RE.exec(filePath); + if (match) { + return `inmemory://sdcpn/transitions/${match[1]!}/kernel.ts`; + } + + match = DE_PATH_RE.exec(filePath); + if (match) { + return `inmemory://sdcpn/differential-equations/${match[1]!}.ts`; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Serialization helpers +// --------------------------------------------------------------------------- + +/** + * Map `ts.DiagnosticCategory` to LSP-like severity. + * TS: 0=Warning, 1=Error, 2=Suggestion, 3=Message + * LSP: 1=Error, 2=Warning, 3=Information, 4=Hint + */ +function toLspSeverity(category: number): number { + switch (category) { + case 0: + return 2; // Warning + case 1: + return 1; // Error + case 2: + return 4; // Hint (Suggestion) + case 3: + return 3; // Information (Message) + default: + return 1; + } +} + +function serializeDiagnostic(diag: ts.Diagnostic): Diagnostic { return { - category: diag.category, + severity: toLspSeverity(diag.category), code: diag.code, - messageText: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), + message: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), start: diag.start, length: diag.length, }; } -/** Map (itemType, itemId) → checker virtual file path. */ -function getCheckerFilePath( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, -): string { - switch (itemType) { - case "transition-lambda": - return getItemFilePath("transition-lambda-code", { - transitionId: itemId, - }); - case "transition-kernel": - return getItemFilePath("transition-kernel-code", { - transitionId: itemId, - }); - case "differential-equation": - return getItemFilePath("differential-equation-code", { id: itemId }); +// --------------------------------------------------------------------------- +// Server state +// --------------------------------------------------------------------------- + +let server: SDCPNLanguageServer | null = null; + +function respond(id: number, result: unknown): void { + self.postMessage({ + jsonrpc: "2.0", + id, + result, + } satisfies ServerMessage); +} + +function respondError(id: number, message: string): void { + self.postMessage({ + jsonrpc: "2.0", + id, + error: { code: -32603, message }, + } satisfies ServerMessage); +} + +/** Run diagnostics on all SDCPN code files and push results to the main thread. */ +function publishAllDiagnostics(sdcpn: SDCPN): void { + if (!server) { + return; } + + const result = checkSDCPN(sdcpn, server); + const params: PublishDiagnosticsParams[] = result.itemDiagnostics.map( + (item) => { + const uri = filePathToUri(item.filePath); + return { + uri: uri ?? item.filePath, + diagnostics: item.diagnostics.map(serializeDiagnostic), + }; + }, + ); + + self.postMessage({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params, + } satisfies ServerMessage); } -self.onmessage = async ({ data }: MessageEvent) => { - const { id, method } = data; +// --------------------------------------------------------------------------- +// Message handler +// --------------------------------------------------------------------------- + +/** Cache the last SDCPN for re-running diagnostics after single-file changes. */ +let lastSDCPN: SDCPN | null = null; +self.onmessage = ({ data }: MessageEvent) => { try { - switch (method) { - case "setSDCPN": { + switch (data.method) { + // --- Notifications (no response) --- + + case "initialize": { const { sdcpn } = data.params; + lastSDCPN = sdcpn; + server = new SDCPNLanguageServer(); + server.syncFiles(sdcpn); + publishAllDiagnostics(sdcpn); + break; + } - languageService = createSDCPNLanguageService(sdcpn); - const raw = checkSDCPN(sdcpn, languageService); - - const result: CheckerResult = { - isValid: raw.isValid, - itemDiagnostics: raw.itemDiagnostics.map( - (item): CheckerItemDiagnostics => ({ - itemId: item.itemId, - itemType: item.itemType, - filePath: item.filePath, - diagnostics: item.diagnostics.map(serializeDiagnostic), - }), - ), - }; - - self.postMessage({ - jsonrpc: "2.0", - id, - result, - } satisfies JsonRpcResponse); + case "sdcpn/didChange": { + const { sdcpn } = data.params; + lastSDCPN = sdcpn; + if (!server) { + server = new SDCPNLanguageServer(); + } + server.syncFiles(sdcpn); + publishAllDiagnostics(sdcpn); break; } - case "getCompletions": { - const { itemType, itemId, offset } = data.params; + case "textDocument/didChange": { + if (!server) { + break; + } + const filePath = uriToFilePath(data.params.textDocument.uri); + if (filePath) { + server.updateDocumentContent(filePath, data.params.text); + // Re-run full diagnostics since type changes can cascade + if (lastSDCPN) { + publishAllDiagnostics(lastSDCPN); + } + } + break; + } - // Wait before requesting completions, to be sure the file content is updated - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + // --- Requests (send response) --- - if (!languageService) { - self.postMessage({ - jsonrpc: "2.0", - id, - result: { items: [] }, - } satisfies JsonRpcResponse); + case "textDocument/completion": { + const { id } = data; + if (!server) { + respond(id, { items: [] } satisfies CompletionList); break; } - const filePath = getCheckerFilePath(itemType, itemId); - const completions = languageService.getCompletionsAtPosition( + const filePath = uriToFilePath(data.params.textDocument.uri); + if (!filePath) { + respond(id, { items: [] } satisfies CompletionList); + break; + } + + const completions = server.getCompletionsAtPosition( filePath, - offset, + data.params.offset, undefined, ); - const items: CheckerCompletionItem[] = (completions?.entries ?? []).map( + const items: CompletionItem[] = (completions?.entries ?? []).map( (entry) => ({ - name: entry.name, + label: entry.name, kind: entry.kind, sortText: entry.sortText, insertText: entry.insertText, }), ); - self.postMessage({ - jsonrpc: "2.0", - id, - result: { items }, - } satisfies JsonRpcResponse); + respond(id, { items } satisfies CompletionList); break; } - case "getQuickInfo": { - const { itemType, itemId, offset } = data.params; + case "textDocument/hover": { + const { id } = data; + if (!server) { + respond(id, null); + break; + } - if (!languageService) { - self.postMessage({ - jsonrpc: "2.0", - id, - result: null, - } satisfies JsonRpcResponse); + const filePath = uriToFilePath(data.params.textDocument.uri); + if (!filePath) { + respond(id, null); break; } - const filePath = getCheckerFilePath(itemType, itemId); - const info = languageService.getQuickInfoAtPosition(filePath, offset); + const info = server.getQuickInfoAtPosition( + filePath, + data.params.offset, + ); - const result: CheckerQuickInfoResult = info + const result: Hover = info ? { displayParts: ts.displayPartsToString(info.displayParts), documentation: ts.displayPartsToString(info.documentation), @@ -159,44 +274,35 @@ self.onmessage = async ({ data }: MessageEvent) => { } : null; - self.postMessage({ - jsonrpc: "2.0", - id, - result, - } satisfies JsonRpcResponse); + respond(id, result); break; } - case "getSignatureHelp": { - const { itemType, itemId, offset } = data.params; - - // Wait a bit before requesting completions, to be sure the file content is updated - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + case "textDocument/signatureHelp": { + const { id } = data; + if (!server) { + respond(id, null); + break; + } - if (!languageService) { - self.postMessage({ - jsonrpc: "2.0", - id, - result: null, - } satisfies JsonRpcResponse); + const filePath = uriToFilePath(data.params.textDocument.uri); + if (!filePath) { + respond(id, null); break; } - const filePath = getCheckerFilePath(itemType, itemId); - const help = languageService.getSignatureHelpItems( + const help = server.getSignatureHelpItems( filePath, - offset, + data.params.offset, undefined, ); - const result: CheckerSignatureHelpResult = help + const result: SignatureHelp = help ? { activeSignature: help.selectedItemIndex, activeParameter: help.argumentIndex, signatures: help.items.map( - (item): CheckerSignatureInfo => ({ + (item): SignatureInformation => ({ label: [ ...item.prefixDisplayParts, ...item.parameters.flatMap((param, idx) => [ @@ -217,22 +323,14 @@ self.onmessage = async ({ data }: MessageEvent) => { } : null; - self.postMessage({ - jsonrpc: "2.0", - id, - result, - } satisfies JsonRpcResponse); + respond(id, result); break; } } } catch (err) { - self.postMessage({ - jsonrpc: "2.0", - id, - error: { - code: -32603, - message: err instanceof Error ? err.message : String(err), - }, - } satisfies JsonRpcResponse); + // Only requests have an `id` that needs a response + if ("id" in data) { + respondError(data.id, err instanceof Error ? err.message : String(err)); + } } }; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts index 7e52045904c..057336a6dae 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts @@ -1,57 +1,70 @@ /** - * JSON-RPC 2.0 protocol types for the checker WebWorker. + * LSP-inspired protocol types for the checker WebWorker. * - * These types define the contract between the main thread and the worker. + * Uses JSON-RPC 2.0 with LSP-like method names and structures. * Diagnostic types are serializable (no `ts.SourceFile` references) so they * can cross the postMessage boundary via structured clone. + * + * Deviations from LSP: + * - Uses character offsets instead of line/character positions (both Monaco + * and the TS LanguageService work in offsets internally). + * - Uses `sdcpn/didChange` for structural model changes (not per-document). */ import type { SDCPN } from "../../core/types/sdcpn"; // --------------------------------------------------------------------------- -// Diagnostics — serializable variants of ts.Diagnostic +// Document identification // --------------------------------------------------------------------------- -/** A single TypeScript diagnostic, safe for structured clone. */ -export type CheckerDiagnostic = { - /** @see ts.DiagnosticCategory */ - category: number; +/** URI identifying a text document (e.g., "inmemory://sdcpn/transitions/t1/lambda.ts"). */ +export type DocumentUri = string; + +export type TextDocumentIdentifier = { + uri: DocumentUri; +}; + +/** + * Position in a text document, specified as a character offset. + * + * Unlike standard LSP (which uses line/character), we use offsets since both + * Monaco and the TS LanguageService work in offsets internally. + */ +export type TextDocumentPositionParams = { + textDocument: TextDocumentIdentifier; + offset: number; +}; + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/** A single diagnostic, safe for structured clone. */ +export type Diagnostic = { + /** Severity: 1=Error, 2=Warning, 3=Info, 4=Hint. */ + severity: number; /** TypeScript error code (e.g. 2322 for type mismatch). */ code: number; /** Human-readable message, pre-flattened from `ts.DiagnosticMessageChain`. */ - messageText: string; - /** Character offset in user code where the error starts. */ + message: string; + /** Character offset in user code where the diagnostic starts. */ start: number | undefined; - /** Length of the error span in characters. */ + /** Length of the diagnostic span in characters. */ length: number | undefined; }; -/** Diagnostics grouped by SDCPN item (one transition function or differential equation). */ -export type CheckerItemDiagnostics = { - /** ID of the transition or differential equation. */ - itemId: string; - /** Which piece of code was checked. */ - itemType: "transition-lambda" | "transition-kernel" | "differential-equation"; - /** Path in the virtual file system used by the TS LanguageService. */ - filePath: string; - /** All diagnostics found in this item's code. */ - diagnostics: CheckerDiagnostic[]; -}; - -/** Result of validating an entire SDCPN model. */ -export type CheckerResult = { - /** `true` when every item compiles without errors. */ - isValid: boolean; - /** Per-item diagnostics (empty when valid). */ - itemDiagnostics: CheckerItemDiagnostics[]; +/** Diagnostics for a single document. */ +export type PublishDiagnosticsParams = { + uri: DocumentUri; + diagnostics: Diagnostic[]; }; // --------------------------------------------------------------------------- -// Completions — serializable variants of ts.CompletionEntry +// Completions // --------------------------------------------------------------------------- /** A single completion suggestion, safe for structured clone. */ -export type CheckerCompletionItem = { - name: string; +export type CompletionItem = { + label: string; /** @see ts.ScriptElementKind */ kind: string; sortText: string; @@ -59,16 +72,16 @@ export type CheckerCompletionItem = { }; /** Result of requesting completions at a position. */ -export type CheckerCompletionResult = { - items: CheckerCompletionItem[]; +export type CompletionList = { + items: CompletionItem[]; }; // --------------------------------------------------------------------------- -// Quick Info (hover) — serializable variant of ts.QuickInfo +// Hover // --------------------------------------------------------------------------- -/** Result of requesting quick info (hover) at a position. */ -export type CheckerQuickInfoResult = { +/** Result of requesting hover info at a position. */ +export type Hover = { /** Type/signature display string. */ displayParts: string; /** JSDoc documentation string. */ @@ -80,73 +93,93 @@ export type CheckerQuickInfoResult = { } | null; // --------------------------------------------------------------------------- -// Signature Help — serializable variant of ts.SignatureHelpItems +// Signature Help // --------------------------------------------------------------------------- /** A single parameter in a signature. */ -export type CheckerSignatureParameter = { +export type SignatureParameter = { label: string; documentation: string; }; /** A single signature (overload). */ -export type CheckerSignatureInfo = { +export type SignatureInformation = { label: string; documentation: string; - parameters: CheckerSignatureParameter[]; + parameters: SignatureParameter[]; }; /** Result of requesting signature help at a position. */ -export type CheckerSignatureHelpResult = { - signatures: CheckerSignatureInfo[]; +export type SignatureHelp = { + signatures: SignatureInformation[]; activeSignature: number; activeParameter: number; } | null; // --------------------------------------------------------------------------- -// JSON-RPC 2.0 +// JSON-RPC 2.0 messages: main thread → worker // --------------------------------------------------------------------------- -/** A JSON-RPC request sent from the main thread to the worker. */ -export type JsonRpcRequest = +/** Notifications (fire-and-forget, no response expected). */ +type ClientNotification = | { jsonrpc: "2.0"; - id: number; - method: "setSDCPN"; + method: "initialize"; params: { sdcpn: SDCPN }; } | { jsonrpc: "2.0"; - id: number; - method: "getCompletions"; + method: "sdcpn/didChange"; + params: { sdcpn: SDCPN }; + } + | { + jsonrpc: "2.0"; + method: "textDocument/didChange"; params: { - itemType: CheckerItemDiagnostics["itemType"]; - itemId: string; - offset: number; + textDocument: TextDocumentIdentifier; + text: string; }; + }; + +/** Requests (expect a response with matching `id`). */ +type ClientRequest = + | { + jsonrpc: "2.0"; + id: number; + method: "textDocument/completion"; + params: TextDocumentPositionParams; } | { jsonrpc: "2.0"; id: number; - method: "getQuickInfo"; - params: { - itemType: CheckerItemDiagnostics["itemType"]; - itemId: string; - offset: number; - }; + method: "textDocument/hover"; + params: TextDocumentPositionParams; } | { jsonrpc: "2.0"; id: number; - method: "getSignatureHelp"; - params: { - itemType: CheckerItemDiagnostics["itemType"]; - itemId: string; - offset: number; - }; + method: "textDocument/signatureHelp"; + params: TextDocumentPositionParams; }; -/** A JSON-RPC response sent from the worker back to the main thread. */ -export type JsonRpcResponse = +/** Any message from the main thread to the worker. */ +export type ClientMessage = ClientNotification | ClientRequest; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 messages: worker → main thread +// --------------------------------------------------------------------------- + +/** A successful response to a request. */ +type ServerResponse = | { jsonrpc: "2.0"; id: number; result: Result } | { jsonrpc: "2.0"; id: number; error: { code: number; message: string } }; + +/** Server-initiated notifications (no `id`). */ +type ServerNotification = { + jsonrpc: "2.0"; + method: "textDocument/publishDiagnostics"; + params: PublishDiagnosticsParams[]; +}; + +/** Any message from the worker to the main thread. */ +export type ServerMessage = ServerResponse | ServerNotification; diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts deleted file mode 100644 index 82d63cbca62..00000000000 --- a/libs/@hashintel/petrinaut/src/checker/worker/use-checker-worker.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; - -import type { SDCPN } from "../../core/types/sdcpn"; -import type { - CheckerCompletionResult, - CheckerItemDiagnostics, - CheckerQuickInfoResult, - CheckerResult, - CheckerSignatureHelpResult, - JsonRpcRequest, - JsonRpcResponse, -} from "./protocol"; - -type Pending = { - resolve: (result: never) => void; - reject: (error: Error) => void; -}; - -/** Methods exposed by the checker WebWorker. */ -export type CheckerWorkerApi = { - /** Send an SDCPN model to the worker. Persists the LanguageService and returns diagnostics. */ - setSDCPN: (sdcpn: SDCPN) => Promise; - /** Request completions at a position within an SDCPN item. */ - getCompletions: ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, - offset: number, - ) => Promise; - /** Request quick info (hover) at a position within an SDCPN item. */ - getQuickInfo: ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, - offset: number, - ) => Promise; - /** Request signature help at a position within an SDCPN item. */ - getSignatureHelp: ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, - offset: number, - ) => Promise; -}; - -/** - * Spawn a checker WebWorker and return a Promise-based API to interact with it. - * The worker is created on mount and terminated on unmount. - */ -export function useCheckerWorker(): CheckerWorkerApi { - const workerRef = useRef(null); - const pendingRef = useRef(new Map()); - const nextId = useRef(0); - - useEffect(() => { - const worker = new Worker(new URL("./checker.worker.ts", import.meta.url), { - type: "module", - }); - - worker.onmessage = (event: MessageEvent) => { - const response = event.data; - const pending = pendingRef.current.get(response.id); - if (!pending) { - return; - } - pendingRef.current.delete(response.id); - - if ("error" in response) { - pending.reject(new Error(response.error.message)); - } else { - pending.resolve(response.result as never); - } - }; - - workerRef.current = worker; - const pending = pendingRef.current; - - return () => { - worker.terminate(); - workerRef.current = null; - for (const entry of pending.values()) { - entry.reject(new Error("Worker terminated")); - } - pending.clear(); - }; - }, []); - - const sendRequest = useCallback((request: JsonRpcRequest): Promise => { - const worker = workerRef.current; - if (!worker) { - return Promise.reject(new Error("Worker not initialized")); - } - - return new Promise((resolve, reject) => { - pendingRef.current.set(request.id, { - resolve: resolve as (result: never) => void, - reject, - }); - worker.postMessage(request); - }); - }, []); - - const setSDCPN = useCallback( - (sdcpn: SDCPN): Promise => { - const id = nextId.current++; - return sendRequest({ - jsonrpc: "2.0", - id, - method: "setSDCPN", - params: { sdcpn }, - }); - }, - [sendRequest], - ); - - const getCompletions = useCallback( - ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, - offset: number, - ): Promise => { - const id = nextId.current++; - return sendRequest({ - jsonrpc: "2.0", - id, - method: "getCompletions", - params: { itemType, itemId, offset }, - }); - }, - [sendRequest], - ); - - const getQuickInfo = useCallback( - ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, - offset: number, - ): Promise => { - const id = nextId.current++; - return sendRequest({ - jsonrpc: "2.0", - id, - method: "getQuickInfo", - params: { itemType, itemId, offset }, - }); - }, - [sendRequest], - ); - - const getSignatureHelp = useCallback( - ( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, - offset: number, - ): Promise => { - const id = nextId.current++; - return sendRequest({ - jsonrpc: "2.0", - id, - method: "getSignatureHelp", - params: { itemType, itemId, offset }, - }); - }, - [sendRequest], - ); - - return { setSDCPN, getCompletions, getQuickInfo, getSignatureHelp }; -} diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts new file mode 100644 index 00000000000..0cc12567b95 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useRef } from "react"; + +import type { SDCPN } from "../../core/types/sdcpn"; +import type { + ClientMessage, + CompletionList, + DocumentUri, + Hover, + PublishDiagnosticsParams, + ServerMessage, + SignatureHelp, +} from "./protocol"; + +type Pending = { + resolve: (result: never) => void; + reject: (error: Error) => void; +}; + +/** Methods exposed by the language client (main-thread side of the worker). */ +export type LanguageClientApi = { + /** Initialize the server with the full SDCPN model (notification, no response). */ + initialize: (sdcpn: SDCPN) => void; + /** Notify the server that the SDCPN model has changed structurally (notification). */ + notifySDCPNChanged: (sdcpn: SDCPN) => void; + /** Notify the server that a single document's content changed (notification). */ + notifyDocumentChanged: (uri: DocumentUri, text: string) => void; + /** Request completions at a position within a document. */ + requestCompletion: ( + uri: DocumentUri, + offset: number, + ) => Promise; + /** Request hover info at a position within a document. */ + requestHover: (uri: DocumentUri, offset: number) => Promise; + /** Request signature help at a position within a document. */ + requestSignatureHelp: ( + uri: DocumentUri, + offset: number, + ) => Promise; + /** Register a callback for diagnostics pushed from the server. */ + onDiagnostics: ( + callback: (params: PublishDiagnosticsParams[]) => void, + ) => void; +}; + +/** + * Spawn a checker WebWorker and return an LSP-inspired API to interact with it. + * The worker is created on mount and terminated on unmount. + */ +export function useLanguageClient(): LanguageClientApi { + const workerRef = useRef(null); + const pendingRef = useRef(new Map()); + const nextId = useRef(0); + const diagnosticsCallbackRef = useRef< + ((params: PublishDiagnosticsParams[]) => void) | null + >(null); + + useEffect(() => { + const worker = new Worker(new URL("./checker.worker.ts", import.meta.url), { + type: "module", + }); + + worker.onmessage = (event: MessageEvent) => { + const msg = event.data; + + if ("id" in msg) { + // Response to a request + const pending = pendingRef.current.get(msg.id); + if (!pending) { + return; + } + pendingRef.current.delete(msg.id); + + if ("error" in msg) { + pending.reject(new Error(msg.error.message)); + } else { + pending.resolve(msg.result as never); + } + } else if ( + "method" in msg && + msg.method === "textDocument/publishDiagnostics" + ) { + // Server-pushed notification + diagnosticsCallbackRef.current?.(msg.params); + } + }; + + workerRef.current = worker; + const pending = pendingRef.current; + + return () => { + worker.terminate(); + workerRef.current = null; + for (const entry of pending.values()) { + entry.reject(new Error("Worker terminated")); + } + pending.clear(); + }; + }, []); + + // --- Notifications (fire-and-forget) --- + + const sendNotification = useCallback((message: Omit) => { + workerRef.current?.postMessage(message); + }, []); + + const initialize = useCallback( + (sdcpn: SDCPN) => { + sendNotification({ + jsonrpc: "2.0", + method: "initialize", + params: { sdcpn }, + }); + }, + [sendNotification], + ); + + const notifySDCPNChanged = useCallback( + (sdcpn: SDCPN) => { + sendNotification({ + jsonrpc: "2.0", + method: "sdcpn/didChange", + params: { sdcpn }, + }); + }, + [sendNotification], + ); + + const notifyDocumentChanged = useCallback( + (uri: DocumentUri, text: string) => { + sendNotification({ + jsonrpc: "2.0", + method: "textDocument/didChange", + params: { textDocument: { uri }, text }, + }); + }, + [sendNotification], + ); + + // --- Requests (return Promise) --- + + const sendRequest = useCallback((message: ClientMessage): Promise => { + const worker = workerRef.current; + if (!worker) { + return Promise.reject(new Error("Worker not initialized")); + } + + return new Promise((resolve, reject) => { + pendingRef.current.set((message as { id: number }).id, { + resolve: resolve as (result: never) => void, + reject, + }); + worker.postMessage(message); + }); + }, []); + + const requestCompletion = useCallback( + (uri: DocumentUri, offset: number): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "textDocument/completion", + params: { textDocument: { uri }, offset }, + }); + }, + [sendRequest], + ); + + const requestHover = useCallback( + (uri: DocumentUri, offset: number): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "textDocument/hover", + params: { textDocument: { uri }, offset }, + }); + }, + [sendRequest], + ); + + const requestSignatureHelp = useCallback( + (uri: DocumentUri, offset: number): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "textDocument/signatureHelp", + params: { textDocument: { uri }, offset }, + }); + }, + [sendRequest], + ); + + const onDiagnostics = useCallback( + (callback: (params: PublishDiagnosticsParams[]) => void) => { + diagnosticsCallbackRef.current = callback; + }, + [], + ); + + return { + initialize, + notifySDCPNChanged, + notifyDocumentChanged, + requestCompletion, + requestHover, + requestSignatureHelp, + onDiagnostics, + }; +} diff --git a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx index 9a9cbbe0868..dfdb5624cca 100644 --- a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx @@ -1,10 +1,9 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; -import { CheckerContext } from "../checker/context"; -import type { CheckerCompletionItem } from "../checker/worker/protocol"; +import { LanguageClientContext } from "../checker/context"; +import type { CompletionItem } from "../checker/worker/protocol"; import { MonacoContext } from "./context"; -import { parseEditorPath } from "./editor-paths"; /** * Map TypeScript `ScriptElementKind` strings to Monaco `CompletionItemKind`. @@ -60,14 +59,14 @@ function toCompletionItemKind( } function toMonacoCompletion( - entry: CheckerCompletionItem, + entry: CompletionItem, range: Monaco.IRange, monaco: typeof Monaco, ): Monaco.languages.CompletionItem { return { - label: entry.name, + label: entry.label, kind: toCompletionItemKind(entry.kind, monaco), - insertText: entry.insertText ?? entry.name, + insertText: entry.insertText ?? entry.label, sortText: entry.sortText, range, }; @@ -75,7 +74,7 @@ function toMonacoCompletion( const CompletionSyncInner = () => { const { monaco } = use(use(MonacoContext)); - const { getCompletions } = use(CheckerContext); + const { requestCompletion } = use(LanguageClientContext); useEffect(() => { const disposable = monaco.languages.registerCompletionItemProvider( @@ -84,17 +83,9 @@ const CompletionSyncInner = () => { triggerCharacters: ["."], async provideCompletionItems(model, position) { - const parsed = parseEditorPath(model.uri.toString()); - if (!parsed) { - return { suggestions: [] }; - } - + const uri = model.uri.toString(); const offset = model.getOffsetAt(position); - const result = await getCompletions( - parsed.itemType, - parsed.itemId, - offset, - ); + const result = await requestCompletion(uri, offset); const word = model.getWordUntilPosition(position); const range: Monaco.IRange = { @@ -114,12 +105,12 @@ const CompletionSyncInner = () => { ); return () => disposable.dispose(); - }, [monaco, getCompletions]); + }, [monaco, requestCompletion]); return null; }; -/** Renders nothing visible — registers a Monaco CompletionItemProvider backed by the checker worker. */ +/** Renders nothing visible — registers a Monaco CompletionItemProvider backed by the language server. */ export const CompletionSync: React.FC = () => ( diff --git a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx index 9bd907d02d5..adcbffcbcbe 100644 --- a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -1,44 +1,46 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect, useRef } from "react"; -import { CheckerContext } from "../checker/context"; -import type { CheckerDiagnostic } from "../checker/worker/protocol"; +import { LanguageClientContext } from "../checker/context"; +import type { Diagnostic } from "../checker/worker/protocol"; import { MonacoContext } from "./context"; -import { getEditorPath } from "./editor-paths"; const OWNER = "checker"; -/** Convert ts.DiagnosticCategory number to Monaco MarkerSeverity. */ +/** + * Convert LSP-like severity to Monaco MarkerSeverity. + * LSP: 1=Error, 2=Warning, 3=Information, 4=Hint + */ function toMarkerSeverity( - category: number, + severity: number, monaco: typeof Monaco, ): Monaco.MarkerSeverity { - switch (category) { - case 0: - return monaco.MarkerSeverity.Warning; + switch (severity) { case 1: return monaco.MarkerSeverity.Error; case 2: - return monaco.MarkerSeverity.Hint; + return monaco.MarkerSeverity.Warning; case 3: return monaco.MarkerSeverity.Info; + case 4: + return monaco.MarkerSeverity.Hint; default: return monaco.MarkerSeverity.Error; } } -/** Convert CheckerDiagnostic[] to IMarkerData[] using the model for offset→position. */ +/** Convert Diagnostic[] to IMarkerData[] using the model for offset→position. */ function diagnosticsToMarkers( model: Monaco.editor.ITextModel, - diagnostics: CheckerDiagnostic[], + diagnostics: Diagnostic[], monaco: typeof Monaco, ): Monaco.editor.IMarkerData[] { return diagnostics.map((diag) => { const start = model.getPositionAt(diag.start ?? 0); const end = model.getPositionAt((diag.start ?? 0) + (diag.length ?? 0)); return { - severity: toMarkerSeverity(diag.category, monaco), - message: diag.messageText, + severity: toMarkerSeverity(diag.severity, monaco), + message: diag.message, startLineNumber: start.lineNumber, startColumn: start.column, endLineNumber: end.lineNumber, @@ -50,57 +52,52 @@ function diagnosticsToMarkers( const DiagnosticsSyncInner = () => { const { monaco } = use(use(MonacoContext)); - const { checkResult } = use(CheckerContext); - const prevPathsRef = useRef>(new Set()); + const { diagnosticsByUri } = use(LanguageClientContext); + const prevUrisRef = useRef>(new Set()); useEffect(() => { - const currentPaths = new Set(); + const currentUris = new Set(); - for (const item of checkResult.itemDiagnostics) { - const path = getEditorPath(item.itemType, item.itemId); - const uri = monaco.Uri.parse(path); - const model = monaco.editor.getModel(uri); + for (const [uri, diagnostics] of diagnosticsByUri) { + const monacoUri = monaco.Uri.parse(uri); + const model = monaco.editor.getModel(monacoUri); if (model) { - const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + const markers = diagnosticsToMarkers(model, diagnostics, monaco); monaco.editor.setModelMarkers(model, OWNER, markers); } - currentPaths.add(path); + currentUris.add(uri); } // Clear markers from models that no longer have diagnostics - for (const path of prevPathsRef.current) { - if (!currentPaths.has(path)) { - const uri = monaco.Uri.parse(path); - const model = monaco.editor.getModel(uri); + for (const uri of prevUrisRef.current) { + if (!currentUris.has(uri)) { + const monacoUri = monaco.Uri.parse(uri); + const model = monaco.editor.getModel(monacoUri); if (model) { monaco.editor.setModelMarkers(model, OWNER, []); } } } - prevPathsRef.current = currentPaths; + prevUrisRef.current = currentUris; // Handle models created after diagnostics arrived const disposable = monaco.editor.onDidCreateModel((model) => { const modelUri = model.uri.toString(); - const item = checkResult.itemDiagnostics.find( - (i) => - monaco.Uri.parse(getEditorPath(i.itemType, i.itemId)).toString() === - modelUri, - ); - if (item) { - const markers = diagnosticsToMarkers(model, item.diagnostics, monaco); + const diags = diagnosticsByUri.get(modelUri); + if (diags) { + const markers = diagnosticsToMarkers(model, diags, monaco); monaco.editor.setModelMarkers(model, OWNER, markers); } }); return () => disposable.dispose(); - }, [checkResult, monaco]); + }, [diagnosticsByUri, monaco]); return null; }; -/** Renders nothing visible — syncs CheckerContext diagnostics to Monaco model markers. */ +/** Renders nothing visible — syncs diagnostics from LanguageClientContext to Monaco model markers. */ export const DiagnosticsSync: React.FC = () => ( diff --git a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts index f6f167e5ef6..956b3d78b2a 100644 --- a/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts +++ b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts @@ -1,10 +1,7 @@ -import type { CheckerItemDiagnostics } from "../checker/worker/protocol"; +import type { ItemType } from "../checker/lib/checker"; -/** Generates the Monaco model path for a given SDCPN item. */ -export function getEditorPath( - itemType: CheckerItemDiagnostics["itemType"], - itemId: string, -): string { +/** Generates the document URI for a given SDCPN item (used as Monaco model URI). */ +export function getDocumentUri(itemType: ItemType, itemId: string): string { switch (itemType) { case "transition-lambda": return `inmemory://sdcpn/transitions/${itemId}/lambda.ts`; @@ -22,9 +19,9 @@ const TRANSITION_KERNEL_RE = const DIFFERENTIAL_EQUATION_RE = /^inmemory:\/\/sdcpn\/differential-equations\/([^/]+)\.ts$/; -/** Extract `(itemType, itemId)` from a Monaco model URI string. */ -export function parseEditorPath(uri: string): { - itemType: CheckerItemDiagnostics["itemType"]; +/** Extract `(itemType, itemId)` from a document URI string. */ +export function parseDocumentUri(uri: string): { + itemType: ItemType; itemId: string; } | null { let match = TRANSITION_LAMBDA_RE.exec(uri); diff --git a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx index 06b3bd14d87..e3cb0aeab27 100644 --- a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -1,24 +1,19 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; -import { CheckerContext } from "../checker/context"; +import { LanguageClientContext } from "../checker/context"; import { MonacoContext } from "./context"; -import { parseEditorPath } from "./editor-paths"; const HoverSyncInner = () => { const { monaco } = use(use(MonacoContext)); - const { getQuickInfo } = use(CheckerContext); + const { requestHover } = use(LanguageClientContext); useEffect(() => { const disposable = monaco.languages.registerHoverProvider("typescript", { async provideHover(model, position) { - const parsed = parseEditorPath(model.uri.toString()); - if (!parsed) { - return null; - } - + const uri = model.uri.toString(); const offset = model.getOffsetAt(position); - const info = await getQuickInfo(parsed.itemType, parsed.itemId, offset); + const info = await requestHover(uri, offset); if (!info) { return null; @@ -45,12 +40,12 @@ const HoverSyncInner = () => { }); return () => disposable.dispose(); - }, [monaco, getQuickInfo]); + }, [monaco, requestHover]); return null; }; -/** Renders nothing visible — registers a Monaco HoverProvider backed by the checker worker. */ +/** Renders nothing visible — registers a Monaco HoverProvider backed by the language server. */ export const HoverSync: React.FC = () => ( diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index 6488438e526..249629de23c 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -63,14 +63,19 @@ async function initMonaco(): Promise { return { monaco, Editor: monacoReact.default }; } +/** Module-level lazy singleton — initialized once, reused across renders. */ +let monacoPromise: Promise | null = null; +function getMonacoPromise(): Promise { + return (monacoPromise ??= initMonaco()); +} + export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - // Stable promise reference — created once, never changes. - const monacoPromise = initMonaco(); + const promise = getMonacoPromise(); return ( - + diff --git a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx index 54f71f59f8b..6fc77d04abf 100644 --- a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -1,13 +1,12 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; -import { CheckerContext } from "../checker/context"; -import type { CheckerSignatureHelpResult } from "../checker/worker/protocol"; +import { LanguageClientContext } from "../checker/context"; +import type { SignatureHelp } from "../checker/worker/protocol"; import { MonacoContext } from "./context"; -import { parseEditorPath } from "./editor-paths"; function toMonacoSignatureHelp( - result: NonNullable, + result: NonNullable, ): Monaco.languages.SignatureHelp { return { activeSignature: result.activeSignature, @@ -25,7 +24,7 @@ function toMonacoSignatureHelp( const SignatureHelpSyncInner = () => { const { monaco } = use(use(MonacoContext)); - const { getSignatureHelp } = use(CheckerContext); + const { requestSignatureHelp } = use(LanguageClientContext); useEffect(() => { const disposable = monaco.languages.registerSignatureHelpProvider( @@ -35,17 +34,9 @@ const SignatureHelpSyncInner = () => { signatureHelpRetriggerCharacters: [","], async provideSignatureHelp(model, position) { - const parsed = parseEditorPath(model.uri.toString()); - if (!parsed) { - return null; - } - + const uri = model.uri.toString(); const offset = model.getOffsetAt(position); - const result = await getSignatureHelp( - parsed.itemType, - parsed.itemId, - offset, - ); + const result = await requestSignatureHelp(uri, offset); if (!result) { return null; @@ -60,12 +51,12 @@ const SignatureHelpSyncInner = () => { ); return () => disposable.dispose(); - }, [monaco, getSignatureHelp]); + }, [monaco, requestSignatureHelp]); return null; }; -/** Renders nothing visible — registers a Monaco SignatureHelpProvider backed by the checker worker. */ +/** Renders nothing visible — registers a Monaco SignatureHelpProvider backed by the language server. */ export const SignatureHelpSync: React.FC = () => ( diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index df237136b33..8b06fa79f52 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -1,7 +1,7 @@ import "reactflow/dist/style.css"; import "./index.css"; -import { CheckerProvider } from "./checker/provider"; +import { LanguageClientProvider } from "./checker/provider"; import type { Color, DifferentialEquation, @@ -98,7 +98,7 @@ export const Petrinaut = ({ return ( - + @@ -110,7 +110,7 @@ export const Petrinaut = ({ - + ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx index e82175b8e04..a92fca2955a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx @@ -3,7 +3,7 @@ import { refractive } from "@hashintel/refractive"; import { use, useCallback, useEffect } from "react"; import { FaChevronDown, FaChevronUp } from "react-icons/fa6"; -import { CheckerContext } from "../../../../checker/context"; +import { LanguageClientContext } from "../../../../checker/context"; import { EditorContext, type EditorState, @@ -69,7 +69,7 @@ export const BottomBar: React.FC = ({ bottomPanelHeight, } = use(EditorContext); - const { totalDiagnosticsCount } = use(CheckerContext); + const { totalDiagnosticsCount } = use(LanguageClientContext); const hasDiagnostics = totalDiagnosticsCount > 0; const showDiagnostics = useCallback(() => { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx index 648a2b0ffa4..7bca84a9515 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/diagnostics-indicator.tsx @@ -2,7 +2,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaCheck, FaXmark } from "react-icons/fa6"; -import { CheckerContext } from "../../../../checker/context"; +import { LanguageClientContext } from "../../../../checker/context"; import { ToolbarButton } from "./toolbar-button"; const iconContainerStyle = cva({ @@ -47,7 +47,7 @@ export const DiagnosticsIndicator: React.FC = ({ onClick, isExpanded, }) => { - const { totalDiagnosticsCount } = use(CheckerContext); + const { totalDiagnosticsCount } = use(LanguageClientContext); const hasErrors = totalDiagnosticsCount > 0; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx index 6556c3f2712..90bcd3b2a71 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx @@ -19,7 +19,7 @@ import type { Place, } from "../../../../core/types/sdcpn"; import { CodeEditor } from "../../../../monaco/code-editor"; -import { getEditorPath } from "../../../../monaco/editor-paths"; +import { getDocumentUri } from "../../../../monaco/editor-paths"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; const containerStyle = css({ @@ -477,7 +477,10 @@ export const DifferentialEquationProperties: React.FC< )} = ({ )} `${a.placeId}:${a.weight}`) .join("-")}`} @@ -492,7 +492,7 @@ export const TransitionProperties: React.FC = ({ )} ; } @@ -111,7 +112,9 @@ interface GroupedDiagnostics { * DiagnosticsContent shows the full list of diagnostics grouped by entity. */ const DiagnosticsContent: React.FC = () => { - const { checkResult, totalDiagnosticsCount } = use(CheckerContext); + const { diagnosticsByUri, totalDiagnosticsCount } = use( + LanguageClientContext, + ); const { petriNetDefinition } = use(SDCPNContext); const { setSelectedResourceId } = use(EditorContext); // Track collapsed entities (all expanded by default) @@ -131,13 +134,18 @@ const DiagnosticsContent: React.FC = () => { const groupedDiagnostics = useMemo(() => { const groups = new Map(); - for (const item of checkResult.itemDiagnostics) { - const entityId = item.itemId; + for (const [uri, diagnostics] of diagnosticsByUri) { + const parsed = parseDocumentUri(uri); + if (!parsed) { + continue; + } + + const entityId = parsed.itemId; let entityType: EntityType; let entityName: string; let subType: "lambda" | "kernel" | null; - if (item.itemType === "differential-equation") { + if (parsed.itemType === "differential-equation") { entityType = "differential-equation"; const de = petriNetDefinition.differentialEquations.find( (deItem) => deItem.id === entityId, @@ -150,7 +158,7 @@ const DiagnosticsContent: React.FC = () => { (tr) => tr.id === entityId, ); entityName = transition?.name ?? entityId; - subType = item.itemType === "transition-lambda" ? "lambda" : "kernel"; + subType = parsed.itemType === "transition-lambda" ? "lambda" : "kernel"; } const key = `${entityType}:${entityId}`; @@ -165,15 +173,15 @@ const DiagnosticsContent: React.FC = () => { } const group = groups.get(key)!; - group.errorCount += item.diagnostics.length; + group.errorCount += diagnostics.length; group.items.push({ subType, - diagnostics: item.diagnostics, + diagnostics, }); } return Array.from(groups.values()); - }, [checkResult, petriNetDefinition]); + }, [diagnosticsByUri, petriNetDefinition]); const toggleEntity = useCallback((entityKey: string) => { setCollapsedEntities((prev) => { @@ -253,7 +261,7 @@ const DiagnosticsContent: React.FC = () => { className={diagnosticButtonStyle} > - {diagnostic.messageText} + {diagnostic.message} {diagnostic.start !== undefined && ( (pos: {diagnostic.start}) From 201124e8474eee9b5af7963fb5df78c27ec407b2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 26 Feb 2026 13:25:51 +0100 Subject: [PATCH 11/22] H-5839: Adopt vscode-languageserver-types for standard LSP protocol types Replace hand-rolled Diagnostic, CompletionItem, Hover, and SignatureHelp types with official LSP types from vscode-languageserver-types. Protocol now uses LSP Position (line/character) instead of raw offsets, with offset<->position conversion utilities bridging the TS LanguageService (offset-based) and LSP (Position-based) worlds. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/package.json | 1 + .../petrinaut/src/checker/context.ts | 11 +- .../lib/create-sdcpn-language-service.ts | 15 +- .../src/checker/lib/position-utils.test.ts | 68 +++++++ .../src/checker/lib/position-utils.ts | 38 ++++ .../src/checker/worker/checker.worker.ts | 180 +++++++++++++----- .../petrinaut/src/checker/worker/protocol.ts | 132 ++++--------- .../src/checker/worker/use-language-client.ts | 30 ++- .../petrinaut/src/monaco/completion-sync.tsx | 67 +++---- .../petrinaut/src/monaco/diagnostics-sync.tsx | 46 ++--- .../petrinaut/src/monaco/hover-sync.tsx | 79 ++++++-- .../src/monaco/signature-help-sync.tsx | 46 +++-- .../src/views/Editor/subviews/diagnostics.tsx | 16 +- yarn.lock | 8 + 14 files changed, 468 insertions(+), 269 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/checker/lib/position-utils.test.ts create mode 100644 libs/@hashintel/petrinaut/src/checker/lib/position-utils.ts diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index c07cdbc5008..85d9c0a1069 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -55,6 +55,7 @@ "reactflow": "11.11.4", "typescript": "5.9.3", "uuid": "13.0.0", + "vscode-languageserver-types": "3.17.5", "web-worker": "1.4.1" }, "devDependencies": { diff --git a/libs/@hashintel/petrinaut/src/checker/context.ts b/libs/@hashintel/petrinaut/src/checker/context.ts index eeacedf8c1f..e3c07ea5114 100644 --- a/libs/@hashintel/petrinaut/src/checker/context.ts +++ b/libs/@hashintel/petrinaut/src/checker/context.ts @@ -5,6 +5,7 @@ import type { Diagnostic, DocumentUri, Hover, + Position, SignatureHelp, } from "./worker/protocol"; @@ -18,22 +19,22 @@ export interface LanguageClientContextValue { /** Request completions at a position within a document. */ requestCompletion: ( uri: DocumentUri, - offset: number, + position: Position, ) => Promise; /** Request hover info at a position within a document. */ - requestHover: (uri: DocumentUri, offset: number) => Promise; + requestHover: (uri: DocumentUri, position: Position) => Promise; /** Request signature help at a position within a document. */ requestSignatureHelp: ( uri: DocumentUri, - offset: number, - ) => Promise; + position: Position, + ) => Promise; } const DEFAULT_CONTEXT_VALUE: LanguageClientContextValue = { diagnosticsByUri: new Map(), totalDiagnosticsCount: 0, notifyDocumentChanged: () => {}, - requestCompletion: () => Promise.resolve({ items: [] }), + requestCompletion: () => Promise.resolve({ isIncomplete: false, items: [] }), requestHover: () => Promise.resolve(null), requestSignatureHelp: () => Promise.resolve(null), }; diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index e790d0effc5..778c54bae76 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -278,10 +278,10 @@ export class SDCPNLanguageServer { if (!this.controller.hasFile(name)) { this.controller.addFile(name, newFile); } else { - const existing = this.controller.getFile(name); + const existing = this.controller.getFile(name)!; if ( - existing?.content !== newFile.content || - existing?.prefix !== newFile.prefix + existing.content !== newFile.content || + existing.prefix !== newFile.prefix ) { this.controller.updateFile(name, newFile); } @@ -294,6 +294,15 @@ export class SDCPNLanguageServer { this.controller.updateContent(fileName, content); } + /** Get the full text content (prefix + user content) of a virtual file. */ + getFileContent(fileName: string): string | undefined { + const file = this.controller.getFile(fileName); + if (!file) { + return undefined; + } + return (file.prefix ?? "") + file.content; + } + getSemanticDiagnostics(fileName: string): ts.Diagnostic[] { const entry = this.controller.getFile(fileName); const prefixLength = entry?.prefix?.length ?? 0; diff --git a/libs/@hashintel/petrinaut/src/checker/lib/position-utils.test.ts b/libs/@hashintel/petrinaut/src/checker/lib/position-utils.test.ts new file mode 100644 index 00000000000..894cfea8327 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/lib/position-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { Position } from "vscode-languageserver-types"; + +import { offsetToPosition, positionToOffset } from "./position-utils"; + +describe("offsetToPosition", () => { + it("returns 0:0 for offset 0", () => { + expect(offsetToPosition("hello", 0)).toEqual(Position.create(0, 0)); + }); + + it("returns correct position within a single line", () => { + expect(offsetToPosition("hello", 3)).toEqual(Position.create(0, 3)); + }); + + it("returns start of second line after newline", () => { + expect(offsetToPosition("ab\ncd", 3)).toEqual(Position.create(1, 0)); + }); + + it("returns correct position on second line", () => { + expect(offsetToPosition("ab\ncd", 4)).toEqual(Position.create(1, 1)); + }); + + it("handles multiple newlines", () => { + const text = "line1\nline2\nline3"; + // 'l' in line3 is at offset 12 + expect(offsetToPosition(text, 12)).toEqual(Position.create(2, 0)); + expect(offsetToPosition(text, 15)).toEqual(Position.create(2, 3)); + }); + + it("clamps to end of text", () => { + expect(offsetToPosition("ab", 100)).toEqual(Position.create(0, 2)); + }); +}); + +describe("positionToOffset", () => { + it("returns 0 for position 0:0", () => { + expect(positionToOffset("hello", Position.create(0, 0))).toBe(0); + }); + + it("returns correct offset within a single line", () => { + expect(positionToOffset("hello", Position.create(0, 3))).toBe(3); + }); + + it("returns offset at start of second line", () => { + expect(positionToOffset("ab\ncd", Position.create(1, 0))).toBe(3); + }); + + it("returns correct offset on second line", () => { + expect(positionToOffset("ab\ncd", Position.create(1, 1))).toBe(4); + }); + + it("handles multiple newlines", () => { + const text = "line1\nline2\nline3"; + expect(positionToOffset(text, Position.create(2, 0))).toBe(12); + expect(positionToOffset(text, Position.create(2, 3))).toBe(15); + }); +}); + +describe("roundtrip", () => { + it("offsetToPosition → positionToOffset is identity", () => { + const text = "function foo() {\n return 42;\n}\n"; + for (let offset = 0; offset <= text.length; offset++) { + const pos = offsetToPosition(text, offset); + const recovered = positionToOffset(text, pos); + expect(recovered).toBe(offset); + } + }); +}); diff --git a/libs/@hashintel/petrinaut/src/checker/lib/position-utils.ts b/libs/@hashintel/petrinaut/src/checker/lib/position-utils.ts new file mode 100644 index 00000000000..27b5097cd23 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/lib/position-utils.ts @@ -0,0 +1,38 @@ +import { Position } from "vscode-languageserver-types"; + +/** + * Convert a character offset to an LSP Position (zero-based line and character). + */ +export function offsetToPosition(text: string, offset: number): Position { + let line = 0; + let character = 0; + const clamped = Math.min(offset, text.length); + + for (let i = 0; i < clamped; i++) { + if (text[i] === "\n") { + line++; + character = 0; + } else { + character++; + } + } + + return Position.create(line, character); +} + +/** + * Convert an LSP Position (zero-based line and character) to a character offset. + */ +export function positionToOffset(text: string, position: Position): number { + let line = 0; + let i = 0; + + while (i < text.length && line < position.line) { + if (text[i] === "\n") { + line++; + } + i++; + } + + return i + position.character; +} diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts index df0f1ef56f5..4f065c86b40 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -10,22 +10,29 @@ * The LanguageService is created once and reused across SDCPN changes. */ import ts from "typescript"; +import { + CompletionItemKind, + DiagnosticSeverity, + MarkupKind, + Range, + type CompletionItem, + type CompletionList, + type Diagnostic, + type DocumentUri, + type Hover, + type SignatureHelp, + type SignatureInformation, +} from "vscode-languageserver-types"; import type { SDCPN } from "../../core/types/sdcpn"; import { checkSDCPN } from "../lib/checker"; import { SDCPNLanguageServer } from "../lib/create-sdcpn-language-service"; import { getItemFilePath } from "../lib/file-paths"; +import { offsetToPosition, positionToOffset } from "../lib/position-utils"; import type { ClientMessage, - CompletionItem, - CompletionList, - Diagnostic, - DocumentUri, - Hover, PublishDiagnosticsParams, ServerMessage, - SignatureHelp, - SignatureInformation, } from "./protocol"; // --------------------------------------------------------------------------- @@ -87,36 +94,92 @@ function filePathToUri(filePath: string): DocumentUri | null { } // --------------------------------------------------------------------------- -// Serialization helpers +// TS → LSP type conversions // --------------------------------------------------------------------------- /** - * Map `ts.DiagnosticCategory` to LSP-like severity. + * Map `ts.DiagnosticCategory` to `DiagnosticSeverity`. * TS: 0=Warning, 1=Error, 2=Suggestion, 3=Message * LSP: 1=Error, 2=Warning, 3=Information, 4=Hint */ -function toLspSeverity(category: number): number { +function toLspSeverity(category: number): DiagnosticSeverity { switch (category) { case 0: - return 2; // Warning + return DiagnosticSeverity.Warning; case 1: - return 1; // Error + return DiagnosticSeverity.Error; case 2: - return 4; // Hint (Suggestion) + return DiagnosticSeverity.Hint; case 3: - return 3; // Information (Message) + return DiagnosticSeverity.Information; default: - return 1; + return DiagnosticSeverity.Error; } } -function serializeDiagnostic(diag: ts.Diagnostic): Diagnostic { +/** + * Map TS `ScriptElementKind` strings to LSP `CompletionItemKind`. + */ +function toCompletionItemKind(kind: string): CompletionItemKind { + switch (kind) { + case "method": + case "construct": + return CompletionItemKind.Method; + case "function": + case "local function": + return CompletionItemKind.Function; + case "constructor": + return CompletionItemKind.Constructor; + case "property": + case "getter": + case "setter": + return CompletionItemKind.Property; + case "parameter": + case "var": + case "local var": + case "let": + case "const": + return CompletionItemKind.Variable; + case "class": + return CompletionItemKind.Class; + case "interface": + return CompletionItemKind.Interface; + case "type": + case "type parameter": + case "primitive type": + case "alias": + return CompletionItemKind.TypeParameter; + case "enum": + return CompletionItemKind.Enum; + case "enum member": + return CompletionItemKind.EnumMember; + case "module": + case "external module name": + return CompletionItemKind.Module; + case "keyword": + return CompletionItemKind.Keyword; + case "string": + return CompletionItemKind.Value; + default: + return CompletionItemKind.Text; + } +} + +function serializeDiagnostic( + diag: ts.Diagnostic, + fileContent: string, +): Diagnostic { + const start = diag.start ?? 0; + const end = start + (diag.length ?? 0); return { severity: toLspSeverity(diag.category), - code: diag.code, + range: Range.create( + offsetToPosition(fileContent, start), + offsetToPosition(fileContent, end), + ), message: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), - start: diag.start, - length: diag.length, + code: diag.code, + source: "ts", }; } @@ -152,9 +215,12 @@ function publishAllDiagnostics(sdcpn: SDCPN): void { const params: PublishDiagnosticsParams[] = result.itemDiagnostics.map( (item) => { const uri = filePathToUri(item.filePath); + const fileContent = server!.getFileContent(item.filePath) ?? ""; return { uri: uri ?? item.filePath, - diagnostics: item.diagnostics.map(serializeDiagnostic), + diagnostics: item.diagnostics.map((diag) => + serializeDiagnostic(diag, fileContent), + ), }; }, ); @@ -190,9 +256,7 @@ self.onmessage = ({ data }: MessageEvent) => { case "sdcpn/didChange": { const { sdcpn } = data.params; lastSDCPN = sdcpn; - if (!server) { - server = new SDCPNLanguageServer(); - } + server ??= new SDCPNLanguageServer(); server.syncFiles(sdcpn); publishAllDiagnostics(sdcpn); break; @@ -218,32 +282,41 @@ self.onmessage = ({ data }: MessageEvent) => { case "textDocument/completion": { const { id } = data; if (!server) { - respond(id, { items: [] } satisfies CompletionList); + respond(id, { + isIncomplete: false, + items: [], + } satisfies CompletionList); break; } const filePath = uriToFilePath(data.params.textDocument.uri); if (!filePath) { - respond(id, { items: [] } satisfies CompletionList); + respond(id, { + isIncomplete: false, + items: [], + } satisfies CompletionList); break; } + const fileContent = server.getFileContent(filePath) ?? ""; + const offset = positionToOffset(fileContent, data.params.position); + const completions = server.getCompletionsAtPosition( filePath, - data.params.offset, + offset, undefined, ); const items: CompletionItem[] = (completions?.entries ?? []).map( (entry) => ({ label: entry.name, - kind: entry.kind, + kind: toCompletionItemKind(entry.kind), sortText: entry.sortText, insertText: entry.insertText, }), ); - respond(id, { items } satisfies CompletionList); + respond(id, { isIncomplete: false, items } satisfies CompletionList); break; } @@ -260,17 +333,29 @@ self.onmessage = ({ data }: MessageEvent) => { break; } - const info = server.getQuickInfoAtPosition( - filePath, - data.params.offset, - ); + const fileContent = server.getFileContent(filePath) ?? ""; + const offset = positionToOffset(fileContent, data.params.position); - const result: Hover = info + const info = server.getQuickInfoAtPosition(filePath, offset); + + const result: Hover | null = info ? { - displayParts: ts.displayPartsToString(info.displayParts), - documentation: ts.displayPartsToString(info.documentation), - start: info.textSpan.start, - length: info.textSpan.length, + contents: { + kind: MarkupKind.Markdown, + value: [ + `\`\`\`typescript\n${ts.displayPartsToString(info.displayParts)}\n\`\`\``, + ts.displayPartsToString(info.documentation), + ] + .filter(Boolean) + .join("\n\n"), + }, + range: Range.create( + offsetToPosition(fileContent, info.textSpan.start), + offsetToPosition( + fileContent, + info.textSpan.start + info.textSpan.length, + ), + ), } : null; @@ -291,13 +376,12 @@ self.onmessage = ({ data }: MessageEvent) => { break; } - const help = server.getSignatureHelpItems( - filePath, - data.params.offset, - undefined, - ); + const fileContent = server.getFileContent(filePath) ?? ""; + const offset = positionToOffset(fileContent, data.params.position); + + const help = server.getSignatureHelpItems(filePath, offset, undefined); - const result: SignatureHelp = help + const result: SignatureHelp | null = help ? { activeSignature: help.selectedItemIndex, activeParameter: help.argumentIndex, @@ -313,10 +397,16 @@ self.onmessage = ({ data }: MessageEvent) => { ] .map((part) => part.text) .join(""), - documentation: ts.displayPartsToString(item.documentation), + documentation: { + kind: MarkupKind.PlainText, + value: ts.displayPartsToString(item.documentation), + }, parameters: item.parameters.map((param) => ({ label: ts.displayPartsToString(param.displayParts), - documentation: ts.displayPartsToString(param.documentation), + documentation: { + kind: MarkupKind.PlainText, + value: ts.displayPartsToString(param.documentation), + }, })), }), ), diff --git a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts index 057336a6dae..a56971ac090 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/protocol.ts @@ -1,121 +1,53 @@ /** * LSP-inspired protocol types for the checker WebWorker. * - * Uses JSON-RPC 2.0 with LSP-like method names and structures. - * Diagnostic types are serializable (no `ts.SourceFile` references) so they - * can cross the postMessage boundary via structured clone. + * Uses JSON-RPC 2.0 with standard LSP types from `vscode-languageserver-types`. + * All diagnostic, completion, hover, and signature help types are the official + * LSP types, serializable for postMessage structured clone. * - * Deviations from LSP: - * - Uses character offsets instead of line/character positions (both Monaco - * and the TS LanguageService work in offsets internally). - * - Uses `sdcpn/didChange` for structural model changes (not per-document). + * Custom extensions: + * - `sdcpn/didChange` for structural model changes (not per-document). */ -import type { SDCPN } from "../../core/types/sdcpn"; +import type { + CompletionItem, + CompletionList, + Diagnostic, + DocumentUri, + Hover, + Position, + SignatureHelp, + TextDocumentIdentifier, +} from "vscode-languageserver-types"; -// --------------------------------------------------------------------------- -// Document identification -// --------------------------------------------------------------------------- - -/** URI identifying a text document (e.g., "inmemory://sdcpn/transitions/t1/lambda.ts"). */ -export type DocumentUri = string; +import type { SDCPN } from "../../core/types/sdcpn"; -export type TextDocumentIdentifier = { - uri: DocumentUri; +// Re-export LSP types used by consumers +export type { + CompletionItem, + CompletionList, + Diagnostic, + DocumentUri, + Hover, + Position, + SignatureHelp, + TextDocumentIdentifier, }; /** - * Position in a text document, specified as a character offset. - * - * Unlike standard LSP (which uses line/character), we use offsets since both - * Monaco and the TS LanguageService work in offsets internally. + * Parameters for `textDocument/publishDiagnostics` notification. + * Defined here rather than pulling in `vscode-languageserver-protocol`. */ -export type TextDocumentPositionParams = { - textDocument: TextDocumentIdentifier; - offset: number; -}; - -// --------------------------------------------------------------------------- -// Diagnostics -// --------------------------------------------------------------------------- - -/** A single diagnostic, safe for structured clone. */ -export type Diagnostic = { - /** Severity: 1=Error, 2=Warning, 3=Info, 4=Hint. */ - severity: number; - /** TypeScript error code (e.g. 2322 for type mismatch). */ - code: number; - /** Human-readable message, pre-flattened from `ts.DiagnosticMessageChain`. */ - message: string; - /** Character offset in user code where the diagnostic starts. */ - start: number | undefined; - /** Length of the diagnostic span in characters. */ - length: number | undefined; -}; - -/** Diagnostics for a single document. */ export type PublishDiagnosticsParams = { uri: DocumentUri; diagnostics: Diagnostic[]; }; -// --------------------------------------------------------------------------- -// Completions -// --------------------------------------------------------------------------- - -/** A single completion suggestion, safe for structured clone. */ -export type CompletionItem = { - label: string; - /** @see ts.ScriptElementKind */ - kind: string; - sortText: string; - insertText?: string; -}; - -/** Result of requesting completions at a position. */ -export type CompletionList = { - items: CompletionItem[]; -}; - -// --------------------------------------------------------------------------- -// Hover -// --------------------------------------------------------------------------- - -/** Result of requesting hover info at a position. */ -export type Hover = { - /** Type/signature display string. */ - displayParts: string; - /** JSDoc documentation string. */ - documentation: string; - /** Offset in user code where the hovered symbol starts. */ - start: number; - /** Length of the hovered symbol span. */ - length: number; -} | null; - -// --------------------------------------------------------------------------- -// Signature Help -// --------------------------------------------------------------------------- - -/** A single parameter in a signature. */ -export type SignatureParameter = { - label: string; - documentation: string; -}; - -/** A single signature (overload). */ -export type SignatureInformation = { - label: string; - documentation: string; - parameters: SignatureParameter[]; +/** Position in a text document (LSP standard: line/character based). */ +export type TextDocumentPositionParams = { + textDocument: TextDocumentIdentifier; + position: Position; }; -/** Result of requesting signature help at a position. */ -export type SignatureHelp = { - signatures: SignatureInformation[]; - activeSignature: number; - activeParameter: number; -} | null; - // --------------------------------------------------------------------------- // JSON-RPC 2.0 messages: main thread → worker // --------------------------------------------------------------------------- diff --git a/libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts b/libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts index 0cc12567b95..a5522658ca4 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/use-language-client.ts @@ -6,6 +6,7 @@ import type { CompletionList, DocumentUri, Hover, + Position, PublishDiagnosticsParams, ServerMessage, SignatureHelp, @@ -27,15 +28,15 @@ export type LanguageClientApi = { /** Request completions at a position within a document. */ requestCompletion: ( uri: DocumentUri, - offset: number, + position: Position, ) => Promise; /** Request hover info at a position within a document. */ - requestHover: (uri: DocumentUri, offset: number) => Promise; + requestHover: (uri: DocumentUri, position: Position) => Promise; /** Request signature help at a position within a document. */ requestSignatureHelp: ( uri: DocumentUri, - offset: number, - ) => Promise; + position: Position, + ) => Promise; /** Register a callback for diagnostics pushed from the server. */ onDiagnostics: ( callback: (params: PublishDiagnosticsParams[]) => void, @@ -75,10 +76,7 @@ export function useLanguageClient(): LanguageClientApi { } else { pending.resolve(msg.result as never); } - } else if ( - "method" in msg && - msg.method === "textDocument/publishDiagnostics" - ) { + } else if ("method" in msg) { // Server-pushed notification diagnosticsCallbackRef.current?.(msg.params); } @@ -154,39 +152,39 @@ export function useLanguageClient(): LanguageClientApi { }, []); const requestCompletion = useCallback( - (uri: DocumentUri, offset: number): Promise => { + (uri: DocumentUri, position: Position): Promise => { const id = nextId.current++; return sendRequest({ jsonrpc: "2.0", id, method: "textDocument/completion", - params: { textDocument: { uri }, offset }, + params: { textDocument: { uri }, position }, }); }, [sendRequest], ); const requestHover = useCallback( - (uri: DocumentUri, offset: number): Promise => { + (uri: DocumentUri, position: Position): Promise => { const id = nextId.current++; - return sendRequest({ + return sendRequest({ jsonrpc: "2.0", id, method: "textDocument/hover", - params: { textDocument: { uri }, offset }, + params: { textDocument: { uri }, position }, }); }, [sendRequest], ); const requestSignatureHelp = useCallback( - (uri: DocumentUri, offset: number): Promise => { + (uri: DocumentUri, position: Position): Promise => { const id = nextId.current++; - return sendRequest({ + return sendRequest({ jsonrpc: "2.0", id, method: "textDocument/signatureHelp", - params: { textDocument: { uri }, offset }, + params: { textDocument: { uri }, position }, }); }, [sendRequest], diff --git a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx index dfdb5624cca..c633c6ae006 100644 --- a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx @@ -1,57 +1,44 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; +import { CompletionItemKind, Position } from "vscode-languageserver-types"; +import type { CompletionItem } from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; -import type { CompletionItem } from "../checker/worker/protocol"; import { MonacoContext } from "./context"; /** - * Map TypeScript `ScriptElementKind` strings to Monaco `CompletionItemKind`. - * @see https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts + * Map LSP `CompletionItemKind` to Monaco `CompletionItemKind`. */ -function toCompletionItemKind( - kind: string, +function toMonacoCompletionKind( + kind: CompletionItemKind | undefined, monaco: typeof Monaco, ): Monaco.languages.CompletionItemKind { switch (kind) { - case "method": - case "construct": + case CompletionItemKind.Method: return monaco.languages.CompletionItemKind.Method; - case "function": - case "local function": + case CompletionItemKind.Function: return monaco.languages.CompletionItemKind.Function; - case "constructor": + case CompletionItemKind.Constructor: return monaco.languages.CompletionItemKind.Constructor; - case "property": - case "getter": - case "setter": + case CompletionItemKind.Property: return monaco.languages.CompletionItemKind.Property; - case "parameter": - case "var": - case "local var": - case "let": + case CompletionItemKind.Variable: return monaco.languages.CompletionItemKind.Variable; - case "const": - return monaco.languages.CompletionItemKind.Variable; - case "class": + case CompletionItemKind.Class: return monaco.languages.CompletionItemKind.Class; - case "interface": + case CompletionItemKind.Interface: return monaco.languages.CompletionItemKind.Interface; - case "type": - case "type parameter": - case "primitive type": - case "alias": + case CompletionItemKind.TypeParameter: return monaco.languages.CompletionItemKind.TypeParameter; - case "enum": + case CompletionItemKind.Enum: return monaco.languages.CompletionItemKind.Enum; - case "enum member": + case CompletionItemKind.EnumMember: return monaco.languages.CompletionItemKind.EnumMember; - case "module": - case "external module name": + case CompletionItemKind.Module: return monaco.languages.CompletionItemKind.Module; - case "keyword": + case CompletionItemKind.Keyword: return monaco.languages.CompletionItemKind.Keyword; - case "string": + case CompletionItemKind.Value: return monaco.languages.CompletionItemKind.Value; default: return monaco.languages.CompletionItemKind.Text; @@ -65,7 +52,7 @@ function toMonacoCompletion( ): Monaco.languages.CompletionItem { return { label: entry.label, - kind: toCompletionItemKind(entry.kind, monaco), + kind: toMonacoCompletionKind(entry.kind, monaco), insertText: entry.insertText ?? entry.label, sortText: entry.sortText, range, @@ -82,15 +69,19 @@ const CompletionSyncInner = () => { { triggerCharacters: ["."], - async provideCompletionItems(model, position) { + async provideCompletionItems(model, monacoPosition) { const uri = model.uri.toString(); - const offset = model.getOffsetAt(position); - const result = await requestCompletion(uri, offset); + // Convert Monaco 1-based position to LSP 0-based Position + const position = Position.create( + monacoPosition.lineNumber - 1, + monacoPosition.column - 1, + ); + const result = await requestCompletion(uri, position); - const word = model.getWordUntilPosition(position); + const word = model.getWordUntilPosition(monacoPosition); const range: Monaco.IRange = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, + startLineNumber: monacoPosition.lineNumber, + endLineNumber: monacoPosition.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; diff --git a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx index adcbffcbcbe..08dd0f69769 100644 --- a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -1,53 +1,49 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect, useRef } from "react"; +import { DiagnosticSeverity } from "vscode-languageserver-types"; +import type { Diagnostic } from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; -import type { Diagnostic } from "../checker/worker/protocol"; import { MonacoContext } from "./context"; const OWNER = "checker"; /** - * Convert LSP-like severity to Monaco MarkerSeverity. - * LSP: 1=Error, 2=Warning, 3=Information, 4=Hint + * Convert LSP `DiagnosticSeverity` to Monaco `MarkerSeverity`. */ function toMarkerSeverity( - severity: number, + severity: DiagnosticSeverity | undefined, monaco: typeof Monaco, ): Monaco.MarkerSeverity { switch (severity) { - case 1: + case DiagnosticSeverity.Error: return monaco.MarkerSeverity.Error; - case 2: + case DiagnosticSeverity.Warning: return monaco.MarkerSeverity.Warning; - case 3: + case DiagnosticSeverity.Information: return monaco.MarkerSeverity.Info; - case 4: + case DiagnosticSeverity.Hint: return monaco.MarkerSeverity.Hint; default: return monaco.MarkerSeverity.Error; } } -/** Convert Diagnostic[] to IMarkerData[] using the model for offset→position. */ +/** Convert LSP Diagnostic[] to Monaco IMarkerData[]. */ function diagnosticsToMarkers( - model: Monaco.editor.ITextModel, diagnostics: Diagnostic[], monaco: typeof Monaco, ): Monaco.editor.IMarkerData[] { - return diagnostics.map((diag) => { - const start = model.getPositionAt(diag.start ?? 0); - const end = model.getPositionAt((diag.start ?? 0) + (diag.length ?? 0)); - return { - severity: toMarkerSeverity(diag.severity, monaco), - message: diag.message, - startLineNumber: start.lineNumber, - startColumn: start.column, - endLineNumber: end.lineNumber, - endColumn: end.column, - code: String(diag.code), - }; - }); + return diagnostics.map((diag) => ({ + severity: toMarkerSeverity(diag.severity, monaco), + message: diag.message, + // Monaco uses 1-based line/column, LSP uses 0-based + startLineNumber: diag.range.start.line + 1, + startColumn: diag.range.start.character + 1, + endLineNumber: diag.range.end.line + 1, + endColumn: diag.range.end.character + 1, + code: diag.code != null ? String(diag.code) : undefined, + })); } const DiagnosticsSyncInner = () => { @@ -62,7 +58,7 @@ const DiagnosticsSyncInner = () => { const monacoUri = monaco.Uri.parse(uri); const model = monaco.editor.getModel(monacoUri); if (model) { - const markers = diagnosticsToMarkers(model, diagnostics, monaco); + const markers = diagnosticsToMarkers(diagnostics, monaco); monaco.editor.setModelMarkers(model, OWNER, markers); } currentUris.add(uri); @@ -86,7 +82,7 @@ const DiagnosticsSyncInner = () => { const modelUri = model.uri.toString(); const diags = diagnosticsByUri.get(modelUri); if (diags) { - const markers = diagnosticsToMarkers(model, diags, monaco); + const markers = diagnosticsToMarkers(diags, monaco); monaco.editor.setModelMarkers(model, OWNER, markers); } }); diff --git a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx index e3cb0aeab27..4dea49a0b5d 100644 --- a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -1,39 +1,82 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; +import { MarkupKind, Position } from "vscode-languageserver-types"; +import type { Hover, MarkupContent } from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; import { MonacoContext } from "./context"; +/** Extract display string from LSP Hover contents. */ +function hoverContentsToMarkdown(hover: Hover): Monaco.IMarkdownString[] { + const { contents } = hover; + + // MarkupContent + if (typeof contents === "object" && "kind" in contents) { + const mc = contents as MarkupContent; + if (mc.kind === MarkupKind.Markdown) { + return [{ value: mc.value }]; + } + // PlainText — wrap in code block for Monaco + return [{ value: mc.value }]; + } + + // string + if (typeof contents === "string") { + return [{ value: contents }]; + } + + // MarkedString[] + if (Array.isArray(contents)) { + return contents.map((item) => { + if (typeof item === "string") { + return { value: item }; + } + return { value: `\`\`\`${item.language}\n${item.value}\n\`\`\`` }; + }); + } + + // MarkedString { language, value } + if ("language" in contents) { + return [ + { + value: `\`\`\`${contents.language}\n${contents.value}\n\`\`\``, + }, + ]; + } + + return []; +} + const HoverSyncInner = () => { const { monaco } = use(use(MonacoContext)); const { requestHover } = use(LanguageClientContext); useEffect(() => { const disposable = monaco.languages.registerHoverProvider("typescript", { - async provideHover(model, position) { + async provideHover(model, monacoPosition) { const uri = model.uri.toString(); - const offset = model.getOffsetAt(position); - const info = await requestHover(uri, offset); + // Convert Monaco 1-based position to LSP 0-based Position + const position = Position.create( + monacoPosition.lineNumber - 1, + monacoPosition.column - 1, + ); + const info = await requestHover(uri, position); if (!info) { return null; } - const startPos = model.getPositionAt(info.start); - const endPos = model.getPositionAt(info.start + info.length); - const range: Monaco.IRange = { - startLineNumber: startPos.lineNumber, - startColumn: startPos.column, - endLineNumber: endPos.lineNumber, - endColumn: endPos.column, - }; - - const contents: Monaco.IMarkdownString[] = [ - { value: `\`\`\`typescript\n${info.displayParts}\n\`\`\`` }, - ]; - if (info.documentation) { - contents.push({ value: info.documentation }); - } + const contents = hoverContentsToMarkdown(info); + + // Convert LSP 0-based range to Monaco 1-based range + const range: Monaco.IRange | undefined = info.range + ? { + startLineNumber: info.range.start.line + 1, + startColumn: info.range.start.character + 1, + endLineNumber: info.range.end.line + 1, + endColumn: info.range.end.character + 1, + } + : undefined; return { range, contents }; }, diff --git a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx index 6fc77d04abf..8fe8a1d28c3 100644 --- a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -1,22 +1,42 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; +import { MarkupKind, Position } from "vscode-languageserver-types"; +import type { MarkupContent, SignatureHelp } from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; -import type { SignatureHelp } from "../checker/worker/protocol"; import { MonacoContext } from "./context"; +/** Extract documentation string from LSP MarkupContent or plain string. */ +function extractDocumentation( + doc: string | MarkupContent | undefined, +): string | undefined { + if (!doc) { + return undefined; + } + if (typeof doc === "string") { + return doc || undefined; + } + if (doc.kind === MarkupKind.Markdown || doc.kind === MarkupKind.PlainText) { + return doc.value || undefined; + } + return undefined; +} + function toMonacoSignatureHelp( - result: NonNullable, + result: SignatureHelp, ): Monaco.languages.SignatureHelp { return { - activeSignature: result.activeSignature, - activeParameter: result.activeParameter, + activeSignature: result.activeSignature ?? 0, + activeParameter: result.activeParameter ?? 0, signatures: result.signatures.map((sig) => ({ label: sig.label, - documentation: sig.documentation || undefined, - parameters: sig.parameters.map((param) => ({ - label: param.label, - documentation: param.documentation || undefined, + documentation: extractDocumentation(sig.documentation), + parameters: (sig.parameters ?? []).map((param) => ({ + label: + typeof param.label === "string" + ? param.label + : [param.label[0], param.label[1]], + documentation: extractDocumentation(param.documentation), })), })), }; @@ -33,10 +53,14 @@ const SignatureHelpSyncInner = () => { signatureHelpTriggerCharacters: ["(", ","], signatureHelpRetriggerCharacters: [","], - async provideSignatureHelp(model, position) { + async provideSignatureHelp(model, monacoPosition) { const uri = model.uri.toString(); - const offset = model.getOffsetAt(position); - const result = await requestSignatureHelp(uri, offset); + // Convert Monaco 1-based position to LSP 0-based Position + const position = Position.create( + monacoPosition.lineNumber - 1, + monacoPosition.column - 1, + ); + const result = await requestSignatureHelp(uri, position); if (!result) { return null; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index a93580da56f..f389291be52 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -2,8 +2,9 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; +import type { Diagnostic } from "vscode-languageserver-types"; + import { LanguageClientContext } from "../../../checker/context"; -import type { Diagnostic } from "../../../checker/worker/protocol"; import type { SubView } from "../../../components/sub-view/types"; import { parseDocumentUri } from "../../../monaco/editor-paths"; import { EditorContext } from "../../../state/editor-context"; @@ -251,8 +252,8 @@ const DiagnosticsContent: React.FC = () => { {itemGroup.diagnostics.map((diagnostic, index) => (
  • ))} diff --git a/yarn.lock b/yarn.lock index 408032f7a54..1115c3550dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7829,6 +7829,7 @@ __metadata: vite: "npm:8.0.0-beta.14" vite-plugin-dts: "npm:4.5.4" vitest: "npm:4.0.18" + vscode-languageserver-types: "npm:3.17.5" web-worker: "npm:1.4.1" peerDependencies: "@hashintel/ds-components": "workspace:^" @@ -45394,6 +45395,13 @@ __metadata: languageName: node linkType: hard +"vscode-languageserver-types@npm:3.17.5": + version: 3.17.5 + resolution: "vscode-languageserver-types@npm:3.17.5" + checksum: 10c0/1e1260de79a2cc8de3e46f2e0182cdc94a7eddab487db5a3bd4ee716f67728e685852707d72c059721ce500447be9a46764a04f0611e94e4321ffa088eef36f8 + languageName: node + linkType: hard + "vscode-uri@npm:^3.0.8": version: 3.1.0 resolution: "vscode-uri@npm:3.1.0" From f98b7d2ead1797479f3ddaf123a86565895816df Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 26 Feb 2026 13:52:11 +0100 Subject: [PATCH 12/22] H-5839: Add changeset for LSP language service layer Co-Authored-By: Claude Opus 4.6 --- .changeset/bright-ideas-flow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bright-ideas-flow.md diff --git a/.changeset/bright-ideas-flow.md b/.changeset/bright-ideas-flow.md new file mode 100644 index 00000000000..b3d25c3acdc --- /dev/null +++ b/.changeset/bright-ideas-flow.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": minor +--- + +Add LSP-based language service layer for Monaco code editors with diagnostics, completions, hover, and signature help From b1ef02aa1d8e59f8577fe5e0f6117019b3a87db8 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 26 Feb 2026 15:42:51 +0100 Subject: [PATCH 13/22] H-5839: Modernize LanguageClientProvider to React 19 patterns Use React 19 Context shorthand, derive totalDiagnosticsCount directly instead of useMemo, and extract pure helper functions for building the diagnostics map. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/checker/provider.tsx | 46 +++++++++++-------- .../petrinaut/src/monaco/diagnostics-sync.tsx | 2 +- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/checker/provider.tsx b/libs/@hashintel/petrinaut/src/checker/provider.tsx index a2c95cb3137..39c982f6554 100644 --- a/libs/@hashintel/petrinaut/src/checker/provider.tsx +++ b/libs/@hashintel/petrinaut/src/checker/provider.tsx @@ -1,4 +1,4 @@ -import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { use, useCallback, useEffect, useRef, useState } from "react"; import { SDCPNContext } from "../state/sdcpn-context"; import { LanguageClientContext } from "./context"; @@ -9,6 +9,28 @@ import type { } from "./worker/protocol"; import { useLanguageClient } from "./worker/use-language-client"; +/** Build an immutable diagnostics map, excluding empty entries. */ +function buildDiagnosticsMap( + allParams: PublishDiagnosticsParams[], +): Map { + return new Map( + allParams + .filter((param) => param.diagnostics.length > 0) + .map((param) => [param.uri, param.diagnostics]), + ); +} + +/** Count total diagnostics across all URIs. */ +function countDiagnostics( + diagnosticsByUri: Map, +): number { + let count = 0; + for (const diagnostics of diagnosticsByUri.values()) { + count += diagnostics.length; + } + return count; +} + export const LanguageClientProvider: React.FC<{ children: React.ReactNode; }> = ({ children }) => { @@ -22,15 +44,7 @@ export const LanguageClientProvider: React.FC<{ // Subscribe to diagnostics pushed from the server const handleDiagnostics = useCallback( (allParams: PublishDiagnosticsParams[]) => { - setDiagnosticsByUri(() => { - const next = new Map(); - for (const param of allParams) { - if (param.diagnostics.length > 0) { - next.set(param.uri, param.diagnostics); - } - } - return next; - }); + setDiagnosticsByUri(buildDiagnosticsMap(allParams)); }, [], ); @@ -50,16 +64,10 @@ export const LanguageClientProvider: React.FC<{ } }, [petriNetDefinition, client]); - const totalDiagnosticsCount = useMemo(() => { - let count = 0; - for (const diagnostics of diagnosticsByUri.values()) { - count += diagnostics.length; - } - return count; - }, [diagnosticsByUri]); + const totalDiagnosticsCount = countDiagnostics(diagnosticsByUri); return ( - {children} - + ); }; diff --git a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx index 08dd0f69769..c08a65433c8 100644 --- a/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -1,7 +1,7 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect, useRef } from "react"; -import { DiagnosticSeverity } from "vscode-languageserver-types"; import type { Diagnostic } from "vscode-languageserver-types"; +import { DiagnosticSeverity } from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; import { MonacoContext } from "./context"; From c36007d59006380983cc0348b6d3d4db8f23971e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 26 Feb 2026 15:59:00 +0100 Subject: [PATCH 14/22] H-5839: Fix ESLint errors across checker and Monaco sync components Fix import sorting, remove unused imports, simplify unnecessary type assertions and conditions, avoid array index keys, and fix no-return-assign in provider singleton. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/checker/worker/checker.worker.ts | 8 ++++---- .../petrinaut/src/monaco/completion-sync.tsx | 2 +- libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx | 4 ++-- libs/@hashintel/petrinaut/src/monaco/provider.tsx | 3 ++- .../petrinaut/src/monaco/signature-help-sync.tsx | 12 ++++++------ .../src/views/Editor/subviews/diagnostics.tsx | 7 ++----- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts index 4f065c86b40..5f8a3d2ee96 100644 --- a/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts +++ b/libs/@hashintel/petrinaut/src/checker/worker/checker.worker.ts @@ -11,15 +11,15 @@ */ import ts from "typescript"; import { - CompletionItemKind, - DiagnosticSeverity, - MarkupKind, - Range, type CompletionItem, + CompletionItemKind, type CompletionList, type Diagnostic, + DiagnosticSeverity, type DocumentUri, type Hover, + MarkupKind, + Range, type SignatureHelp, type SignatureInformation, } from "vscode-languageserver-types"; diff --git a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx index c633c6ae006..546ca7f09a9 100644 --- a/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/completion-sync.tsx @@ -1,7 +1,7 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; -import { CompletionItemKind, Position } from "vscode-languageserver-types"; import type { CompletionItem } from "vscode-languageserver-types"; +import { CompletionItemKind, Position } from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; import { MonacoContext } from "./context"; diff --git a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx index 4dea49a0b5d..0cb92f7e1a5 100644 --- a/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -1,7 +1,7 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; +import type { Hover } from "vscode-languageserver-types"; import { MarkupKind, Position } from "vscode-languageserver-types"; -import type { Hover, MarkupContent } from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; import { MonacoContext } from "./context"; @@ -12,7 +12,7 @@ function hoverContentsToMarkdown(hover: Hover): Monaco.IMarkdownString[] { // MarkupContent if (typeof contents === "object" && "kind" in contents) { - const mc = contents as MarkupContent; + const mc = contents; if (mc.kind === MarkupKind.Markdown) { return [{ value: mc.value }]; } diff --git a/libs/@hashintel/petrinaut/src/monaco/provider.tsx b/libs/@hashintel/petrinaut/src/monaco/provider.tsx index 249629de23c..e36ac9f819b 100644 --- a/libs/@hashintel/petrinaut/src/monaco/provider.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -66,7 +66,8 @@ async function initMonaco(): Promise { /** Module-level lazy singleton — initialized once, reused across renders. */ let monacoPromise: Promise | null = null; function getMonacoPromise(): Promise { - return (monacoPromise ??= initMonaco()); + monacoPromise ??= initMonaco(); + return monacoPromise; } export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ diff --git a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx index 8fe8a1d28c3..582b5cb03c2 100644 --- a/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -1,7 +1,10 @@ import type * as Monaco from "monaco-editor"; import { Suspense, use, useEffect } from "react"; -import { MarkupKind, Position } from "vscode-languageserver-types"; -import type { MarkupContent, SignatureHelp } from "vscode-languageserver-types"; +import { + type MarkupContent, + Position, + type SignatureHelp, +} from "vscode-languageserver-types"; import { LanguageClientContext } from "../checker/context"; import { MonacoContext } from "./context"; @@ -16,10 +19,7 @@ function extractDocumentation( if (typeof doc === "string") { return doc || undefined; } - if (doc.kind === MarkupKind.Markdown || doc.kind === MarkupKind.PlainText) { - return doc.value || undefined; - } - return undefined; + return doc.value || undefined; } function toMonacoSignatureHelp( diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index f389291be52..6086cf61ff3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -1,7 +1,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; - import type { Diagnostic } from "vscode-languageserver-types"; import { LanguageClientContext } from "../../../checker/context"; @@ -249,11 +248,9 @@ const DiagnosticsContent: React.FC = () => { {/* Diagnostics list */}
      - {itemGroup.diagnostics.map((diagnostic, index) => ( + {itemGroup.diagnostics.map((diagnostic) => (