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 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/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/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/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/lsp/context.ts b/libs/@hashintel/petrinaut/src/lsp/context.ts new file mode 100644 index 00000000000..e3c07ea5114 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/context.ts @@ -0,0 +1,44 @@ +import { createContext } from "react"; + +import type { + CompletionList, + Diagnostic, + DocumentUri, + Hover, + Position, + SignatureHelp, +} from "./worker/protocol"; + +export interface LanguageClientContextValue { + /** Per-URI diagnostics pushed from the language server. */ + diagnosticsByUri: Map; + /** Total number of diagnostics across all documents. */ + totalDiagnosticsCount: number; + /** 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, + position: Position, + ) => Promise; + /** Request hover info at a position within a document. */ + requestHover: (uri: DocumentUri, position: Position) => Promise; + /** Request signature help at a position within a document. */ + requestSignatureHelp: ( + uri: DocumentUri, + position: Position, + ) => Promise; +} + +const DEFAULT_CONTEXT_VALUE: LanguageClientContextValue = { + diagnosticsByUri: new Map(), + totalDiagnosticsCount: 0, + notifyDocumentChanged: () => {}, + requestCompletion: () => Promise.resolve({ isIncomplete: false, items: [] }), + requestHover: () => Promise.resolve(null), + requestSignatureHelp: () => Promise.resolve(null), +}; + +export const LanguageClientContext = createContext( + DEFAULT_CONTEXT_VALUE, +); diff --git a/libs/@hashintel/petrinaut/src/core/checker/checker.test.ts b/libs/@hashintel/petrinaut/src/lsp/lib/checker.test.ts similarity index 95% rename from libs/@hashintel/petrinaut/src/core/checker/checker.test.ts rename to libs/@hashintel/petrinaut/src/lsp/lib/checker.test.ts index 8140ea9f151..4fdb53700e5 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/checker.test.ts +++ b/libs/@hashintel/petrinaut/src/lsp/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/core/checker/checker.ts b/libs/@hashintel/petrinaut/src/lsp/lib/checker.ts similarity index 77% rename from libs/@hashintel/petrinaut/src/core/checker/checker.ts rename to libs/@hashintel/petrinaut/src/lsp/lib/checker.ts index 33d170423ae..3c5029c5fe3 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/checker.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/checker.ts @@ -1,14 +1,19 @@ import type ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; -import { createSDCPNLanguageService } from "./create-sdcpn-language-service"; +import type { SDCPN } from "../../core/types/sdcpn"; +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 */ @@ -25,12 +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): SDCPNCheckResult { - const languageService = createSDCPNLanguageService(sdcpn); +export function checkSDCPN( + sdcpn: SDCPN, + server: SDCPNLanguageServer, +): SDCPNCheckResult { const itemDiagnostics: SDCPNDiagnostic[] = []; // Check all differential equations @@ -38,10 +42,8 @@ export function checkSDCPN(sdcpn: SDCPN): SDCPNCheckResult { 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) { @@ -61,9 +63,9 @@ export function checkSDCPN(sdcpn: SDCPN): SDCPNCheckResult { transitionId: transition.id, }); const lambdaSemanticDiagnostics = - languageService.getSemanticDiagnostics(lambdaFilePath); + server.getSemanticDiagnostics(lambdaFilePath); const lambdaSyntacticDiagnostics = - languageService.getSyntacticDiagnostics(lambdaFilePath); + server.getSyntacticDiagnostics(lambdaFilePath); const lambdaDiagnostics = [ ...lambdaSyntacticDiagnostics, ...lambdaSemanticDiagnostics, @@ -90,9 +92,9 @@ export function checkSDCPN(sdcpn: SDCPN): SDCPNCheckResult { 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/core/checker/create-language-service-host.ts b/libs/@hashintel/petrinaut/src/lsp/lib/create-language-service-host.ts similarity index 51% rename from libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts rename to libs/@hashintel/petrinaut/src/lsp/lib/create-language-service-host.ts index 3e85556c015..8a5df10640e 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/create-language-service-host.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/create-language-service-host.ts @@ -37,14 +37,36 @@ 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, -): ts.LanguageServiceHost { +): LanguageServiceHostController { + const versions = new Map(); + const getFileContent = (fileName: string): string | undefined => { const entry = files.get(fileName); if (entry) { @@ -62,10 +84,37 @@ export function createLanguageServiceHost( return undefined; }; - return { + 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; + bumpVersion(fileName); + } + }; + + 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 +131,15 @@ export function createLanguageServiceHost( return getFileContent(path); }, }; + + 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/lsp/lib/create-sdcpn-language-service.test.ts b/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.test.ts new file mode 100644 index 00000000000..68d0a6e7ccf --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from "vitest"; + +import { SDCPNLanguageServer } 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 }; +} + +/** 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], + 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 server = createServer(patched); + + 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 = server.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + return (completions?.entries ?? []).map((entry) => entry.name); +} + +describe("SDCPNLanguageServer 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("updateDocumentContent", () => { + it("returns completions for updated code, not original code", () => { + // Start with number code + const server = createServer({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "const a = 42;\na.", + }, + ], + }); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // Update to string code + const { offset, code } = parseCursor(`const s = "hello";\ns.${CURSOR}`); + server.updateDocumentContent(filePath, code); + + const completions = server.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 server = createServer({ + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + lambdaCode: "", + }, + ], + }); + const filePath = getItemFilePath("transition-lambda-code", { + transitionId: "t1", + }); + + // First update: number + const first = parseCursor(`const a = 42;\na.${CURSOR}`); + server.updateDocumentContent(filePath, first.code); + const firstCompletions = server.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}`); + server.updateDocumentContent(filePath, second.code); + const secondCompletions = server.getCompletionsAtPosition( + filePath, + second.offset, + undefined, + ); + const secondNames = (secondCompletions?.entries ?? []).map( + (entry) => entry.name, + ); + expect(secondNames).toContain("charAt"); + expect(secondNames).not.toContain("toFixed"); + }); + }); + + 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: [ + { + 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/core/checker/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.ts similarity index 65% rename from libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts rename to libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.ts index 87d16b0274e..7ad9a30b46a 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.ts @@ -1,14 +1,13 @@ import ts from "typescript"; -import type { SDCPN } from "../types/sdcpn"; +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; - /** * Sanitizes a color ID to be a valid TypeScript identifier. * Removes all characters that are not valid suffixes for TypeScript identifiers @@ -243,42 +242,137 @@ 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 = createLanguageServiceHost(files); - const baseService = ts.createLanguageService(host); - - // Proxy service to adjust positions for injected prefixes - return { - ...baseService, - - 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, - ); - }, - }; +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); + } + } + } + } + + /** 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); + } + + /** 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; + } + + /** Get only the user-visible content of a virtual file (without the injected prefix). */ + getUserContent(fileName: string): string | undefined { + return this.controller.getFile(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/lsp/lib/document-uris.test.ts b/libs/@hashintel/petrinaut/src/lsp/lib/document-uris.test.ts new file mode 100644 index 00000000000..c73ba724652 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/lib/document-uris.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { + filePathToUri, + getDocumentUri, + parseDocumentUri, + uriToFilePath, +} from "./document-uris"; + +describe("getDocumentUri", () => { + it("builds a transition-lambda URI", () => { + expect(getDocumentUri("transition-lambda", "t1")).toBe( + "inmemory://sdcpn/transitions/t1/lambda.ts", + ); + }); + + it("builds a transition-kernel URI", () => { + expect(getDocumentUri("transition-kernel", "t1")).toBe( + "inmemory://sdcpn/transitions/t1/kernel.ts", + ); + }); + + it("builds a differential-equation URI", () => { + expect(getDocumentUri("differential-equation", "de1")).toBe( + "inmemory://sdcpn/differential-equations/de1.ts", + ); + }); +}); + +describe("parseDocumentUri", () => { + it("parses a transition-lambda URI", () => { + expect( + parseDocumentUri("inmemory://sdcpn/transitions/t1/lambda.ts"), + ).toEqual({ itemType: "transition-lambda", itemId: "t1" }); + }); + + it("parses a transition-kernel URI", () => { + expect( + parseDocumentUri("inmemory://sdcpn/transitions/t1/kernel.ts"), + ).toEqual({ itemType: "transition-kernel", itemId: "t1" }); + }); + + it("parses a differential-equation URI", () => { + expect( + parseDocumentUri("inmemory://sdcpn/differential-equations/de1.ts"), + ).toEqual({ itemType: "differential-equation", itemId: "de1" }); + }); + + it("returns null for an unknown URI", () => { + expect(parseDocumentUri("inmemory://sdcpn/unknown/foo.ts")).toBeNull(); + }); + + it("returns null for a completely unrelated string", () => { + expect(parseDocumentUri("https://example.com")).toBeNull(); + }); +}); + +describe("uriToFilePath", () => { + it("converts a transition-lambda URI to a file path", () => { + expect(uriToFilePath("inmemory://sdcpn/transitions/t1/lambda.ts")).toBe( + "/transitions/t1/lambda/code.ts", + ); + }); + + it("converts a transition-kernel URI to a file path", () => { + expect(uriToFilePath("inmemory://sdcpn/transitions/t1/kernel.ts")).toBe( + "/transitions/t1/kernel/code.ts", + ); + }); + + it("converts a differential-equation URI to a file path", () => { + expect( + uriToFilePath("inmemory://sdcpn/differential-equations/de1.ts"), + ).toBe("/differential_equations/de1/code.ts"); + }); + + it("returns null for an unknown URI", () => { + expect(uriToFilePath("inmemory://sdcpn/unknown/foo.ts")).toBeNull(); + }); +}); + +describe("filePathToUri", () => { + it("converts a transition-lambda file path to a URI", () => { + expect(filePathToUri("/transitions/t1/lambda/code.ts")).toBe( + "inmemory://sdcpn/transitions/t1/lambda.ts", + ); + }); + + it("converts a transition-kernel file path to a URI", () => { + expect(filePathToUri("/transitions/t1/kernel/code.ts")).toBe( + "inmemory://sdcpn/transitions/t1/kernel.ts", + ); + }); + + it("converts a differential-equation file path to a URI", () => { + expect(filePathToUri("/differential_equations/de1/code.ts")).toBe( + "inmemory://sdcpn/differential-equations/de1.ts", + ); + }); + + it("returns null for an unknown file path", () => { + expect(filePathToUri("/unknown/foo/code.ts")).toBeNull(); + }); +}); + +describe("roundtrip", () => { + const cases = [ + { itemType: "transition-lambda" as const, itemId: "t1" }, + { itemType: "transition-kernel" as const, itemId: "t2" }, + { itemType: "differential-equation" as const, itemId: "de1" }, + ]; + + it.for(cases)( + "URI → filePath → URI roundtrips for $itemType", + ({ itemType, itemId }) => { + const uri = getDocumentUri(itemType, itemId); + const filePath = uriToFilePath(uri); + expect(filePath).not.toBeNull(); + expect(filePathToUri(filePath!)).toBe(uri); + }, + ); + + it.for(cases)( + "URI → parse → rebuild roundtrips for $itemType", + ({ itemType, itemId }) => { + const uri = getDocumentUri(itemType, itemId); + const parsed = parseDocumentUri(uri); + expect(parsed).not.toBeNull(); + expect(getDocumentUri(parsed!.itemType, parsed!.itemId)).toBe(uri); + }, + ); +}); diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/document-uris.ts b/libs/@hashintel/petrinaut/src/lsp/lib/document-uris.ts new file mode 100644 index 00000000000..0d2c9333989 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/lib/document-uris.ts @@ -0,0 +1,107 @@ +/** + * Single source of truth for mapping between: + * - Document URIs (`inmemory://sdcpn/...`) used by Monaco and the LSP protocol + * - Internal file paths (`/transitions/.../code.ts`) used by the TS LanguageService + * - Item types + IDs used by the SDCPN domain model + */ +import type { ItemType } from "./checker"; +import { getItemFilePath } from "./file-paths"; + +// --------------------------------------------------------------------------- +// URI construction +// --------------------------------------------------------------------------- + +/** Build a 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`; + case "transition-kernel": + return `inmemory://sdcpn/transitions/${itemId}/kernel.ts`; + case "differential-equation": + return `inmemory://sdcpn/differential-equations/${itemId}.ts`; + } +} + +// --------------------------------------------------------------------------- +// URI parsing +// --------------------------------------------------------------------------- + +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$/; + +/** Extract `(itemType, itemId)` from a document URI string. */ +export function parseDocumentUri( + uri: string, +): { itemType: ItemType; itemId: string } | null { + let match = TRANSITION_LAMBDA_URI_RE.exec(uri); + if (match) { + return { itemType: "transition-lambda", itemId: match[1]! }; + } + + match = TRANSITION_KERNEL_URI_RE.exec(uri); + if (match) { + return { itemType: "transition-kernel", itemId: match[1]! }; + } + + match = DE_URI_RE.exec(uri); + if (match) { + return { itemType: "differential-equation", itemId: match[1]! }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// URI ↔ internal file path conversion +// --------------------------------------------------------------------------- + +/** Convert a document URI to the internal virtual file path used by the TS LanguageService. */ +export function uriToFilePath(uri: string): string | null { + const parsed = parseDocumentUri(uri); + if (!parsed) { + return null; + } + + switch (parsed.itemType) { + case "transition-lambda": + return getItemFilePath("transition-lambda-code", { + transitionId: parsed.itemId, + }); + case "transition-kernel": + return getItemFilePath("transition-kernel-code", { + transitionId: parsed.itemId, + }); + case "differential-equation": + return getItemFilePath("differential-equation-code", { + id: parsed.itemId, + }); + } +} + +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 a document URI. */ +export function filePathToUri(filePath: string): string | null { + let match = TRANSITION_LAMBDA_PATH_RE.exec(filePath); + if (match) { + return getDocumentUri("transition-lambda", match[1]!); + } + + match = TRANSITION_KERNEL_PATH_RE.exec(filePath); + if (match) { + return getDocumentUri("transition-kernel", match[1]!); + } + + match = DE_PATH_RE.exec(filePath); + if (match) { + return getDocumentUri("differential-equation", match[1]!); + } + + return null; +} diff --git a/libs/@hashintel/petrinaut/src/core/checker/file-paths.ts b/libs/@hashintel/petrinaut/src/lsp/lib/file-paths.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/core/checker/file-paths.ts rename to libs/@hashintel/petrinaut/src/lsp/lib/file-paths.ts diff --git a/libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts b/libs/@hashintel/petrinaut/src/lsp/lib/helper/create-sdcpn.ts similarity index 98% rename from libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts rename to libs/@hashintel/petrinaut/src/lsp/lib/helper/create-sdcpn.ts index eec76175ef0..644c469521d 100644 --- a/libs/@hashintel/petrinaut/src/core/checker/helper/create-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/lsp/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/lsp/lib/position-utils.test.ts b/libs/@hashintel/petrinaut/src/lsp/lib/position-utils.test.ts new file mode 100644 index 00000000000..894cfea8327 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/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/lsp/lib/position-utils.ts b/libs/@hashintel/petrinaut/src/lsp/lib/position-utils.ts new file mode 100644 index 00000000000..dc7a5fa31f2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/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 Math.min(i + position.character, text.length); +} diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/ts-to-lsp.test.ts b/libs/@hashintel/petrinaut/src/lsp/lib/ts-to-lsp.test.ts new file mode 100644 index 00000000000..6c95c75ecfb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/lib/ts-to-lsp.test.ts @@ -0,0 +1,162 @@ +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { + CompletionItemKind, + DiagnosticSeverity, +} from "vscode-languageserver-types"; + +import { + serializeDiagnostic, + toCompletionItemKind, + toLspSeverity, +} from "./ts-to-lsp"; + +describe("toLspSeverity", () => { + it.for([ + { category: 0, expected: DiagnosticSeverity.Warning, label: "Warning" }, + { category: 1, expected: DiagnosticSeverity.Error, label: "Error" }, + { category: 2, expected: DiagnosticSeverity.Hint, label: "Suggestion" }, + { + category: 3, + expected: DiagnosticSeverity.Information, + label: "Message", + }, + { category: 99, expected: DiagnosticSeverity.Error, label: "unknown" }, + ])( + "maps TS $label ($category) to LSP severity $expected", + ({ category, expected }) => { + expect(toLspSeverity(category)).toBe(expected); + }, + ); +}); + +describe("toCompletionItemKind", () => { + it.for([ + { tsKind: "method", expected: CompletionItemKind.Method }, + { tsKind: "construct", expected: CompletionItemKind.Method }, + { tsKind: "function", expected: CompletionItemKind.Function }, + { tsKind: "local function", expected: CompletionItemKind.Function }, + { tsKind: "constructor", expected: CompletionItemKind.Constructor }, + { tsKind: "property", expected: CompletionItemKind.Property }, + { tsKind: "getter", expected: CompletionItemKind.Property }, + { tsKind: "setter", expected: CompletionItemKind.Property }, + { tsKind: "parameter", expected: CompletionItemKind.Variable }, + { tsKind: "var", expected: CompletionItemKind.Variable }, + { tsKind: "local var", expected: CompletionItemKind.Variable }, + { tsKind: "let", expected: CompletionItemKind.Variable }, + { tsKind: "const", expected: CompletionItemKind.Variable }, + { tsKind: "class", expected: CompletionItemKind.Class }, + { tsKind: "interface", expected: CompletionItemKind.Interface }, + { tsKind: "type", expected: CompletionItemKind.TypeParameter }, + { tsKind: "type parameter", expected: CompletionItemKind.TypeParameter }, + { tsKind: "primitive type", expected: CompletionItemKind.TypeParameter }, + { tsKind: "alias", expected: CompletionItemKind.TypeParameter }, + { tsKind: "enum", expected: CompletionItemKind.Enum }, + { tsKind: "enum member", expected: CompletionItemKind.EnumMember }, + { tsKind: "module", expected: CompletionItemKind.Module }, + { tsKind: "external module name", expected: CompletionItemKind.Module }, + { tsKind: "keyword", expected: CompletionItemKind.Keyword }, + { tsKind: "string", expected: CompletionItemKind.Value }, + { tsKind: "unknown-kind", expected: CompletionItemKind.Text }, + ])( + 'maps "$tsKind" to CompletionItemKind $expected', + ({ tsKind, expected }) => { + expect(toCompletionItemKind(tsKind)).toBe(expected); + }, + ); +}); + +describe("serializeDiagnostic", () => { + const fileContent = "const x = 1;\nconst y = 2;\n"; + + function makeTsDiagnostic( + overrides: Partial = {}, + ): ts.Diagnostic { + return { + file: undefined, + start: 0, + length: 5, + messageText: "Test error", + category: ts.DiagnosticCategory.Error, + code: 2304, + ...overrides, + }; + } + + it("converts a simple TS diagnostic to LSP format", () => { + const diag = makeTsDiagnostic({ start: 0, length: 5 }); + const result = serializeDiagnostic(diag, fileContent); + + expect(result.severity).toBe(DiagnosticSeverity.Error); + expect(result.message).toBe("Test error"); + expect(result.code).toBe(2304); + expect(result.source).toBe("ts"); + expect(result.range.start).toEqual({ line: 0, character: 0 }); + expect(result.range.end).toEqual({ line: 0, character: 5 }); + }); + + it("handles a diagnostic on the second line", () => { + // "const y" starts at offset 13 (after "const x = 1;\n") + const diag = makeTsDiagnostic({ start: 13, length: 7 }); + const result = serializeDiagnostic(diag, fileContent); + + expect(result.range.start).toEqual({ line: 1, character: 0 }); + expect(result.range.end).toEqual({ line: 1, character: 7 }); + }); + + it("handles a diagnostic spanning across lines", () => { + // From offset 6 ("= 1;\nconst") to offset 19 + const diag = makeTsDiagnostic({ start: 6, length: 13 }); + const result = serializeDiagnostic(diag, fileContent); + + expect(result.range.start).toEqual({ line: 0, character: 6 }); + expect(result.range.end).toEqual({ line: 1, character: 6 }); + }); + + it("defaults start to 0 when undefined", () => { + const diag = makeTsDiagnostic({ start: undefined, length: 3 }); + const result = serializeDiagnostic(diag, fileContent); + + expect(result.range.start).toEqual({ line: 0, character: 0 }); + expect(result.range.end).toEqual({ line: 0, character: 3 }); + }); + + it("defaults length to 0 when undefined", () => { + const diag = makeTsDiagnostic({ start: 6, length: undefined }); + const result = serializeDiagnostic(diag, fileContent); + + // start and end should be the same position + expect(result.range.start).toEqual(result.range.end); + }); + + it("flattens chained diagnostic messages", () => { + const diag = makeTsDiagnostic({ + messageText: { + messageText: "Outer error", + category: ts.DiagnosticCategory.Error, + code: 1000, + next: [ + { + messageText: "Inner cause", + category: ts.DiagnosticCategory.Error, + code: 1001, + next: undefined, + }, + ], + }, + }); + const result = serializeDiagnostic(diag, fileContent); + + expect(result.message).toContain("Outer error"); + expect(result.message).toContain("Inner cause"); + }); + + it("maps TS warning category to LSP warning severity", () => { + const diag = makeTsDiagnostic({ + category: ts.DiagnosticCategory.Warning, + }); + const result = serializeDiagnostic(diag, fileContent); + + expect(result.severity).toBe(DiagnosticSeverity.Warning); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/ts-to-lsp.ts b/libs/@hashintel/petrinaut/src/lsp/lib/ts-to-lsp.ts new file mode 100644 index 00000000000..f10c93baa53 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/lib/ts-to-lsp.ts @@ -0,0 +1,102 @@ +/** + * Pure conversion functions from TypeScript LanguageService types to LSP types. + * + * These define the stable contract between the TS LanguageService and all LSP + * consumers (Monaco sync components, diagnostics panel, etc.). + */ +import ts from "typescript"; +import { + CompletionItemKind, + type Diagnostic, + DiagnosticSeverity, + Range, +} from "vscode-languageserver-types"; + +import { offsetToPosition } from "./position-utils"; + +/** + * Map `ts.DiagnosticCategory` to `DiagnosticSeverity`. + * TS: 0=Warning, 1=Error, 2=Suggestion, 3=Message + * LSP: 1=Error, 2=Warning, 3=Information, 4=Hint + */ +export function toLspSeverity(category: number): DiagnosticSeverity { + switch (category) { + case 0: + return DiagnosticSeverity.Warning; + case 1: + return DiagnosticSeverity.Error; + case 2: + return DiagnosticSeverity.Hint; + case 3: + return DiagnosticSeverity.Information; + default: + return DiagnosticSeverity.Error; + } +} + +/** + * Map TS `ScriptElementKind` strings to LSP `CompletionItemKind`. + */ +export 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; + } +} + +/** Convert a TS diagnostic to an LSP Diagnostic with position-based ranges. */ +export function serializeDiagnostic( + diag: ts.Diagnostic, + fileContent: string, +): Diagnostic { + const start = diag.start ?? 0; + const end = start + (diag.length ?? 0); + return { + severity: toLspSeverity(diag.category), + range: Range.create( + offsetToPosition(fileContent, start), + offsetToPosition(fileContent, end), + ), + message: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), + code: diag.code, + source: "ts", + }; +} diff --git a/libs/@hashintel/petrinaut/src/lsp/provider.tsx b/libs/@hashintel/petrinaut/src/lsp/provider.tsx new file mode 100644 index 00000000000..39c982f6554 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/provider.tsx @@ -0,0 +1,83 @@ +import { use, useCallback, useEffect, useRef, useState } from "react"; + +import { SDCPNContext } from "../state/sdcpn-context"; +import { LanguageClientContext } from "./context"; +import type { + Diagnostic, + DocumentUri, + PublishDiagnosticsParams, +} 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 }) => { + const { petriNetDefinition } = use(SDCPNContext); + const client = useLanguageClient(); + + const [diagnosticsByUri, setDiagnosticsByUri] = useState< + Map + >(new Map()); + + // Subscribe to diagnostics pushed from the server + const handleDiagnostics = useCallback( + (allParams: PublishDiagnosticsParams[]) => { + setDiagnosticsByUri(buildDiagnosticsMap(allParams)); + }, + [], + ); + + 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 = countDiagnostics(diagnosticsByUri); + + return ( + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/lsp/worker/language-server.worker.ts b/libs/@hashintel/petrinaut/src/lsp/worker/language-server.worker.ts new file mode 100644 index 00000000000..abb584c388f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/worker/language-server.worker.ts @@ -0,0 +1,286 @@ +/* eslint-disable no-restricted-globals */ +/** + * Language Server WebWorker — runs TypeScript validation off the main thread. + * + * 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 created once and reused across SDCPN changes. + */ +import ts from "typescript"; +import { + type CompletionItem, + type CompletionList, + type Hover, + MarkupKind, + Range, + 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 { filePathToUri, uriToFilePath } from "../lib/document-uris"; +import { offsetToPosition, positionToOffset } from "../lib/position-utils"; +import { serializeDiagnostic, toCompletionItemKind } from "../lib/ts-to-lsp"; +import type { + ClientMessage, + PublishDiagnosticsParams, + ServerMessage, +} from "./protocol"; + +// --------------------------------------------------------------------------- +// 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); + // Use user content (without prefix) because diagnostic offsets have + // already been adjusted to be relative to user content by adjustDiagnostics. + const userContent = server!.getUserContent(item.filePath) ?? ""; + return { + uri: uri ?? item.filePath, + diagnostics: item.diagnostics.map((diag) => + serializeDiagnostic(diag, userContent), + ), + }; + }, + ); + + self.postMessage({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params, + } satisfies ServerMessage); +} + +// --------------------------------------------------------------------------- +// 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 (data.method) { + // --- Notifications (no response) --- + + case "initialize": { + const { sdcpn } = data.params; + lastSDCPN = sdcpn; + server = new SDCPNLanguageServer(); + server.syncFiles(sdcpn); + publishAllDiagnostics(sdcpn); + break; + } + + case "sdcpn/didChange": { + const { sdcpn } = data.params; + lastSDCPN = sdcpn; + server ??= new SDCPNLanguageServer(); + server.syncFiles(sdcpn); + publishAllDiagnostics(sdcpn); + break; + } + + 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; + } + + // --- Requests (send response) --- + + case "textDocument/completion": { + const { id } = data; + if (!server) { + respond(id, { + isIncomplete: false, + items: [], + } satisfies CompletionList); + break; + } + + const filePath = uriToFilePath(data.params.textDocument.uri); + if (!filePath) { + respond(id, { + isIncomplete: false, + items: [], + } satisfies CompletionList); + break; + } + + // Use user content (without prefix) for position conversion since + // Monaco positions are relative to the visible user code only. + // SDCPNLanguageServer methods handle the prefix offset internally. + const userContent = server.getUserContent(filePath) ?? ""; + const offset = positionToOffset(userContent, data.params.position); + + const completions = server.getCompletionsAtPosition( + filePath, + offset, + undefined, + ); + + const items: CompletionItem[] = (completions?.entries ?? []).map( + (entry) => ({ + label: entry.name, + kind: toCompletionItemKind(entry.kind), + sortText: entry.sortText, + insertText: entry.insertText, + }), + ); + + respond(id, { isIncomplete: false, items } satisfies CompletionList); + break; + } + + case "textDocument/hover": { + const { id } = data; + if (!server) { + respond(id, null); + break; + } + + const filePath = uriToFilePath(data.params.textDocument.uri); + if (!filePath) { + respond(id, null); + break; + } + + // Use user content (without prefix) for position conversion since + // Monaco positions are relative to the visible user code only. + const userContent = server.getUserContent(filePath) ?? ""; + const offset = positionToOffset(userContent, data.params.position); + + const info = server.getQuickInfoAtPosition(filePath, offset); + + // textSpan offsets from getQuickInfoAtPosition are already + // adjusted to be relative to user content (prefix subtracted). + const result: Hover | null = info + ? { + 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(userContent, info.textSpan.start), + offsetToPosition( + userContent, + info.textSpan.start + info.textSpan.length, + ), + ), + } + : null; + + respond(id, result); + break; + } + + case "textDocument/signatureHelp": { + const { id } = data; + if (!server) { + respond(id, null); + break; + } + + const filePath = uriToFilePath(data.params.textDocument.uri); + if (!filePath) { + respond(id, null); + break; + } + + // Use user content (without prefix) for position conversion since + // Monaco positions are relative to the visible user code only. + const userContent = server.getUserContent(filePath) ?? ""; + const offset = positionToOffset(userContent, data.params.position); + + const help = server.getSignatureHelpItems(filePath, offset, undefined); + + const result: SignatureHelp | null = help + ? { + activeSignature: help.selectedItemIndex, + activeParameter: help.argumentIndex, + signatures: help.items.map( + (item): SignatureInformation => ({ + label: [ + ...item.prefixDisplayParts, + ...item.parameters.flatMap((param, idx) => [ + ...(idx > 0 ? item.separatorDisplayParts : []), + ...param.displayParts, + ]), + ...item.suffixDisplayParts, + ] + .map((part) => part.text) + .join(""), + documentation: { + kind: MarkupKind.PlainText, + value: ts.displayPartsToString(item.documentation), + }, + parameters: item.parameters.map((param) => ({ + label: ts.displayPartsToString(param.displayParts), + documentation: { + kind: MarkupKind.PlainText, + value: ts.displayPartsToString(param.documentation), + }, + })), + }), + ), + } + : null; + + respond(id, result); + break; + } + } + } catch (err) { + // 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/lsp/worker/protocol.ts b/libs/@hashintel/petrinaut/src/lsp/worker/protocol.ts new file mode 100644 index 00000000000..685dd39c64a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/worker/protocol.ts @@ -0,0 +1,117 @@ +/** + * LSP-inspired protocol types for the language server WebWorker. + * + * 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. + * + * Custom extensions: + * - `sdcpn/didChange` for structural model changes (not per-document). + */ +import type { + CompletionItem, + CompletionList, + Diagnostic, + DocumentUri, + Hover, + Position, + SignatureHelp, + TextDocumentIdentifier, +} from "vscode-languageserver-types"; + +import type { SDCPN } from "../../core/types/sdcpn"; + +// Re-export LSP types used by consumers +export type { + CompletionItem, + CompletionList, + Diagnostic, + DocumentUri, + Hover, + Position, + SignatureHelp, + TextDocumentIdentifier, +}; + +/** + * Parameters for `textDocument/publishDiagnostics` notification. + * Defined here rather than pulling in `vscode-languageserver-protocol`. + */ +export type PublishDiagnosticsParams = { + uri: DocumentUri; + diagnostics: Diagnostic[]; +}; + +/** Position in a text document (LSP standard: line/character based). */ +export type TextDocumentPositionParams = { + textDocument: TextDocumentIdentifier; + position: Position; +}; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 messages: main thread → worker +// --------------------------------------------------------------------------- + +/** Notifications (fire-and-forget, no response expected). */ +type ClientNotification = + | { + jsonrpc: "2.0"; + method: "initialize"; + params: { sdcpn: SDCPN }; + } + | { + jsonrpc: "2.0"; + method: "sdcpn/didChange"; + params: { sdcpn: SDCPN }; + } + | { + jsonrpc: "2.0"; + method: "textDocument/didChange"; + params: { + 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: "textDocument/hover"; + params: TextDocumentPositionParams; + } + | { + jsonrpc: "2.0"; + id: number; + method: "textDocument/signatureHelp"; + params: TextDocumentPositionParams; + }; + +/** 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/lsp/worker/use-language-client.ts b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts new file mode 100644 index 00000000000..b08b9aad72e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/worker/use-language-client.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef } from "react"; + +import type { SDCPN } from "../../core/types/sdcpn"; +import type { + ClientMessage, + CompletionList, + DocumentUri, + Hover, + Position, + 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, + position: Position, + ) => Promise; + /** Request hover info at a position within a document. */ + requestHover: (uri: DocumentUri, position: Position) => Promise; + /** Request signature help at a position within a document. */ + requestSignatureHelp: ( + uri: DocumentUri, + position: Position, + ) => Promise; + /** Register a callback for diagnostics pushed from the server. */ + onDiagnostics: ( + callback: (params: PublishDiagnosticsParams[]) => void, + ) => void; +}; + +/** + * Spawn the language server 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("./language-server.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) { + // 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, position: Position): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "textDocument/completion", + params: { textDocument: { uri }, position }, + }); + }, + [sendRequest], + ); + + const requestHover = useCallback( + (uri: DocumentUri, position: Position): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "textDocument/hover", + params: { textDocument: { uri }, position }, + }); + }, + [sendRequest], + ); + + const requestSignatureHelp = useCallback( + (uri: DocumentUri, position: Position): Promise => { + const id = nextId.current++; + return sendRequest({ + jsonrpc: "2.0", + id, + method: "textDocument/signatureHelp", + params: { textDocument: { uri }, position }, + }); + }, + [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/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 ( { + const { monaco } = use(use(MonacoContext)); + const { notifyDocumentChanged, requestCompletion } = use( + LanguageClientContext, + ); + + useEffect(() => { + const disposable = monaco.languages.registerCompletionItemProvider( + "typescript", + { + triggerCharacters: ["."], + + async provideCompletionItems(model, monacoPosition) { + const uri = model.uri.toString(); + // TODO(FE-497): Sync current content to the worker before requesting + // completions. When a trigger character (e.g. ".") is typed, the + // Monaco model already has the new text but the worker may still have + // stale content (the SDCPN state sync goes through a React render + // cycle). Since the web worker processes messages in order, this + // ensures the content update is applied before the completion request. + notifyDocumentChanged(uri, model.getValue()); + // 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(monacoPosition); + const range: Monaco.IRange = { + startLineNumber: monacoPosition.lineNumber, + endLineNumber: monacoPosition.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + return { + suggestions: result.items.map((item) => + toMonacoCompletion(item, range, monaco), + ), + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, notifyDocumentChanged, requestCompletion]); + + return null; +}; + +/** 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/context.ts b/libs/@hashintel/petrinaut/src/monaco/context.ts new file mode 100644 index 00000000000..3075ef10660 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/context.ts @@ -0,0 +1,12 @@ +import type { EditorProps } from "@monaco-editor/react"; +import type * as Monaco from "monaco-editor"; +import { createContext } from "react"; + +export type MonacoContextValue = { + monaco: typeof Monaco; + Editor: React.FC; +}; + +export const MonacoContext = createContext>( + null as never, +); 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..027e4c1744f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/diagnostics-sync.tsx @@ -0,0 +1,101 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect, useRef } from "react"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { DiagnosticSeverity } from "vscode-languageserver-types"; + +import { LanguageClientContext } from "../lsp/context"; +import { MonacoContext } from "./context"; + +const OWNER = "checker"; + +/** + * Convert LSP `DiagnosticSeverity` to Monaco `MarkerSeverity`. + */ +function toMarkerSeverity( + severity: DiagnosticSeverity | undefined, + monaco: typeof Monaco, +): Monaco.MarkerSeverity { + switch (severity) { + case DiagnosticSeverity.Error: + return monaco.MarkerSeverity.Error; + case DiagnosticSeverity.Warning: + return monaco.MarkerSeverity.Warning; + case DiagnosticSeverity.Information: + return monaco.MarkerSeverity.Info; + case DiagnosticSeverity.Hint: + return monaco.MarkerSeverity.Hint; + default: + return monaco.MarkerSeverity.Error; + } +} + +/** Convert LSP Diagnostic[] to Monaco IMarkerData[]. */ +function diagnosticsToMarkers( + diagnostics: Diagnostic[], + monaco: typeof Monaco, +): Monaco.editor.IMarkerData[] { + 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 = () => { + const { monaco } = use(use(MonacoContext)); + const { diagnosticsByUri } = use(LanguageClientContext); + const prevUrisRef = useRef>(new Set()); + + useEffect(() => { + const currentUris = new Set(); + + for (const [uri, diagnostics] of diagnosticsByUri) { + const monacoUri = monaco.Uri.parse(uri); + const model = monaco.editor.getModel(monacoUri); + if (model) { + const markers = diagnosticsToMarkers(diagnostics, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + currentUris.add(uri); + } + + // Clear markers from models that no longer have diagnostics + 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, []); + } + } + } + + prevUrisRef.current = currentUris; + + // Handle models created after diagnostics arrived + const disposable = monaco.editor.onDidCreateModel((model) => { + const modelUri = model.uri.toString(); + const diags = diagnosticsByUri.get(modelUri); + if (diags) { + const markers = diagnosticsToMarkers(diags, monaco); + monaco.editor.setModelMarkers(model, OWNER, markers); + } + }); + + return () => disposable.dispose(); + }, [diagnosticsByUri, monaco]); + + return null; +}; + +/** 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 new file mode 100644 index 00000000000..63e0b18f495 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/editor-paths.ts @@ -0,0 +1,5 @@ +/** + * Re-exports from the centralized document-uris module. + * Monaco components import from here for convenience. + */ +export { getDocumentUri, parseDocumentUri } from "../lsp/lib/document-uris"; 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..5fd454b65a1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/hover-sync.tsx @@ -0,0 +1,98 @@ +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 { LanguageClientContext } from "../lsp/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; + 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 { notifyDocumentChanged, requestHover } = use(LanguageClientContext); + + useEffect(() => { + const disposable = monaco.languages.registerHoverProvider("typescript", { + async provideHover(model, monacoPosition) { + const uri = model.uri.toString(); + // TODO(FE-497): Sync current content to ensure the worker has the latest text. + notifyDocumentChanged(uri, model.getValue()); + // 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 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 }; + }, + }); + + return () => disposable.dispose(); + }, [monaco, notifyDocumentChanged, requestHover]); + + return null; +}; + +/** 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 new file mode 100644 index 00000000000..e36ac9f819b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/provider.tsx @@ -0,0 +1,87 @@ +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; +} + +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); +} + +async function initMonaco(): Promise { + // 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); + return { monaco, Editor: monacoReact.default }; +} + +/** Module-level lazy singleton — initialized once, reused across renders. */ +let monacoPromise: Promise | null = null; +function getMonacoPromise(): Promise { + monacoPromise ??= initMonaco(); + return monacoPromise; +} + +export const MonacoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const promise = getMonacoPromise(); + + 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..ad151ed3abc --- /dev/null +++ b/libs/@hashintel/petrinaut/src/monaco/signature-help-sync.tsx @@ -0,0 +1,92 @@ +import type * as Monaco from "monaco-editor"; +import { Suspense, use, useEffect } from "react"; +import { + type MarkupContent, + Position, + type SignatureHelp, +} from "vscode-languageserver-types"; + +import { LanguageClientContext } from "../lsp/context"; +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; + } + return doc.value || undefined; +} + +function toMonacoSignatureHelp( + result: SignatureHelp, +): Monaco.languages.SignatureHelp { + return { + activeSignature: result.activeSignature ?? 0, + activeParameter: result.activeParameter ?? 0, + signatures: result.signatures.map((sig) => ({ + label: sig.label, + 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), + })), + })), + }; +} + +const SignatureHelpSyncInner = () => { + const { monaco } = use(use(MonacoContext)); + const { notifyDocumentChanged, requestSignatureHelp } = use( + LanguageClientContext, + ); + + useEffect(() => { + const disposable = monaco.languages.registerSignatureHelpProvider( + "typescript", + { + signatureHelpTriggerCharacters: ["(", ","], + signatureHelpRetriggerCharacters: [","], + + async provideSignatureHelp(model, monacoPosition) { + const uri = model.uri.toString(); + // TODO(FE-497): Sync current content to ensure the worker has the latest text. + notifyDocumentChanged(uri, model.getValue()); + // 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; + } + + return { + value: toMonacoSignatureHelp(result), + dispose() {}, + }; + }, + }, + ); + + return () => disposable.dispose(); + }, [monaco, notifyDocumentChanged, requestSignatureHelp]); + + return null; +}; + +/** 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 c56d600da35..e78a7e04acf 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -11,11 +11,11 @@ import type { SDCPN, Transition, } from "./core/types/sdcpn"; -import { useMonacoGlobalTypings } from "./hooks/use-monaco-global-typings"; +import { LanguageClientProvider } from "./lsp/provider"; +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 { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; import { EditorView } from "./views/Editor/editor-view"; @@ -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/state/checker-context.ts b/libs/@hashintel/petrinaut/src/state/checker-context.ts deleted file mode 100644 index 1ebf40f6f67..00000000000 --- a/libs/@hashintel/petrinaut/src/state/checker-context.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createContext } from "react"; - -import type { SDCPNCheckResult } from "../core/checker/checker"; - -export type CheckResult = SDCPNCheckResult; - -export interface CheckerContextValue { - /** The result of the last SDCPN check */ - checkResult: SDCPNCheckResult; - /** Total count of all diagnostics across all items */ - totalDiagnosticsCount: number; -} - -const DEFAULT_CONTEXT_VALUE: CheckerContextValue = { - checkResult: { - isValid: true, - itemDiagnostics: [], - }, - totalDiagnosticsCount: 0, -}; - -export const CheckerContext = createContext( - DEFAULT_CONTEXT_VALUE, -); diff --git a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx b/libs/@hashintel/petrinaut/src/state/checker-provider.tsx deleted file mode 100644 index e4ba7b2c337..00000000000 --- a/libs/@hashintel/petrinaut/src/state/checker-provider.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { use } from "react"; - -import { checkSDCPN } from "../core/checker/checker"; -import { CheckerContext } from "./checker-context"; -import { SDCPNContext } from "./sdcpn-context"; - -export const CheckerProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const { petriNetDefinition } = use(SDCPNContext); - - const checkResult = checkSDCPN(petriNetDefinition); - - const totalDiagnosticsCount = checkResult.itemDiagnostics.reduce( - (sum, item) => sum + item.diagnostics.length, - 0, - ); - - return ( - - {children} - - ); -}; 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..fa8fac7026c 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 { LanguageClientContext } from "../../../../lsp/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 145fb52b49e..5102623505b 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 { LanguageClientContext } from "../../../../lsp/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 d2c5db0bcfb..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 @@ -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,8 @@ import type { DifferentialEquation, Place, } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; +import { getDocumentUri } from "../../../../monaco/editor-paths"; import { useIsReadOnly } from "../../../../state/use-is-read-only"; const containerStyle = css({ @@ -476,6 +477,10 @@ 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/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 22201b754ee..cbcbb5cd243 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,25 +1,9 @@ /* 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"; -import { CodeEditor } from "../../../../components/code-editor"; import { IconButton } from "../../../../components/icon-button"; import { Input } from "../../../../components/input"; import { Menu } from "../../../../components/menu"; @@ -31,10 +15,12 @@ import { generateDefaultTransitionKernelCode, } from "../../../../core/default-codes"; import type { Color, Place, Transition } from "../../../../core/types/sdcpn"; +import { CodeEditor } from "../../../../monaco/code-editor"; +import { getDocumentUri } from "../../../../monaco/editor-paths"; 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 +168,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 +245,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 +280,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)} + /> + ); + })}
)} @@ -480,12 +389,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 +492,9 @@ export const TransitionProperties: React.FC = ({ )} `${a.placeId}:${a.weight}`) - .join("-")}-${transition.outputArcs - .map((a) => `${a.placeId}:${a.weight}`) - .join("-")}`} + path={getDocumentUri("transition-kernel", transition.id)} language="typescript" value={transition.transitionKernelCode || ""} - path={`inmemory://sdcpn/transitions/${transition.id}/transition-kernel.ts`} height={400} onChange={(value) => { updateTransition(transition.id, (existingTransition) => { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index b966ef21413..1d96deb9252 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -1,10 +1,11 @@ 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 type { Diagnostic } from "vscode-languageserver-types"; import type { SubView } from "../../../components/sub-view/types"; -import { CheckerContext } from "../../../state/checker-context"; +import { LanguageClientContext } from "../../../lsp/context"; +import { parseDocumentUri } from "../../../monaco/editor-paths"; import { EditorContext } from "../../../state/editor-context"; import { SDCPNContext } from "../../../state/sdcpn-context"; @@ -94,22 +95,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 +104,7 @@ interface GroupedDiagnostics { errorCount: number; items: Array<{ subType: "lambda" | "kernel" | null; - diagnostics: ts.Diagnostic[]; + diagnostics: Diagnostic[]; }>; } @@ -127,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) @@ -147,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, @@ -166,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}`; @@ -181,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) => { @@ -256,11 +248,9 @@ const DiagnosticsContent: React.FC = () => { {/* Diagnostics list */}
    - {itemGroup.diagnostics.map((diagnostic, index) => ( + {itemGroup.diagnostics.map((diagnostic) => (
  • ))} 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: ["(?