From 5c4014cebe661a02198b630f487c04151f1274fd Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Tue, 13 Oct 2026 20:51:45 +0530 Subject: [PATCH 1/3] feat: introduce FrySharp core, sidecar, CLI, and VS Code extension projects --- .gitignore | 5 + CSharpEditorPlugin.cs | 3 + CSharpEditorPlugin.csproj | 20 +- CSharpEditorPlugin.slnx | 5 + Charting/Controls/ChartCanvasControl.cs | 2 +- Charting/Services/ChartPaletteService.cs | 27 +- Charting3D/Services/ColorMapService.cs | 100 +- Controls/Activities/ActivityProgressLine.cs | 25 +- Controls/Activities/ActivityStatusItem.cs | 25 +- .../Common/ImageBytesToBitmapConverter.cs | 49 + Controls/Docs/SnippetOutputControl.axaml | 4 +- .../Editor/DebugHoverDataTipControl.axaml | 2 +- .../Overlays/QuickOpenOverlayControl.axaml | 2 +- Controls/Studio/StudioBottomDeckControl.axaml | 4 +- .../Studio/StudioBranchPickerControl.axaml | 2 +- Controls/Studio/StudioDiffViewerControl.axaml | 2 +- .../StudioLoadingOverlayControl.axaml.cs | 20 +- Directory.Build.props | 9 + FrySharp.Cli/FrySharp.Cli.csproj | 18 + FrySharp.Cli/Program.cs | 155 ++ FrySharp.Core/AssemblyInfo.cs | 4 + .../Assets}/echarts/echarts-gl.min.js | 0 .../Assets}/echarts/echarts.min.js | 0 .../Charting}/Models/ChartAxisOptions.cs | 0 .../Charting}/Models/ChartDataPoint.cs | 0 .../Charting}/Models/ChartOptions.cs | 0 .../Charting}/Models/ChartSeries.cs | 0 .../Charting}/Models/ChartStatistics.cs | 0 .../Charting}/Models/ChartViewState.cs | 0 .../Charting}/Services/ChartDataParser.cs | 0 .../Charting/Services/ChartPalette.cs | 28 + .../Layouts/ForceDirected3DLayout.cs | 0 .../Charting3D}/Models/Camera3D.cs | 0 .../Charting3D}/Models/Graph3DData.cs | 0 .../Charting3D}/Models/Plot3DOptions.cs | 0 .../Charting3D}/Models/Plot3DViewState.cs | 0 .../Charting3D}/Models/Point3D.cs | 0 .../Charting3D}/Models/Series3D.cs | 0 .../Charting3D}/Models/Surface3DData.cs | 0 .../Charting3D/Services/ColorMaps.cs | 98 + .../Charting3D}/Services/Plot3DDataParser.cs | 0 .../Services/Plot3DExportService.cs | 0 .../Charting3D}/Spatial/Matrix4x4D.cs | 0 .../Charting3D}/Spatial/Ray3D.cs | 0 .../Charting3D}/Spatial/Vector3D.cs | 0 FrySharp.Core/FrySharp.Core.csproj | 47 + .../Models}/AssemblyReferenceItem.cs | 0 .../Models}/CSharpCompletionItem.cs | 0 .../Models}/DiagnosticItem.cs | 0 .../Models}/DumpTableResult.cs | 46 +- .../Models}/Git/GitModels.cs | 0 .../Models}/NotebookCellItem.cs | 33 +- .../Models}/NotebookDocumentItem.cs | 0 .../Models}/NotebookVariableInfo.cs | 0 .../Models}/ObjectInspectorNode.cs | 0 .../Models}/QuickInfoModels.cs | 0 .../Models}/RichOutputSnapshots.cs | 0 .../Scripting/Display.Chart.cs | 5 +- .../Scripting/Display.EChart.cs | 6 +- .../Scripting/Display.Events.cs | 5 +- .../Scripting/Display.Plot3D.cs | 5 +- .../Scripting/Display.Visualizer.cs | 5 +- FrySharp.Core/Scripting/Display.cs | 170 ++ .../Services}/Common/BrowserLauncher.cs | 0 .../Services/Common/HeadlessDispatcher.cs | 64 + .../Services}/Common/IDispatcher.cs | 0 .../Common/RangeObservableCollection.cs | 2 +- .../Debugging/HttpDumpTableBuilder.cs | 0 .../Debugging/HttpInspectionHelper.cs | 0 .../Display/DisplayExtensions.EChart.cs | 0 .../Display/DisplayExtensions.Legacy.cs | 0 .../Display/DisplayExtensions.Visuals.cs | 69 +- .../Services/Display/DisplayExtensions.cs | 138 + .../Services}/Display/DumpTableBuilder.cs | 0 .../Display/ECharts/EChart.Factories.cs | 0 .../Services}/Display/ECharts/EChart.Page.cs | 0 .../Display/ECharts/EChart.ThreeD.cs | 0 .../Services}/Display/ECharts/EChart.cs | 2 +- .../Services}/Display/ECharts/EChartData.cs | 0 .../Services}/Display/ECharts/EChartPath.cs | 0 .../Services}/Display/ECharts/EChartTypes.cs | 0 .../Services}/Display/Fluent/ChartBuilder.cs | 0 .../Services}/Display/Fluent/Charts.cs | 2 +- .../Services}/Display/Fluent/Plot3DBuilder.cs | 0 .../Services}/Display/Fluent/SeriesOptions.cs | 0 .../Services}/Display/Fluent/VisualBuilder.cs | 5 +- .../Services}/Display/HtmlAssets.cs | 2 +- .../Services/Display/IScriptHostDisplay.cs | 25 + FrySharp.Core/Services/Display/ImageHeader.cs | 87 + .../Display/InteractiveDisplayContext.cs | 4 +- .../Display/InteractiveTableHtmlRenderer.cs | 483 ++++ .../Services/Display/MarkdownOutput.cs | 68 + .../Services}/Display/NestedTableDetector.cs | 0 .../Display/ObjectInspectorBuilder.cs | 0 .../Services}/Display/RichCellOutput.cs | 53 +- .../Services/Display/RichOutputMime.cs | 155 ++ FrySharp.Core/Services/Display/SkiaImages.cs | 79 + .../Services/Display/VisualSvgRenderer.cs | 608 +++++ .../Execution/ConsoleRoutingContext.cs | 0 .../Execution/ExecutionAbandonment.cs | 0 .../Services/Execution/FileRunner.cs | 106 + .../Execution/InProcessCSharpRunner.cs | 91 + .../Execution/ScriptExecutionEngine.cs | 0 .../Services/Kernels/CellDisposables.cs | 52 + .../Kernels/CellUsingDeclarations.cs | 24 +- .../Services}/Kernels/EmbeddedKernelFiles.cs | 0 .../Services}/Kernels/IKernelLauncher.cs | 0 .../Services}/Kernels/INotebookKernel.cs | 0 .../Services/Kernels/InputRequestReader.cs | 49 + .../Services}/Kernels/KernelValueSharing.cs | 4 +- .../Services}/Kernels/MimeOutputMapper.cs | 12 +- .../Kernels/NotebookCellDirectives.cs | 16 +- .../Kernels/NotebookDirectiveRunner.cs | 112 + .../Kernels/NotebookExecutionKernel.cs | 148 +- .../Services}/Kernels/NotebookKernelRouter.cs | 14 +- .../Kernels/ProtocolKernel.Session.cs | 0 .../Services}/Kernels/ProtocolKernel.cs | 0 .../CSharp/CSharpBuildAndRunScriptRunner.cs | 0 .../CSharp/CSharpCompilerDiagnosticParser.cs | 0 .../Services/Languages/CSharp/CSharpEngine.cs | 43 + .../CSharp/CSharpToolchainProvider.cs | 0 .../Languages/CSharp/NuGetPackageManager.cs | 0 .../Services/Languages/HeadlessLanguages.cs | 35 + .../Services/Languages/ILanguageEngine.cs | 110 + .../JavaScript/JavaScriptDisplayRuntime.cs | 0 .../Languages/JavaScript/JavaScriptEngine.cs | 63 + .../JavaScript/JavaScriptGuidance.cs | 0 .../JavaScript/JavaScriptKernelLauncher.cs | 0 .../JavaScript/JavaScriptPackageMap.cs | 0 .../JavaScript/JavaScriptScriptRunner.cs | 0 .../JavaScriptToolchainProvider.Discovery.cs | 0 .../JavaScript/JavaScriptToolchainProvider.cs | 0 .../JavaScript/JavaScriptTracebackParser.cs | 0 .../Languages/JavaScript/Kernel/fry_kernel.js | 6 +- .../Languages/JavaScript/NpmPackageManager.cs | 0 .../Languages/JavaScript/Runtime/fry.js | 0 .../Services/Languages/JupyterLanguageInfo.cs | 11 + .../Languages/LanguageCapabilities.cs | 0 .../Languages/LanguageEngineServices.cs | 36 + .../Services/Languages/LanguageRegistry.cs | 146 ++ .../Languages/Python/Kernel/fry_kernel.py | 0 .../Languages/Python/Kernel/fry_matplotlib.py | 0 .../Languages/Python/PipPackageManager.cs | 0 .../Languages/Python/PythonDisplayRuntime.cs | 0 .../Services/Languages/Python/PythonEngine.cs | 65 + .../Languages/Python/PythonGuidance.cs | 0 .../Languages/Python/PythonKernelLauncher.cs | 0 .../Languages/Python/PythonPackageMap.cs | 0 .../Languages/Python/PythonScriptRunner.cs | 0 .../PythonToolchainProvider.Discovery.cs | 0 .../Python/PythonToolchainProvider.cs | 0 .../Languages/Python/PythonTracebackParser.cs | 0 .../Services}/Languages/Python/Runtime/fry.py | 0 .../Languages/Python/Runtime/fry_channel.py | 0 .../Languages/Python/Runtime/fry_display.py | 26 + .../Languages/Python/Runtime/fry_specs.py | 0 .../Services}/Packages/IPackageManager.cs | 0 .../Services}/Processes/AsciiTableDetector.cs | 0 .../Processes/InteractiveStdinReader.cs | 0 .../Processes/ManagedProcessExtensions.cs | 0 .../Services}/Processes/ProcessLauncher.cs | 0 .../Services}/Processes/ProcessRegistry.cs | 0 .../Services}/Processes/ScriptRunExecutor.cs | 0 .../Services}/Processes/ScriptRunner.cs | 0 .../Services}/Processes/TerminalTextBuffer.cs | 0 .../Services}/Processes/WindowsJobObject.cs | 0 .../Services}/Roslyn/CSharpClassifier.cs | 2 +- .../Roslyn/CSharpCompletionService.cs | 0 .../Roslyn/CSharpQuickInfoService.cs | 0 .../Roslyn/NuGetReferenceResolver.cs | 0 .../Roslyn/QuickInfoSignatureBuilder.cs | 0 .../Services}/Roslyn/RoslynCompilerService.cs | 160 +- .../Services/Roslyn/ScriptHostProfile.cs | 136 + .../Services/Roslyn/ScriptImports.cs | 30 + .../Roslyn/XmlDocumentationLookup.cs | 0 .../Roslyn/XmlDocumentationParser.cs | 0 FrySharp.Core/Services/Storage/AtomicFile.cs | 66 + .../Services/Storage/NotebookFileFormat.cs | 414 +++ .../Services/Storage/ScriptStorageIdHelper.cs | 19 + .../Services/Storage/TextFileReader.cs | 43 + .../Services}/Toolchains/ExecutableSearch.cs | 0 .../Services}/Toolchains/HostEnvironment.cs | 0 .../Toolchains/IToolchainProvider.cs | 0 .../Services}/Toolchains/ToolchainModels.cs | 0 .../Toolchains/ToolchainSettingsStore.cs | 0 .../Toolchains/ToolchainSetupRunner.cs | 0 .../Git/GitCommitMessageGenerator.cs | 0 .../Services}/Workspace/Git/GitDiffParser.cs | 0 .../Services}/Workspace/Git/GitService.cs | 0 .../Workspace/Git/GitStatusParser.cs | 0 .../Services}/Workspace/Git/IGitService.cs | 0 .../Services}/Workspace/TextMatcher.cs | 0 .../Services}/Workspace/WorkspaceFileIndex.cs | 0 .../Workspace/WorkspaceTextSearch.cs | 2 +- .../Services}/Workspace/WorkspaceWalker.cs | 2 +- .../Services}/Workspace/WorkspaceWatcher.cs | 2 +- .../Visualizers}/Layouts/GraphLayoutEngine.cs | 0 .../Visualizers}/Layouts/TreeLayoutEngine.cs | 0 .../Visualizers}/Models/ArrayPointerData.cs | 0 .../Models/BarChartVisualizerData.cs | 0 .../Models/BoardVisualizerData.cs | 0 .../Visualizers}/Models/GraphData.cs | 0 .../Visualizers}/Models/GridMatrixData.cs | 0 .../Visualizers}/Models/IslandData.cs | 0 .../Visualizers}/Models/LinkedListData.cs | 0 .../Visualizers}/Models/StepWatch.cs | 0 .../Visualizers}/Models/TreeNodeData.cs | 0 .../Visualizers}/Models/VisualizerOptions.cs | 0 .../Visualizers}/Models/VisualizerScene.cs | 0 .../Visualizers}/Models/VisualizerSequence.cs | 1 - .../Visualizers}/Models/VisualizerStep.cs | 0 .../Models/VisualizerViewState.cs | 0 .../Services/ArrayPointerDataParser.cs | 0 .../Services/DataStructureDetector.cs | 0 .../Visualizers}/Services/GraphDataParser.cs | 0 .../Visualizers}/Services/GraphTracker.cs | 0 .../Visualizers}/Services/IntervalTracker.cs | 0 .../Services/IslandDetectorService.cs | 0 .../Services/LinkedListDataParser.cs | 0 .../Services/LinkedListTracker.cs | 0 .../Visualizers}/Services/MatrixDataParser.cs | 0 .../Visualizers}/Services/MatrixTracker.cs | 0 .../Services/MatrixTraversalEngine.cs | 0 .../Visualizers}/Services/RecursionTracker.cs | 0 .../Visualizers}/Services/StepChanges.cs | 0 .../Visualizers}/Services/TreeDataParser.cs | 0 .../Visualizers}/Services/TreeTracker.cs | 0 .../Visualizers}/Services/TrieTracker.cs | 0 .../Services/VisualizerPaletteService.cs | 0 .../Services/VisualizerRecorder.Bars.cs | 0 .../Services/VisualizerRecorder.Board.cs | 0 .../Services/VisualizerRecorder.Canvas.cs | 0 .../Services/VisualizerRecorder.Graph.cs | 0 .../Services/VisualizerRecorder.Matrix.cs | 0 .../Services/VisualizerRecorder.Tree.cs | 0 .../Services/VisualizerRecorder.cs | 12 - .../Services/VisualizerReflectionHelper.cs | 0 .../Services/VisualizerValueFormatter.cs | 0 .../Visualizers}/Services/WatchCapture.cs | 0 .../Building/ChartOptionsConverter.cs | 2 +- .../Visuals}/Building/ChartSpecBuilder.cs | 0 .../Visuals}/Building/DataReader.cs | 0 .../Building/Plot3DOptionsConverter.cs | 0 .../Visuals}/Building/Plot3DSpecBuilder.cs | 0 .../Building/VisualizerOptionsConverter.cs | 0 .../Building/VisualizerSpecBuilder.cs | 0 .../Visuals}/Interaction/DisplayHandle.cs | 0 .../Interaction/ExternalVisualSession.cs | 0 .../Visuals}/Interaction/ProgramVisuals.cs | 0 .../Visuals}/Interaction/VisualEvent.cs | 0 .../Visuals}/Interaction/VisualEventHub.cs | 0 .../Visuals}/Interaction/VisualEventLoop.cs | 0 .../Visuals}/Json/ChartSpecRules.cs | 0 .../Visuals}/Json/VisualJson.cs | 0 .../Visuals}/Json/VisualJsonErrors.cs | 0 .../Visuals}/Json/VisualLimits.cs | 0 .../Visuals}/Json/VisualMimeTypes.cs | 0 .../Visuals}/Json/VisualSchemas.cs | 0 .../Visuals}/Json/VisualSpecValidator.cs | 0 .../Visuals}/Json/VisualizerSpecRules.cs | 0 .../Visuals}/Json/VisualizerStateRules.cs | 0 .../Visuals}/Kinds/ArrayKind.cs | 0 .../Visuals}/Kinds/BarsKind.cs | 0 .../Visuals}/Kinds/BoardKind.cs | 0 .../Visuals}/Kinds/CanvasKind.cs | 0 .../Visuals}/Kinds/ElementStates.cs | 0 .../Visuals}/Kinds/GraphKind.cs | 0 .../Visuals}/Kinds/GridKind.cs | 0 .../Visuals}/Kinds/IVisualizerKind.cs | 0 .../Visuals}/Kinds/LinkedListKind.cs | 0 .../Visuals}/Kinds/StateDiff.cs | 0 .../Visuals}/Kinds/TreeKind.cs | 0 .../Visuals}/Output/VisualOriginCapture.cs | 0 .../Visuals}/Output/VisualOutput.cs | 0 .../Visuals}/Output/VisualOutputRegistry.cs | 0 .../Visuals}/Output/VisualOutputSnapshot.cs | 0 .../Visuals}/Output/VisualOutputs.cs | 0 .../Rendering/ChartRenderModelBuilder.cs | 6 +- .../Visuals}/Rendering/Notices.cs | 0 .../Rendering/Plot3DRenderModelBuilder.cs | 0 .../Visuals}/Rendering/VisualDrawing.cs | 0 .../Visuals}/Rendering/VisualSize.cs | 0 .../Rendering/VisualizerRenderModelBuilder.cs | 0 .../Visuals}/Rendering/VisualizerStepStore.cs | 0 .../Visuals}/Spec/CanvasSpec.cs | 0 .../Visuals}/Spec/ChartSpec.cs | 0 .../Visuals}/Spec/ElementRef.cs | 0 .../Visuals}/Spec/GraphSpec.cs | 0 .../Visuals}/Spec/Plot3DSpec.cs | 0 .../Visuals}/Spec/ScalarValue.cs | 0 .../Visuals}/Spec/VisualSpec.cs | 0 .../Visuals}/Spec/VisualVocabulary.cs | 0 .../Visuals}/Spec/VisualizerSpec.cs | 0 .../Visuals}/Spec/VisualizerStates.cs | 0 FrySharp.Sidecar/FrySharp.Sidecar.csproj | 18 + FrySharp.Sidecar/Hosting/SidecarOptions.cs | 19 + FrySharp.Sidecar/Program.cs | 100 + FrySharp.Sidecar/Protocol/Protocol.cs | 175 ++ FrySharp.Sidecar/Protocol/WorkspacePaths.cs | 115 + FrySharp.Sidecar/Services/DocumentService.cs | 140 + FrySharp.Sidecar/Services/LanguageFeatures.cs | 155 ++ FrySharp.Sidecar/Services/NotebookService.cs | 532 ++++ FrySharp.Sidecar/Services/RunOutput.cs | 96 + FrySharp.Sidecar/Services/RunService.cs | 214 ++ FrySharp.Sidecar/Services/WorkspaceService.cs | 179 ++ FrySharp.Sidecar/Sessions/SidecarRpc.cs | 199 ++ FrySharp.Sidecar/Sessions/SidecarSession.cs | 73 + Makefile | 36 +- Runner/Program.cs | 1 + Services/Display/ImageDecoder.cs | 86 +- .../Display/InteractiveDisplayService.Gif.cs | 22 +- Services/Display/InteractiveDisplayService.cs | 416 +-- Services/Languages/CSharpLanguage.cs | 40 +- Services/Languages/ILanguageDefinition.cs | 104 +- .../JavaScript/JavaScriptLanguage.cs | 57 +- Services/Languages/LanguageRegistry.cs | 135 +- Services/Languages/Python/PythonLanguage.cs | 59 +- Services/Languages/StudioLanguageServices.cs | 4 + Services/Roslyn/DesktopScriptHost.cs | 60 + Services/Roslyn/ScriptImports.cs | 71 - Services/Storage/DocumentExportService.cs | 108 +- .../LocalScriptStorageService.SourceFiles.cs | 11 +- Services/Storage/LocalScriptStorageService.cs | 18 +- Tests.Core/FileRunnerTests.cs | 78 + Tests.Core/FrySharp.Core.Tests.csproj | 17 + Tests.Core/InProcessCSharpRunnerTests.cs | 113 + Tests.Core/NotebookFileFormatTests.cs | 122 + Tests.Core/ScriptDiagnosticsTests.cs | 31 + Tests.Core/StorageAndOutputTests.cs | 284 ++ Tests.Core/TestSupport.cs | 78 + Tests.Sidecar/FrySharp.Sidecar.Tests.csproj | 18 + Tests.Sidecar/SidecarHarness.cs | 102 + Tests.Sidecar/SidecarTests.cs | 148 ++ Tests/CodeStudio/TablePreviewScaleTests.cs | 6 +- Tests/Media/Gif/AnimatedGifEncoderTests.cs | 4 +- Tests/RealJavaScript/JavaScriptKernelTests.cs | 2 +- .../JavaScriptScriptRunTests.cs | 2 +- .../JavaScriptVisualsConformanceTests.cs | 4 +- Tests/RealPython/PythonKernelTests.cs | 2 +- Tests/RealPython/PythonScriptRunTests.cs | 2 +- .../PythonVisualsConformanceTests.cs | 4 +- Tests/Services/Display/DisplayFacadeTests.cs | 51 + .../Execution/ExternalOutputProcessorTests.cs | 16 +- Tests/Settings/ThemedSyntaxAndChartsTests.cs | 2 +- Tests/TestSupport/DesktopScriptHostSetup.cs | 14 + .../CSharpCodeStudioViewModel.Explorer.cs | 47 +- .../CSharpCodeStudioViewModel.Images.cs | 4 +- .../CSharpCodeStudioViewModel.Search.cs | 1 + .../CodeStudio/CSharpCodeStudioViewModel.cs | 6 +- .../CodeStudio/Explorer/ExplorerRefresher.cs | 84 + .../CodeStudio/Explorer/ExplorerRowList.cs | 1 + ViewModels/Common/StudioWorkspaceExplorer.cs | 45 +- ...CSharpNotebookStudioViewModel.Documents.cs | 2 +- .../CSharpNotebookStudioViewModel.Explorer.cs | 55 +- .../CSharpNotebookStudioViewModel.cs | 7 +- ViewModels/Notebooks/NotebookCanvasRows.cs | 1 + ViewModels/Notebooks/NotebookCellViewModel.cs | 6 +- .../NotebookTabViewModel.Languages.cs | 88 +- ViewModels/Notebooks/NotebookTabViewModel.cs | 10 +- ViewModels/Server/FryServerStudioViewModel.cs | 4 +- .../CSharpSettingsViewModel.ThemingExport.cs | 3 +- Views/DumpTableView.Rendering.cs | 2 +- Views/DumpTableView.axaml | 8 +- Views/DumpTableView.axaml.cs | 18 + Views/Server/FryServerStudioView.axaml | 2 +- .../VisualizerRecorderGifExtensions.cs | 26 + devlog/week-2-thumbnail.svg | 2 +- docs/session-protocol.md | 161 ++ futureplan/Idea.md | 90 +- samples/demo.frynb | 2318 +++++++++++++++++ samples/hello.cs | 4 + tools/UiSnapshots/Program.cs | 1 + tools/test-demo-notebook.js | 89 + tools/test-extension-e2e.js | 189 ++ vscode-frysharp/.gitignore | 5 + vscode-frysharp/.vscodeignore | 5 + vscode-frysharp/LICENSE | 21 + vscode-frysharp/README.md | 34 + vscode-frysharp/package-lock.json | 70 + vscode-frysharp/package.json | 134 + vscode-frysharp/src/extension.ts | 189 ++ .../src/notebook/notebookController.ts | 184 ++ .../src/notebook/notebookSerializer.ts | 871 +++++++ vscode-frysharp/src/notebook/variablesView.ts | 305 +++ vscode-frysharp/src/sidecar/protocol.ts | 125 + vscode-frysharp/src/sidecar/sidecarClient.ts | 192 ++ vscode-frysharp/tsconfig.json | 15 + 387 files changed, 12788 insertions(+), 1822 deletions(-) create mode 100644 Controls/Common/ImageBytesToBitmapConverter.cs create mode 100644 FrySharp.Cli/FrySharp.Cli.csproj create mode 100644 FrySharp.Cli/Program.cs create mode 100644 FrySharp.Core/AssemblyInfo.cs rename {Assets => FrySharp.Core/Assets}/echarts/echarts-gl.min.js (100%) rename {Assets => FrySharp.Core/Assets}/echarts/echarts.min.js (100%) rename {Charting => FrySharp.Core/Charting}/Models/ChartAxisOptions.cs (100%) rename {Charting => FrySharp.Core/Charting}/Models/ChartDataPoint.cs (100%) rename {Charting => FrySharp.Core/Charting}/Models/ChartOptions.cs (100%) rename {Charting => FrySharp.Core/Charting}/Models/ChartSeries.cs (100%) rename {Charting => FrySharp.Core/Charting}/Models/ChartStatistics.cs (100%) rename {Charting => FrySharp.Core/Charting}/Models/ChartViewState.cs (100%) rename {Charting => FrySharp.Core/Charting}/Services/ChartDataParser.cs (100%) create mode 100644 FrySharp.Core/Charting/Services/ChartPalette.cs rename {Charting3D => FrySharp.Core/Charting3D}/Layouts/ForceDirected3DLayout.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Models/Camera3D.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Models/Graph3DData.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Models/Plot3DOptions.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Models/Plot3DViewState.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Models/Point3D.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Models/Series3D.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Models/Surface3DData.cs (100%) create mode 100644 FrySharp.Core/Charting3D/Services/ColorMaps.cs rename {Charting3D => FrySharp.Core/Charting3D}/Services/Plot3DDataParser.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Services/Plot3DExportService.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Spatial/Matrix4x4D.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Spatial/Ray3D.cs (100%) rename {Charting3D => FrySharp.Core/Charting3D}/Spatial/Vector3D.cs (100%) create mode 100644 FrySharp.Core/FrySharp.Core.csproj rename {Models => FrySharp.Core/Models}/AssemblyReferenceItem.cs (100%) rename {Models => FrySharp.Core/Models}/CSharpCompletionItem.cs (100%) rename {Models => FrySharp.Core/Models}/DiagnosticItem.cs (100%) rename {Models => FrySharp.Core/Models}/DumpTableResult.cs (91%) rename {Models => FrySharp.Core/Models}/Git/GitModels.cs (100%) rename {Models => FrySharp.Core/Models}/NotebookCellItem.cs (66%) rename {Models => FrySharp.Core/Models}/NotebookDocumentItem.cs (100%) rename {Models => FrySharp.Core/Models}/NotebookVariableInfo.cs (100%) rename {Models => FrySharp.Core/Models}/ObjectInspectorNode.cs (100%) rename {Models => FrySharp.Core/Models}/QuickInfoModels.cs (100%) rename {Models => FrySharp.Core/Models}/RichOutputSnapshots.cs (100%) rename Services/Display/InteractiveDisplayService.Chart.cs => FrySharp.Core/Scripting/Display.Chart.cs (99%) rename Services/Display/InteractiveDisplayService.EChart.cs => FrySharp.Core/Scripting/Display.EChart.cs (84%) rename Services/Display/InteractiveDisplayService.Events.cs => FrySharp.Core/Scripting/Display.Events.cs (86%) rename Services/Display/InteractiveDisplayService.Plot3D.cs => FrySharp.Core/Scripting/Display.Plot3D.cs (98%) rename Services/Display/InteractiveDisplayService.Visualizer.cs => FrySharp.Core/Scripting/Display.Visualizer.cs (98%) create mode 100644 FrySharp.Core/Scripting/Display.cs rename {Services => FrySharp.Core/Services}/Common/BrowserLauncher.cs (100%) create mode 100644 FrySharp.Core/Services/Common/HeadlessDispatcher.cs rename {Services => FrySharp.Core/Services}/Common/IDispatcher.cs (100%) rename {ViewModels => FrySharp.Core/Services}/Common/RangeObservableCollection.cs (97%) rename {Services => FrySharp.Core/Services}/Debugging/HttpDumpTableBuilder.cs (100%) rename {Services => FrySharp.Core/Services}/Debugging/HttpInspectionHelper.cs (100%) rename {Services => FrySharp.Core/Services}/Display/DisplayExtensions.EChart.cs (100%) rename {Services => FrySharp.Core/Services}/Display/DisplayExtensions.Legacy.cs (100%) rename {Services => FrySharp.Core/Services}/Display/DisplayExtensions.Visuals.cs (70%) create mode 100644 FrySharp.Core/Services/Display/DisplayExtensions.cs rename {Services => FrySharp.Core/Services}/Display/DumpTableBuilder.cs (100%) rename {Services => FrySharp.Core/Services}/Display/ECharts/EChart.Factories.cs (100%) rename {Services => FrySharp.Core/Services}/Display/ECharts/EChart.Page.cs (100%) rename {Services => FrySharp.Core/Services}/Display/ECharts/EChart.ThreeD.cs (100%) rename {Services => FrySharp.Core/Services}/Display/ECharts/EChart.cs (99%) rename {Services => FrySharp.Core/Services}/Display/ECharts/EChartData.cs (100%) rename {Services => FrySharp.Core/Services}/Display/ECharts/EChartPath.cs (100%) rename {Services => FrySharp.Core/Services}/Display/ECharts/EChartTypes.cs (100%) rename {Services => FrySharp.Core/Services}/Display/Fluent/ChartBuilder.cs (100%) rename {Services => FrySharp.Core/Services}/Display/Fluent/Charts.cs (98%) rename {Services => FrySharp.Core/Services}/Display/Fluent/Plot3DBuilder.cs (100%) rename {Services => FrySharp.Core/Services}/Display/Fluent/SeriesOptions.cs (100%) rename {Services => FrySharp.Core/Services}/Display/Fluent/VisualBuilder.cs (91%) rename {Services => FrySharp.Core/Services}/Display/HtmlAssets.cs (98%) create mode 100644 FrySharp.Core/Services/Display/IScriptHostDisplay.cs create mode 100644 FrySharp.Core/Services/Display/ImageHeader.cs rename {Services => FrySharp.Core/Services}/Display/InteractiveDisplayContext.cs (97%) create mode 100644 FrySharp.Core/Services/Display/InteractiveTableHtmlRenderer.cs create mode 100644 FrySharp.Core/Services/Display/MarkdownOutput.cs rename {Services => FrySharp.Core/Services}/Display/NestedTableDetector.cs (100%) rename {Services => FrySharp.Core/Services}/Display/ObjectInspectorBuilder.cs (100%) rename {Services => FrySharp.Core/Services}/Display/RichCellOutput.cs (63%) create mode 100644 FrySharp.Core/Services/Display/RichOutputMime.cs create mode 100644 FrySharp.Core/Services/Display/SkiaImages.cs create mode 100644 FrySharp.Core/Services/Display/VisualSvgRenderer.cs rename {Services => FrySharp.Core/Services}/Execution/ConsoleRoutingContext.cs (100%) rename {Services => FrySharp.Core/Services}/Execution/ExecutionAbandonment.cs (100%) create mode 100644 FrySharp.Core/Services/Execution/FileRunner.cs create mode 100644 FrySharp.Core/Services/Execution/InProcessCSharpRunner.cs rename {Services => FrySharp.Core/Services}/Execution/ScriptExecutionEngine.cs (100%) create mode 100644 FrySharp.Core/Services/Kernels/CellDisposables.cs rename {Services => FrySharp.Core/Services}/Kernels/CellUsingDeclarations.cs (68%) rename {Services => FrySharp.Core/Services}/Kernels/EmbeddedKernelFiles.cs (100%) rename {Services => FrySharp.Core/Services}/Kernels/IKernelLauncher.cs (100%) rename {Services => FrySharp.Core/Services}/Kernels/INotebookKernel.cs (100%) create mode 100644 FrySharp.Core/Services/Kernels/InputRequestReader.cs rename {Services => FrySharp.Core/Services}/Kernels/KernelValueSharing.cs (98%) rename {Services => FrySharp.Core/Services}/Kernels/MimeOutputMapper.cs (95%) rename {Services => FrySharp.Core/Services}/Kernels/NotebookCellDirectives.cs (91%) create mode 100644 FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs rename {Services => FrySharp.Core/Services}/Kernels/NotebookExecutionKernel.cs (82%) rename {Services => FrySharp.Core/Services}/Kernels/NotebookKernelRouter.cs (89%) rename {Services => FrySharp.Core/Services}/Kernels/ProtocolKernel.Session.cs (100%) rename {Services => FrySharp.Core/Services}/Kernels/ProtocolKernel.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/CSharp/CSharpBuildAndRunScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/CSharp/CSharpCompilerDiagnosticParser.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/CSharp/CSharpEngine.cs rename {Services => FrySharp.Core/Services}/Languages/CSharp/CSharpToolchainProvider.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/CSharp/NuGetPackageManager.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/HeadlessLanguages.cs create mode 100644 FrySharp.Core/Services/Languages/ILanguageEngine.cs rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptDisplayRuntime.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/JavaScript/JavaScriptEngine.cs rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptGuidance.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptKernelLauncher.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptPackageMap.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptToolchainProvider.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/JavaScriptTracebackParser.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/Kernel/fry_kernel.js (96%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/NpmPackageManager.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/JavaScript/Runtime/fry.js (100%) create mode 100644 FrySharp.Core/Services/Languages/JupyterLanguageInfo.cs rename {Services => FrySharp.Core/Services}/Languages/LanguageCapabilities.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/LanguageEngineServices.cs create mode 100644 FrySharp.Core/Services/Languages/LanguageRegistry.cs rename {Services => FrySharp.Core/Services}/Languages/Python/Kernel/fry_kernel.py (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/Kernel/fry_matplotlib.py (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PipPackageManager.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PythonDisplayRuntime.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Python/PythonEngine.cs rename {Services => FrySharp.Core/Services}/Languages/Python/PythonGuidance.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PythonKernelLauncher.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PythonPackageMap.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PythonScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PythonToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PythonToolchainProvider.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/PythonTracebackParser.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/Runtime/fry.py (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/Runtime/fry_channel.py (100%) rename {Services => FrySharp.Core/Services}/Languages/Python/Runtime/fry_display.py (91%) rename {Services => FrySharp.Core/Services}/Languages/Python/Runtime/fry_specs.py (100%) rename {Services => FrySharp.Core/Services}/Packages/IPackageManager.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/AsciiTableDetector.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/InteractiveStdinReader.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/ManagedProcessExtensions.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/ProcessLauncher.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/ProcessRegistry.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/ScriptRunExecutor.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/ScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/TerminalTextBuffer.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/WindowsJobObject.cs (100%) rename {Services => FrySharp.Core/Services}/Roslyn/CSharpClassifier.cs (99%) rename {Services => FrySharp.Core/Services}/Roslyn/CSharpCompletionService.cs (100%) rename {Services => FrySharp.Core/Services}/Roslyn/CSharpQuickInfoService.cs (100%) rename {Services => FrySharp.Core/Services}/Roslyn/NuGetReferenceResolver.cs (100%) rename {Services => FrySharp.Core/Services}/Roslyn/QuickInfoSignatureBuilder.cs (100%) rename {Services => FrySharp.Core/Services}/Roslyn/RoslynCompilerService.cs (77%) create mode 100644 FrySharp.Core/Services/Roslyn/ScriptHostProfile.cs create mode 100644 FrySharp.Core/Services/Roslyn/ScriptImports.cs rename {Services => FrySharp.Core/Services}/Roslyn/XmlDocumentationLookup.cs (100%) rename {Services => FrySharp.Core/Services}/Roslyn/XmlDocumentationParser.cs (100%) create mode 100644 FrySharp.Core/Services/Storage/AtomicFile.cs create mode 100644 FrySharp.Core/Services/Storage/NotebookFileFormat.cs create mode 100644 FrySharp.Core/Services/Storage/ScriptStorageIdHelper.cs create mode 100644 FrySharp.Core/Services/Storage/TextFileReader.cs rename {Services => FrySharp.Core/Services}/Toolchains/ExecutableSearch.cs (100%) rename {Services => FrySharp.Core/Services}/Toolchains/HostEnvironment.cs (100%) rename {Services => FrySharp.Core/Services}/Toolchains/IToolchainProvider.cs (100%) rename {Services => FrySharp.Core/Services}/Toolchains/ToolchainModels.cs (100%) rename {Services => FrySharp.Core/Services}/Toolchains/ToolchainSettingsStore.cs (100%) rename {Services => FrySharp.Core/Services}/Toolchains/ToolchainSetupRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/Git/GitCommitMessageGenerator.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/Git/GitDiffParser.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/Git/GitService.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/Git/GitStatusParser.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/Git/IGitService.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/TextMatcher.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/WorkspaceFileIndex.cs (100%) rename {Services => FrySharp.Core/Services}/Workspace/WorkspaceTextSearch.cs (99%) rename {Services => FrySharp.Core/Services}/Workspace/WorkspaceWalker.cs (99%) rename {Services => FrySharp.Core/Services}/Workspace/WorkspaceWatcher.cs (98%) rename {Visualizers => FrySharp.Core/Visualizers}/Layouts/GraphLayoutEngine.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Layouts/TreeLayoutEngine.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/ArrayPointerData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/BarChartVisualizerData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/BoardVisualizerData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/GraphData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/GridMatrixData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/IslandData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/LinkedListData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/StepWatch.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/TreeNodeData.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/VisualizerOptions.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/VisualizerScene.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/VisualizerSequence.cs (99%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/VisualizerStep.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Models/VisualizerViewState.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/ArrayPointerDataParser.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/DataStructureDetector.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/GraphDataParser.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/GraphTracker.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/IntervalTracker.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/IslandDetectorService.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/LinkedListDataParser.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/LinkedListTracker.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/MatrixDataParser.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/MatrixTracker.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/MatrixTraversalEngine.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/RecursionTracker.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/StepChanges.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/TreeDataParser.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/TreeTracker.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/TrieTracker.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerPaletteService.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerRecorder.Bars.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerRecorder.Board.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerRecorder.Canvas.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerRecorder.Graph.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerRecorder.Matrix.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerRecorder.Tree.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerRecorder.cs (86%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerReflectionHelper.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/VisualizerValueFormatter.cs (100%) rename {Visualizers => FrySharp.Core/Visualizers}/Services/WatchCapture.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Building/ChartOptionsConverter.cs (98%) rename {Visuals => FrySharp.Core/Visuals}/Building/ChartSpecBuilder.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Building/DataReader.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Building/Plot3DOptionsConverter.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Building/Plot3DSpecBuilder.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Building/VisualizerOptionsConverter.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Building/VisualizerSpecBuilder.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Interaction/DisplayHandle.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Interaction/ExternalVisualSession.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Interaction/ProgramVisuals.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Interaction/VisualEvent.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Interaction/VisualEventHub.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Interaction/VisualEventLoop.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/ChartSpecRules.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualJson.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualJsonErrors.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualLimits.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualMimeTypes.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualSchemas.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualSpecValidator.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualizerSpecRules.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Json/VisualizerStateRules.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/ArrayKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/BarsKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/BoardKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/CanvasKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/ElementStates.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/GraphKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/GridKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/IVisualizerKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/LinkedListKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/StateDiff.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Kinds/TreeKind.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Output/VisualOriginCapture.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Output/VisualOutput.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Output/VisualOutputRegistry.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Output/VisualOutputSnapshot.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Output/VisualOutputs.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Rendering/ChartRenderModelBuilder.cs (95%) rename {Visuals => FrySharp.Core/Visuals}/Rendering/Notices.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Rendering/Plot3DRenderModelBuilder.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Rendering/VisualDrawing.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Rendering/VisualSize.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Rendering/VisualizerRenderModelBuilder.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Rendering/VisualizerStepStore.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/CanvasSpec.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/ChartSpec.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/ElementRef.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/GraphSpec.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/Plot3DSpec.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/ScalarValue.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/VisualSpec.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/VisualVocabulary.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/VisualizerSpec.cs (100%) rename {Visuals => FrySharp.Core/Visuals}/Spec/VisualizerStates.cs (100%) create mode 100644 FrySharp.Sidecar/FrySharp.Sidecar.csproj create mode 100644 FrySharp.Sidecar/Hosting/SidecarOptions.cs create mode 100644 FrySharp.Sidecar/Program.cs create mode 100644 FrySharp.Sidecar/Protocol/Protocol.cs create mode 100644 FrySharp.Sidecar/Protocol/WorkspacePaths.cs create mode 100644 FrySharp.Sidecar/Services/DocumentService.cs create mode 100644 FrySharp.Sidecar/Services/LanguageFeatures.cs create mode 100644 FrySharp.Sidecar/Services/NotebookService.cs create mode 100644 FrySharp.Sidecar/Services/RunOutput.cs create mode 100644 FrySharp.Sidecar/Services/RunService.cs create mode 100644 FrySharp.Sidecar/Services/WorkspaceService.cs create mode 100644 FrySharp.Sidecar/Sessions/SidecarRpc.cs create mode 100644 FrySharp.Sidecar/Sessions/SidecarSession.cs create mode 100644 Services/Roslyn/DesktopScriptHost.cs delete mode 100644 Services/Roslyn/ScriptImports.cs create mode 100644 Tests.Core/FileRunnerTests.cs create mode 100644 Tests.Core/FrySharp.Core.Tests.csproj create mode 100644 Tests.Core/InProcessCSharpRunnerTests.cs create mode 100644 Tests.Core/NotebookFileFormatTests.cs create mode 100644 Tests.Core/ScriptDiagnosticsTests.cs create mode 100644 Tests.Core/StorageAndOutputTests.cs create mode 100644 Tests.Core/TestSupport.cs create mode 100644 Tests.Sidecar/FrySharp.Sidecar.Tests.csproj create mode 100644 Tests.Sidecar/SidecarHarness.cs create mode 100644 Tests.Sidecar/SidecarTests.cs create mode 100644 Tests/Services/Display/DisplayFacadeTests.cs create mode 100644 Tests/TestSupport/DesktopScriptHostSetup.cs create mode 100644 ViewModels/CodeStudio/Explorer/ExplorerRefresher.cs create mode 100644 Visualizers/Services/VisualizerRecorderGifExtensions.cs create mode 100644 docs/session-protocol.md create mode 100644 samples/demo.frynb create mode 100644 samples/hello.cs create mode 100644 tools/test-demo-notebook.js create mode 100644 tools/test-extension-e2e.js create mode 100644 vscode-frysharp/.gitignore create mode 100644 vscode-frysharp/.vscodeignore create mode 100644 vscode-frysharp/LICENSE create mode 100644 vscode-frysharp/README.md create mode 100644 vscode-frysharp/package-lock.json create mode 100644 vscode-frysharp/package.json create mode 100644 vscode-frysharp/src/extension.ts create mode 100644 vscode-frysharp/src/notebook/notebookController.ts create mode 100644 vscode-frysharp/src/notebook/notebookSerializer.ts create mode 100644 vscode-frysharp/src/notebook/variablesView.ts create mode 100644 vscode-frysharp/src/sidecar/protocol.ts create mode 100644 vscode-frysharp/src/sidecar/sidecarClient.ts create mode 100644 vscode-frysharp/tsconfig.json diff --git a/.gitignore b/.gitignore index 97f02e55..456f414e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,8 @@ Desktop.ini *.log .cache/ *.metadata.json + +## FrySharp web mode +# Never commit a server token (older builds wrote one into the workspace). +.frysharp-token +web/node_modules/ diff --git a/CSharpEditorPlugin.cs b/CSharpEditorPlugin.cs index 453c7b5c..cd55f0ce 100644 --- a/CSharpEditorPlugin.cs +++ b/CSharpEditorPlugin.cs @@ -74,6 +74,9 @@ public class CSharpEditorPlugin : IFryPlugin public Task ApplyAsync(IFryPluginContext ctx, CancellationToken ct = default) { + // Scripts compile against the desktop API (Display, charts, Avalonia), so fix that before anything compiles. + PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn.DesktopScriptHost.Configure(); + CSharpStudioHostViewModel? activeHost = null; void NavigateToStudio(IServiceProvider sp) diff --git a/CSharpEditorPlugin.csproj b/CSharpEditorPlugin.csproj index 6949f936..c27fcf3c 100644 --- a/CSharpEditorPlugin.csproj +++ b/CSharpEditorPlugin.csproj @@ -16,7 +16,7 @@ 2.0.4 Code Fry Dev Full-featured interactive FrySharp Script Automation and Polyglot Workspace for FryPDF - $(DefaultItemExcludes);Runner/**;Tests/**;tools/**;samples/** + $(DefaultItemExcludes);Runner/**;Tests/**;tools/**;samples/**;FrySharp.Core/**;FrySharp.Sidecar/**;FrySharp.Cli/**;Tests.Core/**;Tests.Sidecar/**;vscode-frysharp/** false true @@ -26,6 +26,10 @@ + + + + @@ -58,17 +62,7 @@ - - - - - - - - - - - + @@ -97,6 +91,8 @@ + + diff --git a/CSharpEditorPlugin.slnx b/CSharpEditorPlugin.slnx index b1c4777e..a7859404 100644 --- a/CSharpEditorPlugin.slnx +++ b/CSharpEditorPlugin.slnx @@ -1,6 +1,11 @@ + + + + + diff --git a/Charting/Controls/ChartCanvasControl.cs b/Charting/Controls/ChartCanvasControl.cs index ef47cb29..f18c0604 100644 --- a/Charting/Controls/ChartCanvasControl.cs +++ b/Charting/Controls/ChartCanvasControl.cs @@ -202,7 +202,7 @@ private void RenderHoverTooltip(DrawingContext context, ChartHitTestResult hit) var color = ChartPaletteService.ParseColor( !string.IsNullOrEmpty(hit.Point.CustomColor) ? hit.Point.CustomColor - : (!string.IsNullOrEmpty(hit.Series.Color) ? hit.Series.Color : Options?.PrimaryColor ?? "#4ec9b0")); + : (!string.IsNullOrEmpty(hit.Series.Color) ? hit.Series.Color : ChartPaletteService.GetSeriesColor(Options?.Series.IndexOf(hit.Series) ?? 0))); var haloBrush = new SolidColorBrush(Color.FromArgb(50, color.R, color.G, color.B)); var dotBrush = new SolidColorBrush(color); diff --git a/Charting/Services/ChartPaletteService.cs b/Charting/Services/ChartPaletteService.cs index 6d1390fe..a3fcb2a6 100644 --- a/Charting/Services/ChartPaletteService.cs +++ b/Charting/Services/ChartPaletteService.cs @@ -4,28 +4,9 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Charting.Services; +/// Chart colours as Avalonia brushes, and series colours from the active desktop theme ( otherwise). public static class ChartPaletteService { - public static readonly string DefaultCyan = "#4ec9b0"; - public static readonly string AccentAmber = "#f59e0b"; - public static readonly string AccentIndigo = "#6366f1"; - public static readonly string AccentEmerald = "#10b981"; - public static readonly string AccentRose = "#f43f5e"; - public static readonly string AccentSky = "#0ea5e9"; - public static readonly string AccentPurple = "#a855f7"; - - public static readonly IReadOnlyList ExpressivePalette = new[] - { - "#4ec9b0", // Cyan / Teal - "#60a5fa", // Blue - "#f59e0b", // Amber - "#f43f5e", // Rose - "#10b981", // Emerald - "#a855f7", // Purple - "#38bdf8", // Sky - "#fb923c" // Orange - }; - public static IBrush GetBrush(string? colorHexOrName, double opacity = 1.0) { var color = ParseColor(colorHexOrName); @@ -39,7 +20,7 @@ public static IBrush GetBrush(string? colorHexOrName, double opacity = 1.0) public static Color ParseColor(string? colorHexOrName) { if (string.IsNullOrWhiteSpace(colorHexOrName)) - return Color.Parse(DefaultCyan); + return Color.Parse(ChartPalette.DefaultCyan); try { @@ -50,7 +31,7 @@ public static Color ParseColor(string? colorHexOrName) } catch { - return Color.Parse(DefaultCyan); + return Color.Parse(ChartPalette.DefaultCyan); } } @@ -68,7 +49,7 @@ public static string GetSeriesColor(int seriesIndex) return themed; } - return ExpressivePalette[seriesIndex % ExpressivePalette.Count]; + return ChartPalette.DefaultSeriesColor(seriesIndex); } private const int ThemeSeriesCount = 8; diff --git a/Charting3D/Services/ColorMapService.cs b/Charting3D/Services/ColorMapService.cs index 3cc27986..c69dcb25 100644 --- a/Charting3D/Services/ColorMapService.cs +++ b/Charting3D/Services/ColorMapService.cs @@ -1,106 +1,16 @@ -using System; -using System.Linq; using Avalonia.Media; using PdfEditorApp.Plugins.CSharpEditor.Charting3D.Models; namespace PdfEditorApp.Plugins.CSharpEditor.Charting3D.Services; +/// The colour maps of as Avalonia colours, for the desktop's 3D renderers. public static class ColorMapService { - public static Color GetColor(ColorMapPreset preset, double t) - { - t = Math.Clamp(t, 0.0, 1.0); - return preset switch - { - ColorMapPreset.Plasma => SamplePlasma(t), - ColorMapPreset.CoolWarm => SampleCoolWarm(t), - ColorMapPreset.Turbo => SampleTurbo(t), - ColorMapPreset.Rainbow => SampleRainbow(t), - ColorMapPreset.Ocean => SampleOcean(t), - ColorMapPreset.Fire => SampleFire(t), - _ => SampleViridis(t) - }; - } + public static Color GetColor(ColorMapPreset preset, double t) => ToColor(ColorMaps.Sample(preset, t)); - public static string GetHexColor(ColorMapPreset preset, double t) - { - var c = GetColor(preset, t); - return $"#{c.R:X2}{c.G:X2}{c.B:X2}"; - } + public static string GetHexColor(ColorMapPreset preset, double t) => ColorMaps.GetHexColor(preset, t); - // The published palettes, as evenly spaced stops (matplotlib's viridis and plasma, Google's Turbo, Moreland's - // cool-warm), read between them in a straight line: close to the originals, which are tables of 256. - private static readonly Color[] ViridisStops = Stops("#440154", "#482878", "#3E4A89", "#31688E", "#26828E", "#1F9E89", "#35B779", "#6DCD59", "#B4DE2C", "#FDE725"); - private static readonly Color[] PlasmaStops = Stops("#0D0887", "#46039F", "#7201A8", "#9C179E", "#BD3786", "#D8576B", "#ED7953", "#FB9F3A", "#FDCA26", "#F0F921"); - private static readonly Color[] TurboStops = Stops("#30123B", "#4662D7", "#36AAF9", "#1AE4B6", "#72FE5E", "#C8EF34", "#FABA39", "#F66B19", "#CA2A04", "#7A0403"); - private static readonly Color[] CoolWarmStops = Stops("#3B4CC0", "#6788EE", "#9ABBFF", "#C9D7F0", "#EDD1C2", "#F7A889", "#E26952", "#B40426"); + public static Color HsvToRgb(double hue, double saturation, double value) => ToColor(ColorMaps.HsvToRgb(hue, saturation, value)); - private static Color SampleViridis(double t) => Between(ViridisStops, t); - - private static Color SamplePlasma(double t) => Between(PlasmaStops, t); - - private static Color SampleCoolWarm(double t) => Between(CoolWarmStops, t); - - private static Color SampleTurbo(double t) => Between(TurboStops, t); - - private static Color[] Stops(params string[] hex) => hex.Select(Color.Parse).ToArray(); - - private static Color Between(Color[] stops, double t) - { - var at = t * (stops.Length - 1); - var i = Math.Min((int)at, stops.Length - 2); - var f = at - i; - var (a, b) = (stops[i], stops[i + 1]); - return Color.FromRgb( - (byte)Math.Round(a.R + (b.R - a.R) * f), - (byte)Math.Round(a.G + (b.G - a.G) * f), - (byte)Math.Round(a.B + (b.B - a.B) * f)); - } - - // Rainbow - private static Color SampleRainbow(double t) - { - double h = (1.0 - t) * 240.0; // Blue to Red - return HsvToRgb(h, 0.9, 0.95); - } - - // Ocean: Deep Navy -> Turquoise -> Pale Cyan - private static Color SampleOcean(double t) - { - byte r = (byte)Math.Clamp(10 + 120 * t, 0, 255); - byte g = (byte)Math.Clamp(40 + 200 * t, 0, 255); - byte b = (byte)Math.Clamp(90 + 165 * t, 0, 255); - return Color.FromRgb(r, g, b); - } - - // Fire: Black -> Red -> Orange -> Yellow -> White - private static Color SampleFire(double t) - { - byte r = (byte)Math.Clamp(t * 300, 0, 255); - byte g = (byte)Math.Clamp(t > 0.35 ? (t - 0.35) * 350 : 0, 0, 255); - byte b = (byte)Math.Clamp(t > 0.75 ? (t - 0.75) * 800 : 0, 0, 255); - return Color.FromRgb(r, g, b); - } - - public static Color HsvToRgb(double hue, double saturation, double value) - { - int hi = Convert.ToInt32(Math.Floor(hue / 60)) % 6; - double f = hue / 60 - Math.Floor(hue / 60); - - value *= 255; - byte v = (byte)Math.Clamp(value, 0, 255); - byte p = (byte)Math.Clamp(value * (1 - saturation), 0, 255); - byte q = (byte)Math.Clamp(value * (1 - f * saturation), 0, 255); - byte t = (byte)Math.Clamp(value * (1 - (1 - f) * saturation), 0, 255); - - return hi switch - { - 0 => Color.FromRgb(v, t, p), - 1 => Color.FromRgb(q, v, p), - 2 => Color.FromRgb(p, v, t), - 3 => Color.FromRgb(p, q, v), - 4 => Color.FromRgb(t, p, v), - _ => Color.FromRgb(v, p, q) - }; - } + private static Color ToColor(RgbColor c) => Color.FromRgb(c.R, c.G, c.B); } diff --git a/Controls/Activities/ActivityProgressLine.cs b/Controls/Activities/ActivityProgressLine.cs index 499934a9..abb09f72 100644 --- a/Controls/Activities/ActivityProgressLine.cs +++ b/Controls/Activities/ActivityProgressLine.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.Animation; using Avalonia.Controls; @@ -129,10 +130,16 @@ private void UpdateAnimation() double segment = width * SegmentShare; // A still frame (a snapshot, a paused clock) shows the segment on screen rather than parked off the left edge. _slide.X = width * 0.2; + _animation = new CancellationTokenSource(); + StartAnimationLoop(_segment, width, segment, _animation.Token); + } + + private async void StartAnimationLoop(Border segmentBorder, double width, double segment, CancellationToken token) + { var animation = new Animation { Duration = TimeSpan.FromSeconds(1.4), - IterationCount = IterationCount.Infinite, + IterationCount = new IterationCount(1), Easing = new Avalonia.Animation.Easings.QuadraticEaseInOut(), Children = { @@ -140,9 +147,19 @@ private void UpdateAnimation() new KeyFrame { Cue = new Cue(1d), Setters = { new Setter(TranslateTransform.XProperty, width) } }, }, }; - _animation = new CancellationTokenSource(); - // Transform animations run on the control and drive its RenderTransform. - _ = animation.RunAsync(_segment, _animation.Token); + + try + { + while (!token.IsCancellationRequested) + { + await animation.RunAsync(segmentBorder, token); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[ActivityProgressLine] Animation loop stopped: {ex.Message}"); + } } private void StopAnimation() diff --git a/Controls/Activities/ActivityStatusItem.cs b/Controls/Activities/ActivityStatusItem.cs index 5d8dbd9f..3aede016 100644 --- a/Controls/Activities/ActivityStatusItem.cs +++ b/Controls/Activities/ActivityStatusItem.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Threading; +using System.Threading.Tasks; using Avalonia; using Avalonia.Animation; using Avalonia.Controls; @@ -120,18 +121,34 @@ private void UpdateAnimation() } if (_animation != null) return; + _animation = new CancellationTokenSource(); + StartAnimationLoop(_animation.Token); + } + + private async void StartAnimationLoop(CancellationToken token) + { var animation = new Animation { Duration = TimeSpan.FromSeconds(1), - IterationCount = IterationCount.Infinite, + IterationCount = new IterationCount(1), Children = { new KeyFrame { Cue = new Cue(0d), Setters = { new Setter(RotateTransform.AngleProperty, 0d) } }, new KeyFrame { Cue = new Cue(1d), Setters = { new Setter(RotateTransform.AngleProperty, 360d) } }, }, }; - _animation = new CancellationTokenSource(); - // Transform animations run on the control and drive its RenderTransform. - _ = animation.RunAsync(_ring, _animation.Token); + + try + { + while (!token.IsCancellationRequested) + { + await animation.RunAsync(_ring, token); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[ActivityStatusItem] Animation loop stopped: {ex.Message}"); + } } } diff --git a/Controls/Common/ImageBytesToBitmapConverter.cs b/Controls/Common/ImageBytesToBitmapConverter.cs new file mode 100644 index 00000000..0a0e2a39 --- /dev/null +++ b/Controls/Common/ImageBytesToBitmapConverter.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using Avalonia.Data.Converters; +using Avalonia.Media.Imaging; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +namespace PdfEditorApp.Plugins.CSharpEditor.Controls.Common; + +/// +/// Shows a script's image output () as a bitmap. Each byte array is decoded once, +/// at most across, and the bitmap is kept for as long as the bytes live: a +/// template's binding may be evaluated again and again. Bytes that aren't an image show nothing. +/// +/// +/// +/// <Image Source="{Binding ImageBytes, Converter={x:Static common:ImageBytesToBitmapConverter.Instance}}" /> +/// +/// +public sealed class ImageBytesToBitmapConverter : IValueConverter +{ + public static readonly ImageBytesToBitmapConverter Instance = new(); + + // Keyed by the array itself: an output's bytes never change after it is emitted. + private static readonly ConditionalWeakTable Cache = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not byte[] { Length: > 0 } bytes) return null; + return Cache.GetValue(bytes, static b => new Decoded(TryDecode(b))).Bitmap; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); + + private static Bitmap? TryDecode(byte[] bytes) + { + try + { + return ImageDecoder.Decode(bytes); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or NotSupportedException) + { + return null; + } + } + + // A failed decode is remembered too, so it is not retried on every evaluation. + private sealed record Decoded(Bitmap? Bitmap); +} diff --git a/Controls/Docs/SnippetOutputControl.axaml b/Controls/Docs/SnippetOutputControl.axaml index 2f8daa04..d96c505f 100644 --- a/Controls/Docs/SnippetOutputControl.axaml +++ b/Controls/Docs/SnippetOutputControl.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:docs="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.ViewModels.Docs" - xmlns:display="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Services.Display" + xmlns:display="using:PdfEditorApp.Plugins.CSharpEditor.Services.Display" xmlns:views="using:PdfEditorApp.Plugins.CSharpEditor.Views" xmlns:visuals="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Controls.Visuals" xmlns:common="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Controls.Common" @@ -68,7 +68,7 @@ - + diff --git a/Controls/Editor/DebugHoverDataTipControl.axaml b/Controls/Editor/DebugHoverDataTipControl.axaml index 98904728..597c3615 100644 --- a/Controls/Editor/DebugHoverDataTipControl.axaml +++ b/Controls/Editor/DebugHoverDataTipControl.axaml @@ -1,7 +1,7 @@ diff --git a/Controls/Overlays/QuickOpenOverlayControl.axaml b/Controls/Overlays/QuickOpenOverlayControl.axaml index c1f94fbc..f66fd2e7 100644 --- a/Controls/Overlays/QuickOpenOverlayControl.axaml +++ b/Controls/Overlays/QuickOpenOverlayControl.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:vm="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.ViewModels" - xmlns:models="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Models" + xmlns:models="using:PdfEditorApp.Plugins.CSharpEditor.Models" xmlns:common="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600" x:Class="PdfEditorApp.Plugins.CSharpEditor.Controls.Overlays.QuickOpenOverlayControl" diff --git a/Controls/Studio/StudioBottomDeckControl.axaml b/Controls/Studio/StudioBottomDeckControl.axaml index bf994891..08c493fb 100644 --- a/Controls/Studio/StudioBottomDeckControl.axaml +++ b/Controls/Studio/StudioBottomDeckControl.axaml @@ -9,7 +9,7 @@ xmlns:vm="using:PdfEditorApp.Plugins.CSharpEditor.ViewModels" xmlns:controls="using:PdfEditorApp.Plugins.CSharpEditor.Controls" xmlns:common="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Controls.Common" - xmlns:display="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Services.Display" + xmlns:display="using:PdfEditorApp.Plugins.CSharpEditor.Services.Display" xmlns:visuals="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Controls.Visuals" xmlns:vm1="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="300" @@ -146,7 +146,7 @@ - + diff --git a/Controls/Studio/StudioBranchPickerControl.axaml b/Controls/Studio/StudioBranchPickerControl.axaml index c578b7a8..f6031aed 100644 --- a/Controls/Studio/StudioBranchPickerControl.axaml +++ b/Controls/Studio/StudioBranchPickerControl.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:vm="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio;assembly=CSharpEditorPlugin" - xmlns:git="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Models.Git;assembly=CSharpEditorPlugin" + xmlns:git="using:PdfEditorApp.Plugins.CSharpEditor.Models.Git" mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="360" x:Class="PdfEditorApp.Plugins.CSharpEditor.Controls.Studio.StudioBranchPickerControl" x:DataType="vm:CSharpCodeStudioViewModel" diff --git a/Controls/Studio/StudioDiffViewerControl.axaml b/Controls/Studio/StudioDiffViewerControl.axaml index d5607567..dcbe293b 100644 --- a/Controls/Studio/StudioDiffViewerControl.axaml +++ b/Controls/Studio/StudioDiffViewerControl.axaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:vm="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio;assembly=CSharpEditorPlugin" - xmlns:git="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Models.Git;assembly=CSharpEditorPlugin" + xmlns:git="using:PdfEditorApp.Plugins.CSharpEditor.Models.Git" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600" x:Class="PdfEditorApp.Plugins.CSharpEditor.Controls.Studio.StudioDiffViewerControl" x:DataType="vm:CSharpCodeStudioViewModel" diff --git a/Controls/Studio/StudioLoadingOverlayControl.axaml.cs b/Controls/Studio/StudioLoadingOverlayControl.axaml.cs index 38123160..a7a4f003 100644 --- a/Controls/Studio/StudioLoadingOverlayControl.axaml.cs +++ b/Controls/Studio/StudioLoadingOverlayControl.axaml.cs @@ -148,7 +148,7 @@ private void UpdateAnimations() var token = _animations.Token; foreach (var (animation, target) in BuildAnimations()) { - _ = animation.RunAsync(target, token); + RunSingleAnimationLoop(animation, target, token); } } @@ -161,9 +161,25 @@ private void StopAnimations() StepAnimation(TimeSpan.Zero); } + private async void RunSingleAnimationLoop(Animation animation, Animatable target, CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + await animation.RunAsync(target, token); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[StudioLoadingOverlay] Animation loop stopped: {ex.Message}"); + } + } + private static Animation Loop(double seconds, AvaloniaProperty property, params (double Cue, object Value)[] frames) { - var animation = new Animation { Duration = TimeSpan.FromSeconds(seconds), IterationCount = IterationCount.Infinite }; + var animation = new Animation { Duration = TimeSpan.FromSeconds(seconds), IterationCount = new IterationCount(1) }; foreach (var (cue, value) in frames) { animation.Children.Add(new KeyFrame { Cue = new Cue(cue), Setters = { new Setter(property, value) } }); diff --git a/Directory.Build.props b/Directory.Build.props index 0a323291..21612273 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,6 +13,15 @@ README.md + + + net10.0 + enable + enable + 13 + true + + diff --git a/FrySharp.Cli/FrySharp.Cli.csproj b/FrySharp.Cli/FrySharp.Cli.csproj new file mode 100644 index 00000000..a9f453b1 --- /dev/null +++ b/FrySharp.Cli/FrySharp.Cli.csproj @@ -0,0 +1,18 @@ + + + Exe + frysharp + FrySharp.Cli + CodeFryDev.FrySharp.Cli + 1.0.0 + FrySharp from the command line: run scripts, and run the sidecar for the VS Code extension + true + frysharp + + + + + + + + diff --git a/FrySharp.Cli/Program.cs b/FrySharp.Cli/Program.cs new file mode 100644 index 00000000..7556f2fb --- /dev/null +++ b/FrySharp.Cli/Program.cs @@ -0,0 +1,155 @@ +using System.CommandLine; +using System.Runtime.InteropServices; +using FrySharp.Sidecar.Hosting; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; + +namespace FrySharp.Cli; + +/// frysharp: run the VS Code sidecar, run a script, or show what the tool finds on this computer. +public static class Program +{ + public static Task Main(string[] args) => BuildCommand().Parse(args).InvokeAsync(); + + public static RootCommand BuildCommand() + { + var root = new RootCommand("FrySharp: polyglot notebook and Roslyn scripting engine."); + root.Subcommands.Add(SidecarCommand()); + root.Subcommands.Add(RunCommand()); + root.Subcommands.Add(InfoCommand()); + return root; + } + + private static Command SidecarCommand() + { + var workspace = new Option("--workspace", "-w") + { + Description = "Folder the sidecar operates on (default: current directory).", + DefaultValueFactory = _ => new DirectoryInfo(Environment.CurrentDirectory), + }; + + var command = new Command("sidecar", "Run the JSON-RPC stdio sidecar for the VS Code extension.") { workspace }; + command.SetAction(async (parse, ct) => + { + var workspaceDir = parse.GetValue(workspace)!.FullName; + await FrySharp.Sidecar.Program.RunSidecarAsync(workspaceDir, ct).ConfigureAwait(false); + return 0; + }); + return command; + } + + private static Command RunCommand() + { + var file = new Argument("file") { Description = "The script to run: .cs, .csx, .py or .js." }; + var arguments = new Argument("arguments") { Description = "Arguments for the script (after --).", Arity = ArgumentArity.ZeroOrMore }; + file.Validators.Add(result => + { + if (result.GetValueOrDefault() is { Exists: false } missing) result.AddError($"'{missing.FullName}' doesn't exist."); + }); + + var command = new Command("run", "Run a script here, the way the studio's Run does.") { file, arguments }; + command.SetAction(async (parse, ct) => + { + ScriptHost.Configure(ScriptHostProfile.Headless); + var output = parse.InvocationConfiguration.Output; + var error = parse.InvocationConfiguration.Error; + var path = parse.GetValue(file)!.FullName; + var engines = LanguageEngineServices.Create(SidecarOptions.DefaultDataDirectory()); + var languages = HeadlessLanguages.Create(engines); + var runner = new FileRunner(languages, new RoslynCompilerService(), engines.Processes); + if (runner.LanguageOf(path) is not { } language || !FileRunner.CanRun(language)) + { + await error.WriteLineAsync($"{Path.GetFileName(path)} isn't a script frysharp runs (.cs, .csx, .py, .js)."); + return 2; + } + + ScriptRunSession? process = null; + using var input = new CancellationTokenSource(); + var options = new FileRunOptions + { + OnOutput = output.Write, + OnDisplay = display => output.WriteLine(Describe(display)), + CSharpInput = Console.In, + OnProcessStarted = started => + { + process = started; + _ = ForwardInputAsync(started, input.Token); + }, + Arguments = parse.GetValue(arguments) ?? [], + WorkspaceRoot = Environment.CurrentDirectory, + }; + + try + { + var result = await runner.RunFileAsync(path, language, options, ct); + foreach (var problem in result.Problems) + { + await error.WriteLineAsync($"{Path.GetFileName(path)}({problem.Line},{problem.Column}): {problem.Severity.ToString().ToLowerInvariant()} {problem.Id}: {problem.Message}"); + } + return result.ExitCode; + } + finally + { + await input.CancelAsync(); + process?.Stop(); + } + }); + return command; + } + + private static Command InfoCommand() + { + var command = new Command("info", "Show the .NET runtime and the languages frysharp finds on this computer."); + command.SetAction(async (parse, ct) => + { + var output = parse.InvocationConfiguration.Output; + await output.WriteLineAsync($"frysharp {typeof(Program).Assembly.GetName().Version?.ToString(3)}"); + await output.WriteLineAsync($".NET {Environment.Version} ({RuntimeInformation.RuntimeIdentifier})"); + await output.WriteLineAsync($"OS {RuntimeInformation.OSDescription}"); + + var languages = HeadlessLanguages.Create(LanguageEngineServices.Create(SidecarOptions.DefaultDataDirectory())); + foreach (var language in languages.All) + { + if (language.Toolchain is not { } toolchains) + { + await output.WriteLineAsync($"{language.DisplayName,-13} {language.RuntimeDescription}"); + continue; + } + + var found = await toolchains.ResolveAsync(new PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains.ToolchainQuery(Environment.CurrentDirectory), ct); + await output.WriteLineAsync(found.Toolchain is { } toolchain + ? $"{language.DisplayName,-13} {toolchain.Version} ({toolchain.ExecutablePath})" + : $"{language.DisplayName,-13} not found"); + } + return 0; + }); + return command; + } + + // A program of another language reads its input from what is typed here, line by line. + private static async Task ForwardInputAsync(ScriptRunSession process, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && await Console.In.ReadLineAsync(ct) is { } line) + { + if (process.AcceptsInput) await process.SendInputAsync(line + "\n"); + } + } + catch (OperationCanceledException) + { + // The program ended. + } + } + + // A terminal shows Markdown as its text; for a table or a chart, it says what was shown. + private static string Describe(RichCellOutput output) + { + var bundle = RichOutputMime.ToBundle(output, out _); + if (bundle?["text/markdown"]?.GetValue() is { } markdown) return markdown; + return bundle?["text/plain"]?.GetValue() is { Length: > 0 } text ? $"[{text}]" : "[display]"; + } +} diff --git a/FrySharp.Core/AssemblyInfo.cs b/FrySharp.Core/AssemblyInfo.cs new file mode 100644 index 00000000..4c78d90c --- /dev/null +++ b/FrySharp.Core/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSharpEditorPlugin")] +[assembly: InternalsVisibleTo("CSharpEditorPlugin.Tests")] diff --git a/Assets/echarts/echarts-gl.min.js b/FrySharp.Core/Assets/echarts/echarts-gl.min.js similarity index 100% rename from Assets/echarts/echarts-gl.min.js rename to FrySharp.Core/Assets/echarts/echarts-gl.min.js diff --git a/Assets/echarts/echarts.min.js b/FrySharp.Core/Assets/echarts/echarts.min.js similarity index 100% rename from Assets/echarts/echarts.min.js rename to FrySharp.Core/Assets/echarts/echarts.min.js diff --git a/Charting/Models/ChartAxisOptions.cs b/FrySharp.Core/Charting/Models/ChartAxisOptions.cs similarity index 100% rename from Charting/Models/ChartAxisOptions.cs rename to FrySharp.Core/Charting/Models/ChartAxisOptions.cs diff --git a/Charting/Models/ChartDataPoint.cs b/FrySharp.Core/Charting/Models/ChartDataPoint.cs similarity index 100% rename from Charting/Models/ChartDataPoint.cs rename to FrySharp.Core/Charting/Models/ChartDataPoint.cs diff --git a/Charting/Models/ChartOptions.cs b/FrySharp.Core/Charting/Models/ChartOptions.cs similarity index 100% rename from Charting/Models/ChartOptions.cs rename to FrySharp.Core/Charting/Models/ChartOptions.cs diff --git a/Charting/Models/ChartSeries.cs b/FrySharp.Core/Charting/Models/ChartSeries.cs similarity index 100% rename from Charting/Models/ChartSeries.cs rename to FrySharp.Core/Charting/Models/ChartSeries.cs diff --git a/Charting/Models/ChartStatistics.cs b/FrySharp.Core/Charting/Models/ChartStatistics.cs similarity index 100% rename from Charting/Models/ChartStatistics.cs rename to FrySharp.Core/Charting/Models/ChartStatistics.cs diff --git a/Charting/Models/ChartViewState.cs b/FrySharp.Core/Charting/Models/ChartViewState.cs similarity index 100% rename from Charting/Models/ChartViewState.cs rename to FrySharp.Core/Charting/Models/ChartViewState.cs diff --git a/Charting/Services/ChartDataParser.cs b/FrySharp.Core/Charting/Services/ChartDataParser.cs similarity index 100% rename from Charting/Services/ChartDataParser.cs rename to FrySharp.Core/Charting/Services/ChartDataParser.cs diff --git a/FrySharp.Core/Charting/Services/ChartPalette.cs b/FrySharp.Core/Charting/Services/ChartPalette.cs new file mode 100644 index 00000000..7e135ea0 --- /dev/null +++ b/FrySharp.Core/Charting/Services/ChartPalette.cs @@ -0,0 +1,28 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Charting.Services; + +/// +/// The chart colours every host starts from. A series without its own colour is drawn in the theme's colour for its +/// index, which only the host's renderer knows (the desktop reads the active theme, the browser its CSS), so the render +/// model leaves it empty and these are the fallback. +/// +public static class ChartPalette +{ + public const string DefaultCyan = "#4ec9b0"; + + /// Series colours when the theme has none, in order. + public static IReadOnlyList ExpressivePalette { get; } = + [ + "#4ec9b0", // Cyan / Teal + "#60a5fa", // Blue + "#f59e0b", // Amber + "#f43f5e", // Rose + "#10b981", // Emerald + "#a855f7", // Purple + "#38bdf8", // Sky + "#fb923c" // Orange + ]; + + /// The fallback colour of series (0-based). + public static string DefaultSeriesColor(int seriesIndex) => + ExpressivePalette[Math.Max(seriesIndex, 0) % ExpressivePalette.Count]; +} diff --git a/Charting3D/Layouts/ForceDirected3DLayout.cs b/FrySharp.Core/Charting3D/Layouts/ForceDirected3DLayout.cs similarity index 100% rename from Charting3D/Layouts/ForceDirected3DLayout.cs rename to FrySharp.Core/Charting3D/Layouts/ForceDirected3DLayout.cs diff --git a/Charting3D/Models/Camera3D.cs b/FrySharp.Core/Charting3D/Models/Camera3D.cs similarity index 100% rename from Charting3D/Models/Camera3D.cs rename to FrySharp.Core/Charting3D/Models/Camera3D.cs diff --git a/Charting3D/Models/Graph3DData.cs b/FrySharp.Core/Charting3D/Models/Graph3DData.cs similarity index 100% rename from Charting3D/Models/Graph3DData.cs rename to FrySharp.Core/Charting3D/Models/Graph3DData.cs diff --git a/Charting3D/Models/Plot3DOptions.cs b/FrySharp.Core/Charting3D/Models/Plot3DOptions.cs similarity index 100% rename from Charting3D/Models/Plot3DOptions.cs rename to FrySharp.Core/Charting3D/Models/Plot3DOptions.cs diff --git a/Charting3D/Models/Plot3DViewState.cs b/FrySharp.Core/Charting3D/Models/Plot3DViewState.cs similarity index 100% rename from Charting3D/Models/Plot3DViewState.cs rename to FrySharp.Core/Charting3D/Models/Plot3DViewState.cs diff --git a/Charting3D/Models/Point3D.cs b/FrySharp.Core/Charting3D/Models/Point3D.cs similarity index 100% rename from Charting3D/Models/Point3D.cs rename to FrySharp.Core/Charting3D/Models/Point3D.cs diff --git a/Charting3D/Models/Series3D.cs b/FrySharp.Core/Charting3D/Models/Series3D.cs similarity index 100% rename from Charting3D/Models/Series3D.cs rename to FrySharp.Core/Charting3D/Models/Series3D.cs diff --git a/Charting3D/Models/Surface3DData.cs b/FrySharp.Core/Charting3D/Models/Surface3DData.cs similarity index 100% rename from Charting3D/Models/Surface3DData.cs rename to FrySharp.Core/Charting3D/Models/Surface3DData.cs diff --git a/FrySharp.Core/Charting3D/Services/ColorMaps.cs b/FrySharp.Core/Charting3D/Services/ColorMaps.cs new file mode 100644 index 00000000..db04b831 --- /dev/null +++ b/FrySharp.Core/Charting3D/Services/ColorMaps.cs @@ -0,0 +1,98 @@ +using PdfEditorApp.Plugins.CSharpEditor.Charting3D.Models; + +namespace PdfEditorApp.Plugins.CSharpEditor.Charting3D.Services; + +/// An opaque colour as three bytes. +public readonly record struct RgbColor(byte R, byte G, byte B) +{ + public string ToHex() => $"#{R:X2}{G:X2}{B:X2}"; + + public static RgbColor FromHex(string hex) + { + var s = hex.AsSpan().TrimStart('#'); + if (s.Length != 6) throw new FormatException($"'{hex}' is not a #rrggbb colour."); + return new RgbColor( + byte.Parse(s[..2], System.Globalization.NumberStyles.HexNumber), + byte.Parse(s[2..4], System.Globalization.NumberStyles.HexNumber), + byte.Parse(s[4..], System.Globalization.NumberStyles.HexNumber)); + } +} + +/// The colour maps of 3D plots and grids: a value from 0 to 1 gives a colour, the same in every host. +public static class ColorMaps +{ + public static RgbColor Sample(ColorMapPreset preset, double t) + { + t = Math.Clamp(t, 0.0, 1.0); + return preset switch + { + ColorMapPreset.Plasma => Between(PlasmaStops, t), + ColorMapPreset.CoolWarm => Between(CoolWarmStops, t), + ColorMapPreset.Turbo => Between(TurboStops, t), + ColorMapPreset.Rainbow => SampleRainbow(t), + ColorMapPreset.Ocean => SampleOcean(t), + ColorMapPreset.Fire => SampleFire(t), + _ => Between(ViridisStops, t) + }; + } + + public static string GetHexColor(ColorMapPreset preset, double t) => Sample(preset, t).ToHex(); + + // The published palettes, as evenly spaced stops (matplotlib's viridis and plasma, Google's Turbo, Moreland's + // cool-warm), read between them in a straight line: close to the originals, which are tables of 256. + private static readonly RgbColor[] ViridisStops = Stops("#440154", "#482878", "#3E4A89", "#31688E", "#26828E", "#1F9E89", "#35B779", "#6DCD59", "#B4DE2C", "#FDE725"); + private static readonly RgbColor[] PlasmaStops = Stops("#0D0887", "#46039F", "#7201A8", "#9C179E", "#BD3786", "#D8576B", "#ED7953", "#FB9F3A", "#FDCA26", "#F0F921"); + private static readonly RgbColor[] TurboStops = Stops("#30123B", "#4662D7", "#36AAF9", "#1AE4B6", "#72FE5E", "#C8EF34", "#FABA39", "#F66B19", "#CA2A04", "#7A0403"); + private static readonly RgbColor[] CoolWarmStops = Stops("#3B4CC0", "#6788EE", "#9ABBFF", "#C9D7F0", "#EDD1C2", "#F7A889", "#E26952", "#B40426"); + + private static RgbColor[] Stops(params string[] hex) => hex.Select(RgbColor.FromHex).ToArray(); + + private static RgbColor Between(RgbColor[] stops, double t) + { + var at = t * (stops.Length - 1); + var i = Math.Min((int)at, stops.Length - 2); + var f = at - i; + var (a, b) = (stops[i], stops[i + 1]); + return new RgbColor( + (byte)Math.Round(a.R + (b.R - a.R) * f), + (byte)Math.Round(a.G + (b.G - a.G) * f), + (byte)Math.Round(a.B + (b.B - a.B) * f)); + } + + // Rainbow: blue to red. + private static RgbColor SampleRainbow(double t) => HsvToRgb((1.0 - t) * 240.0, 0.9, 0.95); + + // Ocean: deep navy -> turquoise -> pale cyan. + private static RgbColor SampleOcean(double t) => new( + (byte)Math.Clamp(10 + 120 * t, 0, 255), + (byte)Math.Clamp(40 + 200 * t, 0, 255), + (byte)Math.Clamp(90 + 165 * t, 0, 255)); + + // Fire: black -> red -> orange -> yellow -> white. + private static RgbColor SampleFire(double t) => new( + (byte)Math.Clamp(t * 300, 0, 255), + (byte)Math.Clamp(t > 0.35 ? (t - 0.35) * 350 : 0, 0, 255), + (byte)Math.Clamp(t > 0.75 ? (t - 0.75) * 800 : 0, 0, 255)); + + public static RgbColor HsvToRgb(double hue, double saturation, double value) + { + int hi = Convert.ToInt32(Math.Floor(hue / 60)) % 6; + double f = hue / 60 - Math.Floor(hue / 60); + + value *= 255; + byte v = (byte)Math.Clamp(value, 0, 255); + byte p = (byte)Math.Clamp(value * (1 - saturation), 0, 255); + byte q = (byte)Math.Clamp(value * (1 - f * saturation), 0, 255); + byte t = (byte)Math.Clamp(value * (1 - (1 - f) * saturation), 0, 255); + + return hi switch + { + 0 => new RgbColor(v, t, p), + 1 => new RgbColor(q, v, p), + 2 => new RgbColor(p, v, t), + 3 => new RgbColor(p, q, v), + 4 => new RgbColor(t, p, v), + _ => new RgbColor(v, p, q) + }; + } +} diff --git a/Charting3D/Services/Plot3DDataParser.cs b/FrySharp.Core/Charting3D/Services/Plot3DDataParser.cs similarity index 100% rename from Charting3D/Services/Plot3DDataParser.cs rename to FrySharp.Core/Charting3D/Services/Plot3DDataParser.cs diff --git a/Charting3D/Services/Plot3DExportService.cs b/FrySharp.Core/Charting3D/Services/Plot3DExportService.cs similarity index 100% rename from Charting3D/Services/Plot3DExportService.cs rename to FrySharp.Core/Charting3D/Services/Plot3DExportService.cs diff --git a/Charting3D/Spatial/Matrix4x4D.cs b/FrySharp.Core/Charting3D/Spatial/Matrix4x4D.cs similarity index 100% rename from Charting3D/Spatial/Matrix4x4D.cs rename to FrySharp.Core/Charting3D/Spatial/Matrix4x4D.cs diff --git a/Charting3D/Spatial/Ray3D.cs b/FrySharp.Core/Charting3D/Spatial/Ray3D.cs similarity index 100% rename from Charting3D/Spatial/Ray3D.cs rename to FrySharp.Core/Charting3D/Spatial/Ray3D.cs diff --git a/Charting3D/Spatial/Vector3D.cs b/FrySharp.Core/Charting3D/Spatial/Vector3D.cs similarity index 100% rename from Charting3D/Spatial/Vector3D.cs rename to FrySharp.Core/Charting3D/Spatial/Vector3D.cs diff --git a/FrySharp.Core/FrySharp.Core.csproj b/FrySharp.Core/FrySharp.Core.csproj new file mode 100644 index 00000000..d6fbe404 --- /dev/null +++ b/FrySharp.Core/FrySharp.Core.csproj @@ -0,0 +1,47 @@ + + + FrySharp.Core + PdfEditorApp.Plugins.CSharpEditor + Headless execution, Roslyn scripting, workspace index, toolchains, kernels and visuals render models for FrySharp + + true + $(NoWarn);CS1591;CS1573 + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_FryUiPackageAsset Include="@(RuntimeCopyLocalItems)" Condition="$([System.String]::Copy('%(NuGetPackageId)').ToLowerInvariant().StartsWith('avalonia'))" /> + <_FryUiPackageAsset Include="@(ResolvedCompileFileDefinitions)" Condition="$([System.String]::Copy('%(NuGetPackageId)').ToLowerInvariant().StartsWith('avalonia'))" /> + + + + diff --git a/Models/AssemblyReferenceItem.cs b/FrySharp.Core/Models/AssemblyReferenceItem.cs similarity index 100% rename from Models/AssemblyReferenceItem.cs rename to FrySharp.Core/Models/AssemblyReferenceItem.cs diff --git a/Models/CSharpCompletionItem.cs b/FrySharp.Core/Models/CSharpCompletionItem.cs similarity index 100% rename from Models/CSharpCompletionItem.cs rename to FrySharp.Core/Models/CSharpCompletionItem.cs diff --git a/Models/DiagnosticItem.cs b/FrySharp.Core/Models/DiagnosticItem.cs similarity index 100% rename from Models/DiagnosticItem.cs rename to FrySharp.Core/Models/DiagnosticItem.cs diff --git a/Models/DumpTableResult.cs b/FrySharp.Core/Models/DumpTableResult.cs similarity index 91% rename from Models/DumpTableResult.cs rename to FrySharp.Core/Models/DumpTableResult.cs index cf7a1c48..2e9d76ff 100644 --- a/Models/DumpTableResult.cs +++ b/FrySharp.Core/Models/DumpTableResult.cs @@ -4,10 +4,9 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; -using Avalonia.Input.Platform; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common; +using PdfEditorApp.Plugins.CSharpEditor.Services.Common; namespace PdfEditorApp.Plugins.CSharpEditor.Models; @@ -35,8 +34,6 @@ public class DumpTableCell /// public bool IsNestedExpanded { get; set; } - public Avalonia.Media.TextAlignment Alignment => IsNumeric ? Avalonia.Media.TextAlignment.Right : Avalonia.Media.TextAlignment.Left; - public override string ToString() => DisplayText; } @@ -446,45 +443,4 @@ private static string EscapeCsv(string value) } return value; } - - [RelayCommand] - public async Task CopyTsvAsync() - { - await SetClipboardTextAsync(ToTsv()); - } - - [RelayCommand] - public async Task CopyCsvAsync() - { - await SetClipboardTextAsync(ToCsv()); - } - - [RelayCommand] - public async Task CopyJsonAsync() - { - await SetClipboardTextAsync(ToJson()); - } - - [RelayCommand] - public async Task CopyMarkdownAsync() - { - await SetClipboardTextAsync(ToMarkdown()); - } - - private static async Task SetClipboardTextAsync(string text) - { - try - { - if (Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop && - desktop.MainWindow != null) - { - var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(desktop.MainWindow); - if (topLevel?.Clipboard != null) - { - await topLevel.Clipboard.SetTextAsync(text); - } - } - } - catch { } - } } diff --git a/Models/Git/GitModels.cs b/FrySharp.Core/Models/Git/GitModels.cs similarity index 100% rename from Models/Git/GitModels.cs rename to FrySharp.Core/Models/Git/GitModels.cs diff --git a/Models/NotebookCellItem.cs b/FrySharp.Core/Models/NotebookCellItem.cs similarity index 66% rename from Models/NotebookCellItem.cs rename to FrySharp.Core/Models/NotebookCellItem.cs index f52cc736..15009cb6 100644 --- a/Models/NotebookCellItem.cs +++ b/FrySharp.Core/Models/NotebookCellItem.cs @@ -4,10 +4,35 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Models; +[JsonConverter(typeof(FlexibleCellTypeConverter))] public enum CellType { - Code, - Markdown + Code = 0, + Markdown = 1 +} + +public sealed class FlexibleCellTypeConverter : JsonConverter +{ + public override CellType Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + { + if (reader.TokenType == System.Text.Json.JsonTokenType.Number) + { + return reader.GetInt32() == 1 ? CellType.Markdown : CellType.Code; + } + if (reader.TokenType == System.Text.Json.JsonTokenType.String) + { + var str = reader.GetString(); + return string.Equals(str, "markdown", StringComparison.OrdinalIgnoreCase) || str == "1" + ? CellType.Markdown + : CellType.Code; + } + return CellType.Code; + } + + public override void Write(System.Text.Json.Utf8JsonWriter writer, CellType value, System.Text.Json.JsonSerializerOptions options) + { + writer.WriteNumberValue((int)value); + } } public class NotebookCellItem @@ -35,6 +60,10 @@ public class NotebookCellItem public int? ImageWidth { get; set; } public int? ImageHeight { get; set; } public string? HtmlContent { get; set; } + + /// For a Markdown output, the Markdown was rendered from (saved only when there is one). + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MarkdownContent { get; set; } public DumpTableSnapshot? TableSnapshot { get; set; } public ObjectInspectorSnapshot? InspectorSnapshot { get; set; } diff --git a/Models/NotebookDocumentItem.cs b/FrySharp.Core/Models/NotebookDocumentItem.cs similarity index 100% rename from Models/NotebookDocumentItem.cs rename to FrySharp.Core/Models/NotebookDocumentItem.cs diff --git a/Models/NotebookVariableInfo.cs b/FrySharp.Core/Models/NotebookVariableInfo.cs similarity index 100% rename from Models/NotebookVariableInfo.cs rename to FrySharp.Core/Models/NotebookVariableInfo.cs diff --git a/Models/ObjectInspectorNode.cs b/FrySharp.Core/Models/ObjectInspectorNode.cs similarity index 100% rename from Models/ObjectInspectorNode.cs rename to FrySharp.Core/Models/ObjectInspectorNode.cs diff --git a/Models/QuickInfoModels.cs b/FrySharp.Core/Models/QuickInfoModels.cs similarity index 100% rename from Models/QuickInfoModels.cs rename to FrySharp.Core/Models/QuickInfoModels.cs diff --git a/Models/RichOutputSnapshots.cs b/FrySharp.Core/Models/RichOutputSnapshots.cs similarity index 100% rename from Models/RichOutputSnapshots.cs rename to FrySharp.Core/Models/RichOutputSnapshots.cs diff --git a/Services/Display/InteractiveDisplayService.Chart.cs b/FrySharp.Core/Scripting/Display.Chart.cs similarity index 99% rename from Services/Display/InteractiveDisplayService.Chart.cs rename to FrySharp.Core/Scripting/Display.Chart.cs index 9d4d07a1..da0fe9c3 100644 --- a/Services/Display/InteractiveDisplayService.Chart.cs +++ b/FrySharp.Core/Scripting/Display.Chart.cs @@ -3,12 +3,13 @@ using PdfEditorApp.Plugins.CSharpEditor.Visuals.Building; using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; using PdfEditorApp.Plugins.CSharpEditor.Visuals.Output; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; +namespace PdfEditorApp.Plugins.CSharpEditor.Scripting; // Charts. Every helper takes the data first, then the title, then what is particular to it; settings left out are the // studio's defaults, the same in every language, and `configure` can change anything in the spec before it is shown. -public static partial class Display +public partial class Display { /// /// A chart of : numbers, [x, y] pairs, a label → number map, a name → sequence map (a series diff --git a/Services/Display/InteractiveDisplayService.EChart.cs b/FrySharp.Core/Scripting/Display.EChart.cs similarity index 84% rename from Services/Display/InteractiveDisplayService.EChart.cs rename to FrySharp.Core/Scripting/Display.EChart.cs index 0e837b0d..5bed6aae 100644 --- a/Services/Display/InteractiveDisplayService.EChart.cs +++ b/FrySharp.Core/Scripting/Display.EChart.cs @@ -1,6 +1,8 @@ -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; -public static partial class Display +namespace PdfEditorApp.Plugins.CSharpEditor.Scripting; + +public partial class Display { /// /// Shows as an interactive ECharts chart of , and returns it (it can be diff --git a/Services/Display/InteractiveDisplayService.Events.cs b/FrySharp.Core/Scripting/Display.Events.cs similarity index 86% rename from Services/Display/InteractiveDisplayService.Events.cs rename to FrySharp.Core/Scripting/Display.Events.cs index 24498517..b6f68fbe 100644 --- a/Services/Display/InteractiveDisplayService.Events.cs +++ b/FrySharp.Core/Scripting/Display.Events.cs @@ -1,10 +1,11 @@ using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; +namespace PdfEditorApp.Plugins.CSharpEditor.Scripting; // Events: a visual's callbacks (DisplayHandle.On, OnClick…) run when the kernel is free; running code that wants them // sooner, or wants to wait for them, says so here. -public static partial class Display +public partial class Display { /// Runs the callbacks whose events have come, here and now, and says how many ran. public static int ProcessEvents() => VisualEventLoop.Current?.RunPending() ?? 0; diff --git a/Services/Display/InteractiveDisplayService.Plot3D.cs b/FrySharp.Core/Scripting/Display.Plot3D.cs similarity index 98% rename from Services/Display/InteractiveDisplayService.Plot3D.cs rename to FrySharp.Core/Scripting/Display.Plot3D.cs index 8c3d8c58..3b81e388 100644 --- a/Services/Display/InteractiveDisplayService.Plot3D.cs +++ b/FrySharp.Core/Scripting/Display.Plot3D.cs @@ -3,11 +3,12 @@ using PdfEditorApp.Plugins.CSharpEditor.Charting3D.Models; using PdfEditorApp.Plugins.CSharpEditor.Visuals.Building; using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; +namespace PdfEditorApp.Plugins.CSharpEditor.Scripting; // 3D plots, z up for every kind. -public static partial class Display +public partial class Display { /// /// A 3D plot of : points ((x, y, z) tuples, three-number arrays, records with x, y and z, a name → diff --git a/Services/Display/InteractiveDisplayService.Visualizer.cs b/FrySharp.Core/Scripting/Display.Visualizer.cs similarity index 98% rename from Services/Display/InteractiveDisplayService.Visualizer.cs rename to FrySharp.Core/Scripting/Display.Visualizer.cs index bd36de43..2dce873e 100644 --- a/Services/Display/InteractiveDisplayService.Visualizer.cs +++ b/FrySharp.Core/Scripting/Display.Visualizer.cs @@ -4,12 +4,13 @@ using PdfEditorApp.Plugins.CSharpEditor.Visualizers.Services; using PdfEditorApp.Plugins.CSharpEditor.Visuals.Building; using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; +namespace PdfEditorApp.Plugins.CSharpEditor.Scripting; // Data-structure visualizers: one helper per kind, plus Visualize (which works out the kind) and the recorder and // trackers that play an algorithm step by step. -public static partial class Display +public partial class Display { /// /// A visualizer for whatever is: a 2D grid (a matrix), a node with next (a linked list) or with diff --git a/FrySharp.Core/Scripting/Display.cs b/FrySharp.Core/Scripting/Display.cs new file mode 100644 index 00000000..5f99d7e2 --- /dev/null +++ b/FrySharp.Core/Scripting/Display.cs @@ -0,0 +1,170 @@ +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; + +namespace PdfEditorApp.Plugins.CSharpEditor.Scripting; + +/// +/// What a script shows besides its console output: images, HTML, tables, charts, 3D plots, visualizers and ECharts. Each +/// call emits one output to the cell or script that is running (), whatever host +/// shows it. The desktop studio's Display derives from this class and adds what only it can show (live controls, +/// Animate), so a desktop script calls both through the same name. +/// +public partial class Display +{ + /// Only a host's own Display derives from this class; scripts call its members statically. + protected Display() + { + } + + public static void Image(byte[] bytes, string format = "PNG") + { + // The size is in the header: decoding the whole image just to read it (and never disposing the bitmap) cost a + // full decode and leaked native memory on every call, every frame of a Display.Image animation. + InteractiveDisplayContext.Emit(RichCellOutput.ForImage(bytes, format)); + } + + public static void Image(string base64OrPath) + { + if (string.IsNullOrWhiteSpace(base64OrPath)) return; + + if (base64OrPath.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) + { + var commaIdx = base64OrPath.IndexOf(','); + if (commaIdx >= 0) + { + var base64 = base64OrPath.Substring(commaIdx + 1); + var bytes = Convert.FromBase64String(base64); + Image(bytes, "PNG"); + return; + } + } + + if (File.Exists(base64OrPath)) + { + var bytes = File.ReadAllBytes(base64OrPath); + var ext = Path.GetExtension(base64OrPath).TrimStart('.').ToUpperInvariant(); + Image(bytes, string.IsNullOrEmpty(ext) ? "PNG" : ext); + return; + } + + try + { + var bytes = Convert.FromBase64String(base64OrPath); + Image(bytes, "PNG"); + } + catch (FormatException) + { + Console.WriteLine($"[Display] Could not decode image data: {base64OrPath}"); + } + } + + /// + /// Shows an image of any kind the host knows: PNG/JPEG/GIF bytes, a path or data URL, a SkiaSharp image, bitmap, + /// surface or data, or the host's own image type (an Avalonia Bitmap on the desktop). + /// + public static void Image(object anyImage) + { + if (anyImage == null) return; + + if (anyImage is byte[] bytes) + { + Image(bytes); + return; + } + + if (anyImage is string str) + { + Image(str); + return; + } + + if (ScriptHost.Profile.Display?.TryEncodeImage(anyImage) is { } png) + { + Image(png, "PNG"); + return; + } + + if (SkiaImages.TryEncodePng(anyImage) is { } skiaPng) + { + Image(skiaPng, "PNG"); + return; + } + + Console.WriteLine($"[Display] Unsupported image type: {anyImage.GetType().FullName}"); + } + + /// Displays an animated GIF in the interactive output cell. + public static void Gif(byte[] gifBytes) + { + Image(gifBytes, "GIF"); + } + + /// Displays an animated GIF from a file path in the interactive output cell. + public static void Gif(string filePath) + { + if (File.Exists(filePath)) + { + var bytes = File.ReadAllBytes(filePath); + Image(bytes, "GIF"); + } + } + + public static void Html(string html) + { + InteractiveDisplayContext.Emit(new RichCellOutput + { + Kind = CellOutputKind.Html, + HtmlContent = html + }); + } + + /// Shows Markdown: rendered as Markdown where the host has a renderer (the browser studio, Jupyter), and as + /// basic HTML (headers, bold, italic, inline code, links) in the desktop's HTML view. + public static void Markdown(string markdown) + { + InteractiveDisplayContext.Emit(MarkdownOutput.Create(markdown ?? string.Empty)); + } + + /// The current cell/script execution's cancellation token (Stop button + timeout), for + /// code that wants to cooperatively check it — e.g. inside a bounded frame-rendering loop. See + /// ThrowIfCancellationRequested() for the common case. + public static CancellationToken CancellationToken => InteractiveCancellationContext.Current; + + /// Throws OperationCanceledException if Stop was pressed or the execution timed out. + /// Call this once per loop iteration in a frame-rendering loop so it can actually be interrupted — + /// without it, Roslyn scripting only checks cancellation between whole cells, never inside one, so + /// an unchecked loop runs to completion regardless of Stop/timeout. + public static void ThrowIfCancellationRequested() => + InteractiveCancellationContext.Current.ThrowIfCancellationRequested(); + + public static void Table(DumpTableResult table) + { + InteractiveDisplayContext.Emit(new RichCellOutput + { + Kind = CellOutputKind.Table, + TableResult = table + }); + } + + public static void Table(object anyTableOrCollection, string? label = null) + { + if (anyTableOrCollection is DumpTableResult table) + { + Table(table); + return; + } + + var result = DumpTableBuilder.Create(anyTableOrCollection, label); + Table(result); + } + + public static void Inspector(ObjectInspectorNode node) + { + InteractiveDisplayContext.Emit(new RichCellOutput + { + Kind = CellOutputKind.ObjectInspector, + InspectorNode = node + }); + } +} diff --git a/Services/Common/BrowserLauncher.cs b/FrySharp.Core/Services/Common/BrowserLauncher.cs similarity index 100% rename from Services/Common/BrowserLauncher.cs rename to FrySharp.Core/Services/Common/BrowserLauncher.cs diff --git a/FrySharp.Core/Services/Common/HeadlessDispatcher.cs b/FrySharp.Core/Services/Common/HeadlessDispatcher.cs new file mode 100644 index 00000000..a722c856 --- /dev/null +++ b/FrySharp.Core/Services/Common/HeadlessDispatcher.cs @@ -0,0 +1,64 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Common; + +/// +/// Headless implementation of for CLI tools, server sessions, and unit tests +/// where no Avalonia UI thread pump is active. All dispatches run synchronously or inline. +/// +public sealed class HeadlessDispatcher : IDispatcher +{ + public static readonly HeadlessDispatcher Instance = new(); + + public bool HasLiveUiLifetime => false; + + public void RunOnUi(Action action) => action(); + + // Like a UI dispatcher, a failure is reported through the returned task, never thrown at the caller. + public Task InvokeAsync(Action action) + { + try + { + action(); + return Task.CompletedTask; + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + public Task InvokeAsync(Func asyncAction) + { + try + { + return asyncAction(); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + public Task InvokeAsync(Func function) + { + try + { + return Task.FromResult(function()); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + public Task InvokeAsync(Func> asyncFunction) + { + try + { + return asyncFunction(); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } +} diff --git a/Services/Common/IDispatcher.cs b/FrySharp.Core/Services/Common/IDispatcher.cs similarity index 100% rename from Services/Common/IDispatcher.cs rename to FrySharp.Core/Services/Common/IDispatcher.cs diff --git a/ViewModels/Common/RangeObservableCollection.cs b/FrySharp.Core/Services/Common/RangeObservableCollection.cs similarity index 97% rename from ViewModels/Common/RangeObservableCollection.cs rename to FrySharp.Core/Services/Common/RangeObservableCollection.cs index a12d782e..f198a38d 100644 --- a/ViewModels/Common/RangeObservableCollection.cs +++ b/FrySharp.Core/Services/Common/RangeObservableCollection.cs @@ -2,7 +2,7 @@ using System.Collections.Specialized; using System.ComponentModel; -namespace PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common; +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Common; /// /// An that can change many items with a single notification. Adding N items one diff --git a/Services/Debugging/HttpDumpTableBuilder.cs b/FrySharp.Core/Services/Debugging/HttpDumpTableBuilder.cs similarity index 100% rename from Services/Debugging/HttpDumpTableBuilder.cs rename to FrySharp.Core/Services/Debugging/HttpDumpTableBuilder.cs diff --git a/Services/Debugging/HttpInspectionHelper.cs b/FrySharp.Core/Services/Debugging/HttpInspectionHelper.cs similarity index 100% rename from Services/Debugging/HttpInspectionHelper.cs rename to FrySharp.Core/Services/Debugging/HttpInspectionHelper.cs diff --git a/Services/Display/DisplayExtensions.EChart.cs b/FrySharp.Core/Services/Display/DisplayExtensions.EChart.cs similarity index 100% rename from Services/Display/DisplayExtensions.EChart.cs rename to FrySharp.Core/Services/Display/DisplayExtensions.EChart.cs diff --git a/Services/Display/DisplayExtensions.Legacy.cs b/FrySharp.Core/Services/Display/DisplayExtensions.Legacy.cs similarity index 100% rename from Services/Display/DisplayExtensions.Legacy.cs rename to FrySharp.Core/Services/Display/DisplayExtensions.Legacy.cs diff --git a/Services/Display/DisplayExtensions.Visuals.cs b/FrySharp.Core/Services/Display/DisplayExtensions.Visuals.cs similarity index 70% rename from Services/Display/DisplayExtensions.Visuals.cs rename to FrySharp.Core/Services/Display/DisplayExtensions.Visuals.cs index 4af7fb2a..733bc916 100644 --- a/Services/Display/DisplayExtensions.Visuals.cs +++ b/FrySharp.Core/Services/Display/DisplayExtensions.Visuals.cs @@ -1,6 +1,7 @@ using PdfEditorApp.Plugins.CSharpEditor.Charting3D.Models; using PdfEditorApp.Plugins.CSharpEditor.Visualizers.Models; using PdfEditorApp.Plugins.CSharpEditor.Visualizers.Services; +using ScriptDisplay = PdfEditorApp.Plugins.CSharpEditor.Scripting.Display; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; @@ -12,38 +13,38 @@ public static partial class DisplayExtensions public static T DisplayChart(this T data, string? title = null, string? color = null, string? chartType = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null) { - if (data != null) Display.Chart(data, title, color, chartType, width: width, height: height, configure: configure, xLabel: xLabel, yLabel: yLabel, legend: legend); + if (data != null) ScriptDisplay.Chart(data, title, color, chartType, width: width, height: height, configure: configure, xLabel: xLabel, yLabel: yLabel, legend: legend); return data; } /// Charts the records: each at (a number, or a label) with the value . public static IEnumerable DisplayChart(this IEnumerable records, Func x, Func y, string? title = null, string? color = null, string? chartType = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null) { - Display.Chart(records, x, y, title, color, chartType, configure, xLabel, yLabel, width, height, legend); + ScriptDisplay.Chart(records, x, y, title, color, chartType, configure, xLabel, yLabel, width, height, legend); return records; } public static T DisplayLineChart(this T data, string? title = null, string? color = null, bool? showPoints = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null, ChartStack? stack = null) { - if (data != null) Display.LineChart(data, title, color, showPoints, configure, xLabel, yLabel, width, height, legend, labels, stack); + if (data != null) ScriptDisplay.LineChart(data, title, color, showPoints, configure, xLabel, yLabel, width, height, legend, labels, stack); return data; } public static T DisplayAreaChart(this T data, string? title = null, string? color = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null, ChartStack? stack = null) { - if (data != null) Display.AreaChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, stack); + if (data != null) ScriptDisplay.AreaChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, stack); return data; } public static T DisplayBarChart(this T data, string? title = null, string? color = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null, ChartStack? stack = null, bool horizontal = false) { - if (data != null) Display.BarChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, stack, horizontal); + if (data != null) ScriptDisplay.BarChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, stack, horizontal); return data; } public static T DisplayScatterChart(this T data, string? title = null, string? color = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null) { - if (data != null) Display.ScatterChart(data, title, color, configure, xLabel, yLabel, width, height, legend); + if (data != null) ScriptDisplay.ScatterChart(data, title, color, configure, xLabel, yLabel, width, height, legend); return data; } @@ -51,7 +52,7 @@ public static T DisplayStackedBarChart(this T data, string? title = null, str string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null, bool percent = false, bool horizontal = false) { - if (data != null) Display.StackedBarChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, percent, horizontal); + if (data != null) ScriptDisplay.StackedBarChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, percent, horizontal); return data; } @@ -59,46 +60,46 @@ public static T DisplayHorizontalBarChart(this T data, string? title = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null, ChartStack? stack = null) { - if (data != null) Display.HorizontalBarChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, stack); + if (data != null) ScriptDisplay.HorizontalBarChart(data, title, color, configure, xLabel, yLabel, width, height, legend, labels, stack); return data; } public static T DisplayBubbleChart(this T data, string? title = null, string? color = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null) { - if (data != null) Display.BubbleChart(data, title, color, configure, xLabel, yLabel, width, height, legend); + if (data != null) ScriptDisplay.BubbleChart(data, title, color, configure, xLabel, yLabel, width, height, legend); return data; } public static T DisplayRadarChart(this T data, string? title = null, string? color = null, Action? configure = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null, LegendPosition? legendPosition = null) { - if (data != null) Display.RadarChart(data, title, color, configure, width, height, legend, labels, legendPosition); + if (data != null) ScriptDisplay.RadarChart(data, title, color, configure, width, height, legend, labels, legendPosition); return data; } public static T DisplayPolarAreaChart(this T data, string? title = null, Action? configure = null, double? width = null, double? height = null, IEnumerable? labels = null) { - if (data != null) Display.PolarAreaChart(data, title, configure, width, height, labels); + if (data != null) ScriptDisplay.PolarAreaChart(data, title, configure, width, height, labels); return data; } public static T DisplayPieChart(this T data, string? title = null, Action? configure = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null) { - if (data != null) Display.PieChart(data, title, configure, width, height, legend, labels); + if (data != null) ScriptDisplay.PieChart(data, title, configure, width, height, legend, labels); return data; } public static T DisplayDonutChart(this T data, string? title = null, Action? configure = null, double? width = null, double? height = null, bool? legend = null, IEnumerable? labels = null, bool gauge = false) { - if (data != null) Display.DonutChart(data, title, configure, width, height, legend, labels, gauge); + if (data != null) ScriptDisplay.DonutChart(data, title, configure, width, height, legend, labels, gauge); return data; } public static T DisplayHistogram(this T samples, string? title = null, int? bins = null, string? color = null, Action? configure = null, string? xLabel = null, string? yLabel = null, double? width = null, double? height = null, bool? legend = null) { - if (samples != null) Display.Histogram(samples, title, bins, color, configure, xLabel, yLabel, width, height, legend); + if (samples != null) ScriptDisplay.Histogram(samples, title, bins, color, configure, xLabel, yLabel, width, height, legend); return samples; } @@ -106,13 +107,13 @@ public static T DisplayHistogram(this T samples, string? title = null, int? b public static T DisplayPlot3D(this T data, string? title = null, string? color = null, string? plotType = null, ColorMapPreset? colorMap = null, Action? configure = null, string? xLabel = null, string? yLabel = null, string? zLabel = null, double? width = null, double? height = null) { - if (data != null) Display.Plot3D(data, title, color, plotType, colorMap, width: width, height: height, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel); + if (data != null) ScriptDisplay.Plot3D(data, title, color, plotType, colorMap, width: width, height: height, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel); return data; } public static T DisplayScatter3D(this T data, string? title = null, string? color = null, ColorMapPreset? colorMap = null, Action? configure = null, string? xLabel = null, string? yLabel = null, string? zLabel = null, double? width = null, double? height = null) { - if (data != null) Display.Scatter3D(data, title, color, colorMap, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); + if (data != null) ScriptDisplay.Scatter3D(data, title, color, colorMap, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); return data; } @@ -121,106 +122,106 @@ public static IEnumerable DisplayScatter3D(this IEnumerable records, Fu { ArgumentNullException.ThrowIfNull(records); var points = records.Where(r => r != null).Select(r => new Point3D(x(r), y(r), z(r), label?.Invoke(r))).ToList(); - Display.Scatter3D(points, title, color, configure: configure); + ScriptDisplay.Scatter3D(points, title, color, configure: configure); return records; } public static T DisplayTrajectory3D(this T data, string? title = null, ColorMapPreset? colorMap = null, Action? configure = null, string? xLabel = null, string? yLabel = null, string? zLabel = null, double? width = null, double? height = null) { - if (data != null) Display.Trajectory3D(data, title, colorMap, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); + if (data != null) ScriptDisplay.Trajectory3D(data, title, colorMap, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); return data; } public static T DisplaySurface3D(this T data, string? title = null, ColorMapPreset? colorMap = null, bool wireframe = false, Action? configure = null, string? xLabel = null, string? yLabel = null, string? zLabel = null, double? width = null, double? height = null) { - if (data != null) Display.Surface3D(data, title, colorMap, wireframe, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); + if (data != null) ScriptDisplay.Surface3D(data, title, colorMap, wireframe, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); return data; } public static T DisplayGraph3D(this T data, string? title = null, string? color = null, Action? configure = null, double? width = null, double? height = null) { - if (data != null) Display.Graph3D(data, title, color, configure: configure, width: width, height: height); + if (data != null) ScriptDisplay.Graph3D(data, title, color, configure: configure, width: width, height: height); return data; } public static T DisplayVoxelBar3D(this T data, string? title = null, string? color = null, Action? configure = null, string? xLabel = null, string? yLabel = null, string? zLabel = null, double? width = null, double? height = null) { - if (data != null) Display.VoxelBar3D(data, title, color, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); + if (data != null) ScriptDisplay.VoxelBar3D(data, title, color, configure: configure, xLabel: xLabel, yLabel: yLabel, zLabel: zLabel, width: width, height: height); return data; } // Visualizers - /// Visualizes the data as whatever it is (a grid, tree, list, graph or array); see . + /// Visualizes the data as whatever it is (a grid, tree, list, graph or array); see . public static T DisplayVisualizer(this T data, string? title = null, Action? configure = null) { - if (data != null) Display.Visualize(data, title, configure); + if (data != null) ScriptDisplay.Visualize(data, title, configure); return data; } public static VisualizerRecorder? DisplayVisualizer(this VisualizerRecorder? recorder) { - if (recorder != null) Display.Visualizer(recorder); + if (recorder != null) ScriptDisplay.Visualizer(recorder); return recorder; } public static MatrixTracker? DisplayVisualizer(this MatrixTracker? tracker) { - if (tracker != null) Display.Visualizer(tracker); + if (tracker != null) ScriptDisplay.Visualizer(tracker); return tracker; } public static T DisplayIslands(this T grid, string? title = null, bool recordSteps = true, bool fourDirectional = true, Action? configure = null) { - if (grid != null) Display.Islands(grid, title, recordSteps, fourDirectional, configure); + if (grid != null) ScriptDisplay.Islands(grid, title, recordSteps, fourDirectional, configure); return grid; } public static T DisplayMatrix(this T grid, string? title = null, bool? showCoordinates = null, bool? showValues = null, Action? configure = null) { - if (grid != null) Display.Matrix(grid, title, showCoordinates, showValues, configure: configure); + if (grid != null) ScriptDisplay.Matrix(grid, title, showCoordinates, showValues, configure: configure); return grid; } public static T DisplayTree(this T root, string? title = null, string? recordTraversal = null, Action? configure = null) { - if (root != null) Display.Tree(root, title, recordTraversal, configure); + if (root != null) ScriptDisplay.Tree(root, title, recordTraversal, configure); return root; } public static T DisplayGraph(this T graph, string? title = null, bool isDirected = true, Action? configure = null) { - if (graph != null) Display.Graph(graph, title, isDirected, configure); + if (graph != null) ScriptDisplay.Graph(graph, title, isDirected, configure); return graph; } public static T DisplayLinkedList(this T head, string? title = null, bool recordCycleSteps = false, Action? configure = null) { - if (head != null) Display.LinkedList(head, title, recordCycleSteps, configure); + if (head != null) ScriptDisplay.LinkedList(head, title, recordCycleSteps, configure); return head; } public static T DisplayArray(this T array, object? pointers = null, string? title = null, Action? configure = null) { - if (array != null) Display.Array(array, pointers, title, configure); + if (array != null) ScriptDisplay.Array(array, pointers, title, configure); return array; } public static T DisplayBars(this T values, string? title = null, Action? configure = null) { - if (values != null) Display.Bars(values, title, configure); + if (values != null) ScriptDisplay.Bars(values, title, configure); return values; } public static BoardVisualizerData DisplayBoard(this BoardVisualizerData boardData, string? title = null, Action? configure = null) { - Display.Board(boardData, title, configure); + ScriptDisplay.Board(boardData, title, configure); return boardData; } public static VisualizerScene DisplayCanvas(this VisualizerScene scene, string? title = null, Action? configure = null) { - Display.Canvas(scene, title, configure); + ScriptDisplay.Canvas(scene, title, configure); return scene; } } diff --git a/FrySharp.Core/Services/Display/DisplayExtensions.cs b/FrySharp.Core/Services/Display/DisplayExtensions.cs new file mode 100644 index 00000000..56806bf1 --- /dev/null +++ b/FrySharp.Core/Services/Display/DisplayExtensions.cs @@ -0,0 +1,138 @@ +using System.Collections; +using System.Text.Json; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; +using ScriptDisplay = PdfEditorApp.Plugins.CSharpEditor.Scripting.Display; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +public static partial class DisplayExtensions +{ + /// + /// Shows the best way it can be shown (a table, an image, a host control or its text) and hands + /// it back, so Dump() can sit inside an expression. + /// + public static T Dump(this T obj, string? label = null) + { + if (!string.IsNullOrEmpty(label)) + { + Console.WriteLine($"=== {label} ==="); + } + + if (obj == null) + { + Console.WriteLine(""); + ShowTable(null, label, reportFailure: false); + return obj; + } + + var host = ScriptHost.Profile.Display; + if (host?.TryCreateElementOutput(obj) is { } element) + { + InteractiveDisplayContext.Emit(element); + return obj; + } + + if (host?.TryEncodeImage(obj) is { } hostPng) + { + ScriptDisplay.Image(hostPng); + return obj; + } + + if (SkiaImages.IsSkiaImage(obj)) + { + ScriptDisplay.Image(obj); + return obj; + } + + if (obj is byte[] bytes && IsImageBytes(bytes)) + { + ScriptDisplay.Image(bytes); + return obj; + } + + if (obj is string s) + { + if (s.StartsWith("data:image", StringComparison.OrdinalIgnoreCase) || + (s.TrimStart().StartsWith('<') && s.Contains("'))) + { + ScriptDisplay.Html(s); + return obj; + } + Console.WriteLine(s); + ShowTable(s, label, reportFailure: false); + return obj; + } + + // Generate interactive visual table result + ShowTable(obj, label, reportFailure: true); + + // Print console representation + if (obj is IDictionary dict) + { + Console.WriteLine($"[Dictionary: {dict.Count} entries]"); + foreach (DictionaryEntry entry in dict) + { + Console.WriteLine($" {entry.Key} => {entry.Value}"); + } + return obj; + } + + if (obj is IEnumerable enumerable) + { + int index = 0; + Console.WriteLine($"[{DumpTableBuilder.GetFriendlyTypeName(obj.GetType())}]"); + foreach (var item in enumerable) + { + Console.WriteLine($" [{index++}] {item}"); + } + return obj; + } + + try + { + Console.WriteLine(JsonSerializer.Serialize(obj, IndentedJson)); + } + catch (Exception ex) when (ex is NotSupportedException or JsonException or InvalidOperationException) + { + Console.WriteLine(obj.ToString()); + } + + return obj; + } + + public static T DisplayObject(this T obj) + { + return Dump(obj); + } + + private static readonly JsonSerializerOptions IndentedJson = new() { WriteIndented = true }; + + // A value the table builder can't take apart is still printed as text; for an object the notice says why there is no table. + private static void ShowTable(object? value, string? label, bool reportFailure) + { + try + { + ScriptDisplay.Table(DumpTableBuilder.Create(value, label)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (reportFailure) Console.WriteLine($"[Display] Notice: {ex.Message}"); + } + } + + private static bool IsImageBytes(byte[] bytes) + { + if (bytes.Length < 8) return false; + // PNG magic bytes: 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) return true; + // JPEG magic bytes: 0xFF, 0xD8, 0xFF + if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) return true; + // GIF magic bytes: 'G', 'I', 'F', '8' + if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38) return true; + // WEBP: RIFF....WEBP + if (bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && + bytes.Length >= 12 && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) return true; + + return false; + } +} diff --git a/Services/Display/DumpTableBuilder.cs b/FrySharp.Core/Services/Display/DumpTableBuilder.cs similarity index 100% rename from Services/Display/DumpTableBuilder.cs rename to FrySharp.Core/Services/Display/DumpTableBuilder.cs diff --git a/Services/Display/ECharts/EChart.Factories.cs b/FrySharp.Core/Services/Display/ECharts/EChart.Factories.cs similarity index 100% rename from Services/Display/ECharts/EChart.Factories.cs rename to FrySharp.Core/Services/Display/ECharts/EChart.Factories.cs diff --git a/Services/Display/ECharts/EChart.Page.cs b/FrySharp.Core/Services/Display/ECharts/EChart.Page.cs similarity index 100% rename from Services/Display/ECharts/EChart.Page.cs rename to FrySharp.Core/Services/Display/ECharts/EChart.Page.cs diff --git a/Services/Display/ECharts/EChart.ThreeD.cs b/FrySharp.Core/Services/Display/ECharts/EChart.ThreeD.cs similarity index 100% rename from Services/Display/ECharts/EChart.ThreeD.cs rename to FrySharp.Core/Services/Display/ECharts/EChart.ThreeD.cs diff --git a/Services/Display/ECharts/EChart.cs b/FrySharp.Core/Services/Display/ECharts/EChart.cs similarity index 99% rename from Services/Display/ECharts/EChart.cs rename to FrySharp.Core/Services/Display/ECharts/EChart.cs index f7f86b06..a8fbba83 100644 --- a/Services/Display/ECharts/EChart.cs +++ b/FrySharp.Core/Services/Display/ECharts/EChart.cs @@ -282,7 +282,7 @@ public string SaveHtml(string path) // ── Helpers ────────────────────────────────────────────────────────────────────────────────────────────────── private static string[] Stops(ColorMapPreset preset) => - Enumerable.Range(0, 9).Select(i => ColorMapService.GetHexColor(preset, i / 8.0)).ToArray(); + Enumerable.Range(0, 9).Select(i => ColorMaps.GetHexColor(preset, i / 8.0)).ToArray(); // A component of the option (title, legend…): the object, or the first one of a list; made when missing. private JsonObject Component(string key) diff --git a/Services/Display/ECharts/EChartData.cs b/FrySharp.Core/Services/Display/ECharts/EChartData.cs similarity index 100% rename from Services/Display/ECharts/EChartData.cs rename to FrySharp.Core/Services/Display/ECharts/EChartData.cs diff --git a/Services/Display/ECharts/EChartPath.cs b/FrySharp.Core/Services/Display/ECharts/EChartPath.cs similarity index 100% rename from Services/Display/ECharts/EChartPath.cs rename to FrySharp.Core/Services/Display/ECharts/EChartPath.cs diff --git a/Services/Display/ECharts/EChartTypes.cs b/FrySharp.Core/Services/Display/ECharts/EChartTypes.cs similarity index 100% rename from Services/Display/ECharts/EChartTypes.cs rename to FrySharp.Core/Services/Display/ECharts/EChartTypes.cs diff --git a/Services/Display/Fluent/ChartBuilder.cs b/FrySharp.Core/Services/Display/Fluent/ChartBuilder.cs similarity index 100% rename from Services/Display/Fluent/ChartBuilder.cs rename to FrySharp.Core/Services/Display/Fluent/ChartBuilder.cs diff --git a/Services/Display/Fluent/Charts.cs b/FrySharp.Core/Services/Display/Fluent/Charts.cs similarity index 98% rename from Services/Display/Fluent/Charts.cs rename to FrySharp.Core/Services/Display/Fluent/Charts.cs index 20aca702..dd98341b 100644 --- a/Services/Display/Fluent/Charts.cs +++ b/FrySharp.Core/Services/Display/Fluent/Charts.cs @@ -11,7 +11,7 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; /// Charts.Line(sales).Title("Sales").XLabel("Month").YLabel("USD").Show(); /// Charts.Surface((x, y) => Math.Sin(x) * Math.Cos(y)).ColorMap(ColorMapPreset.Plasma).Show(); /// -/// It draws what the same call on draws; use whichever reads better. Returned as a cell's last +/// It draws what the same call on draws; use whichever reads better. Returned as a cell's last /// value, a chart shows without .Show(). /// public static class Charts diff --git a/Services/Display/Fluent/Plot3DBuilder.cs b/FrySharp.Core/Services/Display/Fluent/Plot3DBuilder.cs similarity index 100% rename from Services/Display/Fluent/Plot3DBuilder.cs rename to FrySharp.Core/Services/Display/Fluent/Plot3DBuilder.cs diff --git a/Services/Display/Fluent/SeriesOptions.cs b/FrySharp.Core/Services/Display/Fluent/SeriesOptions.cs similarity index 100% rename from Services/Display/Fluent/SeriesOptions.cs rename to FrySharp.Core/Services/Display/Fluent/SeriesOptions.cs diff --git a/Services/Display/Fluent/VisualBuilder.cs b/FrySharp.Core/Services/Display/Fluent/VisualBuilder.cs similarity index 91% rename from Services/Display/Fluent/VisualBuilder.cs rename to FrySharp.Core/Services/Display/Fluent/VisualBuilder.cs index 6f07b37b..0671b7e6 100644 --- a/Services/Display/Fluent/VisualBuilder.cs +++ b/FrySharp.Core/Services/Display/Fluent/VisualBuilder.cs @@ -1,10 +1,11 @@ using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; +using ScriptDisplay = PdfEditorApp.Plugins.CSharpEditor.Scripting.Display; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; /// /// A visual being put together one setting at a time: Charts.Line(sales).Title("Sales").YLabel("USD").Show(). It -/// builds the same spec does, so what it draws is what the same call on draws. +/// builds the same spec does, so what it draws is what the same call on draws. /// draws it; so does returning it as a cell's last value, or Display.Show(builder). Once drawn it /// can't be changed: use the handle's Update for that. /// @@ -50,7 +51,7 @@ public TBuilder Configure(Action configure) /// Draws the visual and returns its handle: handle.Update(...), handle.OnClick(...). A visual that can't be /// drawn as described shows an error that says where, and the handle's IsShown is false. /// - public DisplayHandle Show() => Display.Show(Close()); + public DisplayHandle Show() => ScriptDisplay.Show(Close()); VisualSpec IVisualSource.ToVisualSpec() => Close(); diff --git a/Services/Display/HtmlAssets.cs b/FrySharp.Core/Services/Display/HtmlAssets.cs similarity index 98% rename from Services/Display/HtmlAssets.cs rename to FrySharp.Core/Services/Display/HtmlAssets.cs index 2d9ee5bc..f709b9c4 100644 --- a/Services/Display/HtmlAssets.cs +++ b/FrySharp.Core/Services/Display/HtmlAssets.cs @@ -69,7 +69,7 @@ public static string ForDisplay(string html, bool dark, string? background = nul private static bool IsColor(string? value) => value is { Length: 7 or 9 } && value[0] == '#' && value.Skip(1).All(Uri.IsHexDigit); - // The script from the plugin's resources, or from Assets next to it; read once. + // The script from Core's resources, or from Assets next to it; read once. private static string Load(string name) => Loaded.GetOrAdd(name, static n => { try diff --git a/FrySharp.Core/Services/Display/IScriptHostDisplay.cs b/FrySharp.Core/Services/Display/IScriptHostDisplay.cs new file mode 100644 index 00000000..0122581a --- /dev/null +++ b/FrySharp.Core/Services/Display/IScriptHostDisplay.cs @@ -0,0 +1,25 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +/// +/// What only the host's UI can show: its own live elements and image types (on the desktop, Avalonia controls and +/// bitmaps). , Dump and the notebook kernels ask it first, then show everything else +/// the same way in every host. A host without such types (the web server, the CLI) has none. +/// +public interface IScriptHostDisplay +{ + /// The output that shows when it is one of the host's live elements, or null. + /// The element can't be shown, with a message saying why (e.g. it belongs to the script's thread). + RichCellOutput? TryCreateElementOutput(object value); + + /// The image as PNG bytes when it is one of the host's image types, or null. + byte[]? TryEncodeImage(object value); + + /// How the Variables panel shows when it is one of the host's types, or null. + HostValueDescription? Describe(object value); + + /// What a value of is when #!share can't send it ("a control"), or null. + string? DescribeUnshareable(Type type); +} + +/// A host value in the Variables panel: its text (<Avalonia.Button>) and kind (UI Control). +public readonly record struct HostValueDescription(string Text, string Kind); diff --git a/FrySharp.Core/Services/Display/ImageHeader.cs b/FrySharp.Core/Services/Display/ImageHeader.cs new file mode 100644 index 00000000..b455b5de --- /dev/null +++ b/FrySharp.Core/Services/Display/ImageHeader.cs @@ -0,0 +1,87 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +/// Reads an image's pixel size from its header (PNG, GIF, BMP, JPEG), without decoding it. +public static class ImageHeader +{ + /// Reads an image file's pixel size from its header (PNG, GIF, BMP, JPEG), without decoding it. + public static bool TryReadDimensions(string path, out int width, out int height) + { + width = 0; + height = 0; + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var header = new byte[(int)Math.Min(64 * 1024, fs.Length)]; + int read = fs.ReadAtLeast(header, header.Length, throwOnEndOfStream: false); + return TryReadDimensions(header.AsSpan(0, read), out width, out height); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// Reads an image's pixel size from the first bytes of its data (PNG, GIF, BMP, JPEG). + public static bool TryReadDimensions(ReadOnlySpan header, out int width, out int height) + { + width = 0; + height = 0; + int read = header.Length; + if (read < 10) return false; + + // PNG: 89 50 4E 47 0D 0A 1A 0A, then the IHDR chunk. + if (read >= 24 && header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47 && + header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A) + { + width = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; + height = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; + return width > 0 && height > 0; + } + + // GIF87a / GIF89a. + if (header[0] == 'G' && header[1] == 'I' && header[2] == 'F') + { + width = header[6] | (header[7] << 8); + height = header[8] | (header[9] << 8); + return width > 0 && height > 0; + } + + // BMP. + if (read >= 26 && header[0] == 'B' && header[1] == 'M') + { + width = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); + height = Math.Abs(header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24)); + return width > 0 && height > 0; + } + + // JPEG: walk the segments to the start-of-frame marker. + if (header[0] == 0xFF && header[1] == 0xD8) + { + int pos = 2; + while (pos + 4 < read) + { + if (header[pos] != 0xFF) { pos++; continue; } + while (pos < read && header[pos] == 0xFF) pos++; + if (pos >= read) break; + + byte marker = header[pos++]; + if (marker == 0xD9 || marker == 0xDA) break; // end of image, or start of scan + if (pos + 2 > read) break; + int chunkLen = (header[pos] << 8) | header[pos + 1]; + if (marker is 0xC0 or 0xC1 or 0xC2 or 0xC3 or 0xC5 or 0xC6 or 0xC7 or 0xC9 or 0xCA or 0xCB) + { + if (pos + 7 <= read) + { + height = (header[pos + 3] << 8) | header[pos + 4]; + width = (header[pos + 5] << 8) | header[pos + 6]; + return width > 0 && height > 0; + } + } + + pos += chunkLen; + } + } + + return false; + } +} diff --git a/Services/Display/InteractiveDisplayContext.cs b/FrySharp.Core/Services/Display/InteractiveDisplayContext.cs similarity index 97% rename from Services/Display/InteractiveDisplayContext.cs rename to FrySharp.Core/Services/Display/InteractiveDisplayContext.cs index 2c3a7483..a4200c2f 100644 --- a/Services/Display/InteractiveDisplayContext.cs +++ b/FrySharp.Core/Services/Display/InteractiveDisplayContext.cs @@ -1,5 +1,3 @@ -using Avalonia.Controls; - namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; /// @@ -46,7 +44,7 @@ public void Dispose() /// public static class InteractiveControlLifecycle { - public static void DisposeIfNeeded(Control? control) + public static void DisposeIfNeeded(object? control) { if (control is IDisposable disposable) { diff --git a/FrySharp.Core/Services/Display/InteractiveTableHtmlRenderer.cs b/FrySharp.Core/Services/Display/InteractiveTableHtmlRenderer.cs new file mode 100644 index 00000000..1cec9598 --- /dev/null +++ b/FrySharp.Core/Services/Display/InteractiveTableHtmlRenderer.cs @@ -0,0 +1,483 @@ +using System.Globalization; +using System.Net; +using System.Text; +using PdfEditorApp.Plugins.CSharpEditor.Models; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +/// +/// Renders DumpTableResult and ObjectInspectorNode into an interactive, compact HTML component +/// matching the desktop IDE's DumpTableView and ObjectInspector. +/// +public static class InteractiveTableHtmlRenderer +{ + private const int MaxRenderedRows = 1000; + + public static string Render(DumpTableResult table) + { + ArgumentNullException.ThrowIfNull(table); + + var uid = "fry_tbl_" + Guid.NewGuid().ToString("N")[..8]; + var title = WebUtility.HtmlEncode(!string.IsNullOrWhiteSpace(table.Title) ? table.Title : (!string.IsNullOrWhiteSpace(table.Label) ? table.Label : "Data Table")); + var rowCount = table.Rows.Count; + var colCount = table.Columns.Count; + var renderedRows = Math.Min(rowCount, MaxRenderedRows); + + var headerHtml = new StringBuilder(); + for (var c = 0; c < colCount; c++) + { + var col = table.Columns[c]; + var align = col.IsNumeric ? "right" : "left"; + var colHeader = WebUtility.HtmlEncode(col.Header); + var isNumStr = col.IsNumeric ? "true" : "false"; + headerHtml.Append($$""" + + {{colHeader}} ↕ + + """); + } + + var rowsHtml = new StringBuilder(); + for (var r = 0; r < renderedRows; r++) + { + var row = table.Rows[r]; + var bg = (r % 2 == 0) ? "background:rgba(255,255,255,0.015);" : "background:transparent;"; + + var cellsHtml = new StringBuilder(); + var propCardsHtml = new StringBuilder(); + + for (var c = 0; c < colCount; c++) + { + var col = table.Columns[c]; + var cell = c < row.Cells.Count ? row.Cells[c] : null; + var isNum = col.IsNumeric; + var align = isNum ? "right" : "left"; + var (formatted, cssClass) = FormatCellHtml(cell, isNum); + + cellsHtml.Append($$"""{{formatted}}"""); + + propCardsHtml.Append($$""" +
+
{{WebUtility.HtmlEncode(col.Header)}}
+
{{formatted}}
+
+ """); + } + + var nestedTablesHtml = new StringBuilder(); + for (var c = 0; c < row.Cells.Count; c++) + { + if (row.Cells[c].NestedTable is { } childTable) + { + nestedTablesHtml.Append($$""" +
+ {{Render(childTable)}} +
+ """); + } + } + + rowsHtml.Append($$""" + + + + + {{cellsHtml}} + + + +
+
+
🔍 Row #{{r + 1}} Inspector
+
{{colCount}} properties
+
+
+ {{propCardsHtml}} +
+ {{nestedTablesHtml}} +
+ + + """); + } + + return $$""" +
+ + +
+ +
+
+ 🔍 + +
+ + +
+
+ +
+ + + + + {{headerHtml}} + + + + {{rowsHtml}} + +
+
+ + + + +
+ """; + } + + public static string RenderInspector(ObjectInspectorNode node) + { + ArgumentNullException.ThrowIfNull(node); + + var title = WebUtility.HtmlEncode(node.HeaderTitle); + var propRowsHtml = new StringBuilder(); + RenderInspectorProperties(propRowsHtml, node); + + return $$""" +
+
+ 🔬 {{title}} +
+ {{propRowsHtml}} +
+ """; + } + + private static void RenderInspectorProperties(StringBuilder sb, ObjectInspectorNode node) + { + sb.Append("""
"""); + foreach (var prop in node.Properties) + { + var name = WebUtility.HtmlEncode(prop.Name); + if (prop.IsComplexChild && prop.ChildNode != null) + { + var childPropsHtml = new StringBuilder(); + RenderInspectorProperties(childPropsHtml, prop.ChildNode); + sb.Append($$""" +
+ {{name}}: {{WebUtility.HtmlEncode(prop.SimpleValueText)}} +
+ {{childPropsHtml}} +
+
+ """); + } + else + { + var (valHtml, _) = FormatValueText(prop.SimpleValueText, prop.IsNull, prop.IsString); + sb.Append($$""" +
+ {{name}} + {{valHtml}} +
+ """); + } + } + sb.Append("
"); + } + + private static (string html, string cssClass) FormatCellHtml(DumpTableCell? cell, bool isColumnNumeric) + { + if (cell == null || cell.IsNull) + { + return ("""null""", ""); + } + + var text = cell.DisplayText ?? string.Empty; + var lower = text.Trim().ToLowerInvariant(); + + switch (lower) + { + case "online" or "active" or "healthy" or "optimal" or "passed" or "success" or "true" or "running" or "ready" or "connected": + return ($$"""{{WebUtility.HtmlEncode(text)}}""", ""); + case "warning" or "degraded" or "under load" or "pending" or "slow" or "paused" or "retrying": + return ($$"""{{WebUtility.HtmlEncode(text)}}""", ""); + case "error" or "failed" or "critical" or "offline" or "down" or "false" or "stopped": + return ($$"""{{WebUtility.HtmlEncode(text)}}""", ""); + } + + if (cell.IsNumeric || isColumnNumeric) + { + return (WebUtility.HtmlEncode(text), "fry-num"); + } + + return (WebUtility.HtmlEncode(text), ""); + } + + private static (string html, string cssClass) FormatValueText(string text, bool isNull, bool isString) + { + if (isNull) return ("""null""", ""); + var lower = text.Trim().ToLowerInvariant(); + if (lower is "true" or "false") + { + var badgeClass = lower == "true" ? "fry-badge-green" : "fry-badge-red"; + return ($$"""{{text}}""", ""); + } + if (isString) + { + return ($$""""{{WebUtility.HtmlEncode(text)}}"""", ""); + } + if (double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out _)) + { + return (WebUtility.HtmlEncode(text), "fry-num"); + } + return (WebUtility.HtmlEncode(text), ""); + } +} diff --git a/FrySharp.Core/Services/Display/MarkdownOutput.cs b/FrySharp.Core/Services/Display/MarkdownOutput.cs new file mode 100644 index 00000000..408854c0 --- /dev/null +++ b/FrySharp.Core/Services/Display/MarkdownOutput.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +/// +/// A Markdown display, the same from every language: C# Display.Markdown, Python fry.markdown and JavaScript +/// Display.markdown (which send text/markdown). The output keeps the Markdown text, which a host with a real +/// Markdown renderer shows (the browser studio, Jupyter), and a basic HTML rendering of it for the desktop's HTML view. +/// +public static partial class MarkdownOutput +{ + /// The Markdown text. + public static RichCellOutput Create(string markdown) + { + ArgumentNullException.ThrowIfNull(markdown); + return new RichCellOutput + { + Kind = CellOutputKind.Html, + HtmlContent = ToBasicHtml(markdown), + MarkdownSource = markdown, + }; + } + + /// + /// Headers, bold, italic, inline code and links as HTML; everything else as text. Links go only to web and mail + /// addresses: a javascript: link is shown as its text. + /// + public static string ToBasicHtml(string markdown) + { + ArgumentNullException.ThrowIfNull(markdown); + + // Encode the raw text first so the tags added below are the only real HTML: Markdown must never be able to + // smuggle a + """); + } + else + { + RenderFallbackSvg(sb, plot); + } + + sb.Append(""); + } + + private static void RenderVisualizerSvg(StringBuilder sb, VisualizerSpec viz) + { + const double width = 760; + const double height = 300; + sb.Append($""); + sb.Append($""); + sb.Append($"{viz.Kind} Visualizer"); + sb.Append($"Interactive Data Structure Spec"); + sb.Append(""); + } + + private static void RenderFallbackSvg(StringBuilder sb, VisualSpec spec) + { + const double width = 760; + const double height = 240; + sb.Append($""); + sb.Append($"{spec.Family} Visual Output"); + sb.Append(""); + } + + private static (double X, double Y) ProjectIso(double x, double y, double z, double cx, double cy) + { + // Isometric 3D Projection transformation + const double scaleX = 180.0; + const double scaleY = 90.0; + const double scaleZ = 120.0; + double px = cx + ((x - y) * Math.Cos(Math.PI / 6.0) * scaleX * 0.7); + double py = cy + ((x + y) * Math.Sin(Math.PI / 6.0) * scaleY * 0.7) - (z * scaleZ * 0.6); + return (px, py); + } + + private static string ColorMap(double t) + { + t = Math.Clamp(t, 0.0, 1.0); + // Viridis/Cyan-Purple gradient: HSL 220 -> 140 -> 45 + double hue = 240.0 - (t * 195.0); + return $"hsl({hue:F0}, 85%, 55%)"; + } + + private static void RenderLegend(StringBuilder sb, IEnumerable<(string Name, string Color)> items, double y) + { + var list = items.Take(6).ToList(); + if (list.Count == 0) return; + + double startX = 60; + foreach (var (name, color) in list) + { + sb.Append($""); + sb.Append($"{WebUtility.HtmlEncode(name)}"); + startX += Math.Max(90, name.Length * 7 + 35); + if (startX > 680) break; + } + } + + private static string FormatNumber(double val) + { + if (Math.Abs(val) >= 1_000_000) return (val / 1_000_000.0).ToString("0.#M", CultureInfo.InvariantCulture); + if (Math.Abs(val) >= 1_000) return (val / 1_000.0).ToString("0.#k", CultureInfo.InvariantCulture); + if (Math.Abs(val) < 0.01 && Math.Abs(val) > 0) return val.ToString("0.##e+0", CultureInfo.InvariantCulture); + return val.ToString("0.##", CultureInfo.InvariantCulture); + } +} diff --git a/Services/Execution/ConsoleRoutingContext.cs b/FrySharp.Core/Services/Execution/ConsoleRoutingContext.cs similarity index 100% rename from Services/Execution/ConsoleRoutingContext.cs rename to FrySharp.Core/Services/Execution/ConsoleRoutingContext.cs diff --git a/Services/Execution/ExecutionAbandonment.cs b/FrySharp.Core/Services/Execution/ExecutionAbandonment.cs similarity index 100% rename from Services/Execution/ExecutionAbandonment.cs rename to FrySharp.Core/Services/Execution/ExecutionAbandonment.cs diff --git a/FrySharp.Core/Services/Execution/FileRunner.cs b/FrySharp.Core/Services/Execution/FileRunner.cs new file mode 100644 index 00000000..8098d989 --- /dev/null +++ b/FrySharp.Core/Services/Execution/FileRunner.cs @@ -0,0 +1,106 @@ +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Execution; + +/// What a file run reports, and how its input reaches it. +public sealed class FileRunOptions +{ + /// Everything the run prints (and its build steps), in order, on background threads. + public required Action OnOutput { get; init; } + + /// Displays (tables, images, charts…) a C# run makes; dropped when null. + public Action? OnDisplay { get; init; } + + /// What C# code reads from Console.In; nothing (end of input) when null. + public TextReader? CSharpInput { get; init; } + + /// The program of another language has started: input for it goes to . + public Action? OnProcessStarted { get; init; } + + /// Arguments for the program, each passed as its own argument. + public IReadOnlyList Arguments { get; init; } = []; + + /// The workspace the file belongs to, where toolchains look for a project's own environment. + public string? WorkspaceRoot { get; init; } +} + +/// How a file run ended: a process exit code (0 ran fine, 130 stopped, 127 couldn't start) and its problems. +public sealed record FileRunResult(int ExitCode, IReadOnlyList Problems); + +/// +/// Runs a source file the way the studio does (F5), for any host: C# in this process (), +/// any other language through its engine (its toolchain, then , then +/// ), with the build steps and program the desktop runs, arguments never joined into one string. +/// +public sealed class FileRunner(LanguageRegistry languages, RoslynCompilerService compiler, IProcessLauncher processes) +{ + /// True when can run files here. + public static bool CanRun(ILanguageEngine language) => + language.Id == LanguageIds.CSharp || (language.ScriptRunner != null && language.Toolchain != null); + + /// Runs the file at as . + /// The language can't run files here. + public async Task RunFileAsync(string fullPath, ILanguageEngine language, FileRunOptions options, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(language); + if (!CanRun(language)) throw new NotSupportedException($"{language.DisplayName} files can't be run here."); + + if (language.Id == LanguageIds.CSharp) + { + var code = await File.ReadAllTextAsync(fullPath, ct).ConfigureAwait(false); + return await RunCSharpAsync(code, options, ct).ConfigureAwait(false); + } + + var folder = Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory; + var resolution = await language.Toolchain!.ResolveAsync(new ToolchainQuery(folder, options.WorkspaceRoot), ct).ConfigureAwait(false); + if (resolution.Toolchain is not { } toolchain) + { + options.OnOutput((resolution.Missing?.ToText() ?? $"{language.Toolchain.ToolName} wasn't found.") + "\n"); + return new FileRunResult(127, []); + } + + var plan = await language.ScriptRunner!.PlanAsync(new ScriptRunContext(fullPath, folder, toolchain), ct).ConfigureAwait(false); + if (options.Arguments.Count > 0 && plan.Steps.Count > 0) + { + // Arguments are for the program, the last step: appended as arguments of their own. + var last = plan.Steps[^1]; + plan = new ScriptRunPlan([ + .. plan.Steps.Take(plan.Steps.Count - 1), + last with { Spec = last.Spec with { Arguments = [.. last.Spec.Arguments, .. options.Arguments] } }]); + } + + var session = new ScriptRunExecutor(processes).Start(plan, fullPath, language.RunDiagnostics, options.OnOutput, ct); + options.OnProcessStarted?.Invoke(session); + var result = await session.Completion.ConfigureAwait(false); + + if (result.StartError is { } startError) options.OnOutput($"\n❌ {startError}\n"); + if (result.FailedBuildStep is { } failedStep) options.OnOutput($"\n❌ {failedStep} failed.\n"); + + var exitCode = result.WasCancelled ? 130 : result.StartError != null ? 127 : result.ExitCode ?? 1; + return new FileRunResult(exitCode, result.Diagnostics.Diagnostics); + } + + /// Runs C# code (a file's text, or code typed without a file). + public async Task RunCSharpAsync(string code, FileRunOptions options, CancellationToken ct) + { + var result = await new InProcessCSharpRunner(compiler).RunAsync( + code, + options.OnOutput, + options.OnDisplay ?? (_ => { }), + options.CSharpInput, + ct).ConfigureAwait(false); + + // Compile and runtime errors are printed by the kernel and the engine as they happen; an abandoned run isn't. + if (result.WasAbandoned && result.Error is { Length: > 0 } error) options.OnOutput($"\n⚠️ {error}\n"); + var problems = result.Diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList(); + return new FileRunResult(result.ExitCode, problems); + } + + /// The language a file runs as, or null when no language here runs it. + public ILanguageEngine? LanguageOf(string path) => languages.LanguageOf(path); +} diff --git a/FrySharp.Core/Services/Execution/InProcessCSharpRunner.cs b/FrySharp.Core/Services/Execution/InProcessCSharpRunner.cs new file mode 100644 index 00000000..e5267f9f --- /dev/null +++ b/FrySharp.Core/Services/Execution/InProcessCSharpRunner.cs @@ -0,0 +1,91 @@ +using Microsoft.CodeAnalysis; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Execution; + +/// How a C# run ended. +public sealed record CSharpRunResult( + bool Success, + bool WasCancelled, + TimeSpan Elapsed, + IReadOnlyList Diagnostics, + string? Error = null, + bool WasAbandoned = false) +{ + /// The run as a process exit code: 0 when it succeeded, 130 when stopped, 1 otherwise. + public int ExitCode => Success ? 0 : WasCancelled ? 130 : 1; +} + +/// +/// Runs C# source in this process, as the desktop's Code Studio does: statements and expressions in a fresh +/// (the last value is shown, #r "nuget:…" works), a whole program (a class with +/// a Main) compiled and run by . Displays go to the onRichOutput callback of +/// . Stop cancels; code that ignores it is abandoned after , +/// because Roslyn can't preempt a blocked call. +/// +public sealed class InProcessCSharpRunner(RoslynCompilerService compiler) +{ + public async Task RunAsync( + string code, + Action onConsole, + Action onRichOutput, + TextReader? stdin, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(onConsole); + ArgumentNullException.ThrowIfNull(onRichOutput); + + // Displays made by a program's code reach the caller through this scope; the kernel hands its own to the callback. + using var displays = InteractiveDisplayContext.EnterScope(onRichOutput); + using var cancellation = InteractiveCancellationContext.EnterScope(ct); + + return RoslynCompilerService.IsExplicitProgram(code) + ? await RunProgramAsync(code, onConsole, stdin, ct).ConfigureAwait(false) + : await RunStatementsAsync(code, onConsole, onRichOutput, stdin, ct).ConfigureAwait(false); + } + + private async Task RunStatementsAsync(string code, Action onConsole, Action onRichOutput, TextReader? stdin, CancellationToken ct) + { + var kernel = new NotebookExecutionKernel(); + var execution = Task.Run(() => kernel.ExecuteCellAsync(code, onConsole, onRichOutput, ct, stdin: stdin), CancellationToken.None); + if (!await ExecutionAbandonment.WaitWithGraceAsync(execution, ct).ConfigureAwait(false)) + { + return Abandoned(); + } + + var result = await execution.ConfigureAwait(false); + return new CSharpRunResult(result.Success, result.WasCancelled, result.Elapsed, result.Diagnostics, + string.IsNullOrEmpty(result.ErrorMessage) ? null : result.ErrorMessage); + } + + private async Task RunProgramAsync(string code, Action onConsole, TextReader? stdin, CancellationToken ct) + { + var (success, bytes, diagnostics) = await Task.Run(() => compiler.CompileToAssembly(code, ExecutionLanguageMode.Program), ct).ConfigureAwait(false); + if (!success || bytes == null) + { + foreach (var diagnostic in diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)) + { + onConsole($"{diagnostic.LocationString}: {diagnostic.Id} {diagnostic.Message}\n"); + } + return new CSharpRunResult(false, false, TimeSpan.Zero, diagnostics, "Compilation failed."); + } + + var execution = new ScriptExecutionEngine().ExecuteAsync(bytes, onConsole, ct, stdin); + if (!await ExecutionAbandonment.WaitWithGraceAsync(execution, ct).ConfigureAwait(false)) + { + return Abandoned(); + } + + var result = await execution.ConfigureAwait(false); + return new CSharpRunResult(result.Success, result.WasCancelled, result.Elapsed, diagnostics, + string.IsNullOrEmpty(result.Error) ? null : result.Error); + } + + // The code ignored Stop: it keeps its thread until it returns, but the run is over for whoever waits on it. + private static CSharpRunResult Abandoned() => + new(false, true, TimeSpan.Zero, [], "The code did not respond to Stop and was abandoned.", WasAbandoned: true); +} diff --git a/Services/Execution/ScriptExecutionEngine.cs b/FrySharp.Core/Services/Execution/ScriptExecutionEngine.cs similarity index 100% rename from Services/Execution/ScriptExecutionEngine.cs rename to FrySharp.Core/Services/Execution/ScriptExecutionEngine.cs diff --git a/FrySharp.Core/Services/Kernels/CellDisposables.cs b/FrySharp.Core/Services/Kernels/CellDisposables.cs new file mode 100644 index 00000000..785a35d9 --- /dev/null +++ b/FrySharp.Core/Services/Kernels/CellDisposables.cs @@ -0,0 +1,52 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; + +/// +/// What a C# cell declared with using (see ): disposed when the cell has run, +/// last first, as a using declaration would at the end of its scope; also when the cell fails. The kernel opens a scope +/// around each cell; the cell's code adds to it. +/// +public static class CellDisposables +{ + private static readonly AsyncLocal Current = new(); + + /// Starts collecting a cell's disposables; disposing the scope disposes them. + public static Scope EnterScope() + { + var scope = new Scope(Current.Value); + Current.Value = scope; + return scope; + } + + /// Called by a cell's rewritten using var: disposes when the cell has run. + public static T Track(T value) + { + if (value is IAsyncDisposable or IDisposable) Current.Value?.Add(value); + return value; + } + + public sealed class Scope(Scope? outer) : IAsyncDisposable + { + private readonly Stack _owned = new(); + + internal void Add(object value) + { + lock (_owned) _owned.Push(value); + } + + public async ValueTask DisposeAsync() + { + Current.Value = outer; + while (true) + { + object next; + lock (_owned) + { + if (!_owned.TryPop(out next!)) return; + } + + if (next is IAsyncDisposable asyncDisposable) await asyncDisposable.DisposeAsync().ConfigureAwait(false); + else ((IDisposable)next).Dispose(); + } + } + } +} diff --git a/Services/Kernels/CellUsingDeclarations.cs b/FrySharp.Core/Services/Kernels/CellUsingDeclarations.cs similarity index 68% rename from Services/Kernels/CellUsingDeclarations.cs rename to FrySharp.Core/Services/Kernels/CellUsingDeclarations.cs index 095c257d..37551a4e 100644 --- a/Services/Kernels/CellUsingDeclarations.cs +++ b/FrySharp.Core/Services/Kernels/CellUsingDeclarations.cs @@ -8,9 +8,10 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; /// /// C# scripts can't hold a using var x = …; declaration at the top of a cell (it reads as a using directive), but -/// a notebook cell is where people write them. The declaration is made a plain one and what it declared is disposed when -/// the cell's statements have run, in reverse order, which is when a using declaration would have disposed it. -/// Line numbers stay as they were: nothing is added before the end of the cell. +/// a notebook cell is where people write them. The declaration is made a plain one, and what it declared is handed to +/// , which disposes it when the cell has run (last first, also when the cell fails), as a +/// using declaration would. A last expression without a semicolon stays the cell's result. Line numbers stay as they +/// were: the registration goes on the declaration's own line, after it. /// public static class CellUsingDeclarations { @@ -31,21 +32,16 @@ public static string Rewrite(string code) if (declarations.Count == 0) return code; var text = new StringBuilder(code); - var owned = new List<(string Name, bool Async)>(); foreach (var declaration in declarations.AsEnumerable().Reverse()) { - // The two keywords become spaces, so nothing after them moves. + // After the declaration, on its line: each variable is tracked by the cell's scope. + var tracking = string.Concat(declaration.Declaration.Variables.Select(v => + $" global::PdfEditorApp.Plugins.CSharpEditor.Services.Kernels.CellDisposables.Track(@{v.Identifier.ValueText});")); + text.Insert(declaration.Span.End, tracking); + + // The two keywords become spaces, so nothing after them on the line moves. Blank(text, declaration.UsingKeyword); if (declaration.AwaitKeyword != default) Blank(text, declaration.AwaitKeyword); - owned.InsertRange(0, declaration.Declaration.Variables.Select(v => (v.Identifier.ValueText, declaration.AwaitKeyword != default))); - } - - text.AppendLine(); - foreach (var (name, isAsync) in Enumerable.Reverse(owned)) - { - text.AppendLine(isAsync - ? $"if (@{name} is System.IAsyncDisposable __fryAsync) await __fryAsync.DisposeAsync(); else (@{name} as System.IDisposable)?.Dispose();" - : $"(@{name} as System.IDisposable)?.Dispose();"); } return text.ToString(); diff --git a/Services/Kernels/EmbeddedKernelFiles.cs b/FrySharp.Core/Services/Kernels/EmbeddedKernelFiles.cs similarity index 100% rename from Services/Kernels/EmbeddedKernelFiles.cs rename to FrySharp.Core/Services/Kernels/EmbeddedKernelFiles.cs diff --git a/Services/Kernels/IKernelLauncher.cs b/FrySharp.Core/Services/Kernels/IKernelLauncher.cs similarity index 100% rename from Services/Kernels/IKernelLauncher.cs rename to FrySharp.Core/Services/Kernels/IKernelLauncher.cs diff --git a/Services/Kernels/INotebookKernel.cs b/FrySharp.Core/Services/Kernels/INotebookKernel.cs similarity index 100% rename from Services/Kernels/INotebookKernel.cs rename to FrySharp.Core/Services/Kernels/INotebookKernel.cs diff --git a/FrySharp.Core/Services/Kernels/InputRequestReader.cs b/FrySharp.Core/Services/Kernels/InputRequestReader.cs new file mode 100644 index 00000000..88ff9aea --- /dev/null +++ b/FrySharp.Core/Services/Kernels/InputRequestReader.cs @@ -0,0 +1,49 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; + +/// +/// Console.In for a C# cell: each line the code reads is asked of the person running the notebook (an input box in +/// the cell, on the desktop or in the browser) through , as Python's +/// input() is. The cell's code reads synchronously on its own thread-pool thread, so waiting here holds only the +/// cell. No answer (the box was dismissed, or nobody is connected) is the end of input: ReadLine returns null. +/// +public sealed class InputRequestReader(Func> ask, CancellationToken ct) : TextReader +{ + // What Read and Peek have taken of the last answer, with its line break: Console.Read reads one character at a time. + private string _buffer = string.Empty; + private int _position; + private bool _ended; + + public override string? ReadLine() + { + if (_position < _buffer.Length) + { + var end = _buffer.IndexOf('\n', _position); + var rest = _buffer[_position..end]; + _position = end + 1; + return rest; + } + return Ask(); + } + + public override int Peek() => Fill() ? _buffer[_position] : -1; + + public override int Read() => Fill() ? _buffer[_position++] : -1; + + private bool Fill() + { + if (_position < _buffer.Length) return true; + if (Ask() is not { } line) return false; + _buffer = line + "\n"; + _position = 0; + return true; + } + + private string? Ask() + { + if (_ended) return null; + // The prompt is what the code wrote before reading (Console.Write("Name? ")), already in the cell's output. + var line = ask(string.Empty, false, ct).GetAwaiter().GetResult(); + if (line == null) _ended = true; + return line; + } +} diff --git a/Services/Kernels/KernelValueSharing.cs b/FrySharp.Core/Services/Kernels/KernelValueSharing.cs similarity index 98% rename from Services/Kernels/KernelValueSharing.cs rename to FrySharp.Core/Services/Kernels/KernelValueSharing.cs index aa72ec11..847fc4d9 100644 --- a/Services/Kernels/KernelValueSharing.cs +++ b/FrySharp.Core/Services/Kernels/KernelValueSharing.cs @@ -6,6 +6,7 @@ using System.Text.Json.Serialization; using Microsoft.CodeAnalysis.CSharp; using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; @@ -240,8 +241,7 @@ public static string TypeName(Type type) if (typeof(Stream).IsAssignableFrom(type)) return "a stream"; if (typeof(Task).IsAssignableFrom(type)) return "a task (await it first)"; if (typeof(Type).IsAssignableFrom(type) || typeof(MemberInfo).IsAssignableFrom(type) || typeof(Assembly).IsAssignableFrom(type)) return "a type or member"; - if (typeof(Avalonia.Visual).IsAssignableFrom(type)) return "a control"; - if (typeof(Avalonia.Media.IImage).IsAssignableFrom(type)) return "an image"; + if (ScriptHost.Profile.Display?.DescribeUnshareable(type) is { } hostKind) return hostKind; if (type == typeof(IntPtr) || type == typeof(UIntPtr) || typeof(System.Runtime.InteropServices.SafeHandle).IsAssignableFrom(type)) return "a handle"; return null; } diff --git a/Services/Kernels/MimeOutputMapper.cs b/FrySharp.Core/Services/Kernels/MimeOutputMapper.cs similarity index 95% rename from Services/Kernels/MimeOutputMapper.cs rename to FrySharp.Core/Services/Kernels/MimeOutputMapper.cs index a3d18598..2382debd 100644 --- a/Services/Kernels/MimeOutputMapper.cs +++ b/FrySharp.Core/Services/Kernels/MimeOutputMapper.cs @@ -81,12 +81,14 @@ public static KernelOutput Map(JsonElement data, JsonElement metadata, string? d return new KernelOutput(null, new RichCellOutput { Kind = CellOutputKind.Html, HtmlContent = htmlText }); } - foreach (var mime in new[] { "text/markdown", "text/plain" }) + if (data.TryGetProperty("text/markdown", out var markdown) && markdown.GetString() is { } markdownText) { - if (data.TryGetProperty(mime, out var text) && text.GetString() is { } plain) - { - return new KernelOutput(plain.EndsWith('\n') ? plain : plain + "\n", null); - } + return new KernelOutput(null, MarkdownOutput.Create(markdownText)); + } + + if (data.TryGetProperty("text/plain", out var text) && text.GetString() is { } plain) + { + return new KernelOutput(plain.EndsWith('\n') ? plain : plain + "\n", null); } return new KernelOutput(null, null); diff --git a/Services/Kernels/NotebookCellDirectives.cs b/FrySharp.Core/Services/Kernels/NotebookCellDirectives.cs similarity index 91% rename from Services/Kernels/NotebookCellDirectives.cs rename to FrySharp.Core/Services/Kernels/NotebookCellDirectives.cs index 37bd0bd2..21ad428f 100644 --- a/Services/Kernels/NotebookCellDirectives.cs +++ b/FrySharp.Core/Services/Kernels/NotebookCellDirectives.cs @@ -34,11 +34,11 @@ public sealed class CellDirectives /// public static class NotebookCellDirectives { - public static CellDirectives Parse(string? source, LanguageRegistry registry, string? defaultLanguageId) + public static CellDirectives Parse(string? source, ILanguageCatalog registry, string? defaultLanguageId) { source ??= string.Empty; var lines = source.Split('\n'); - var language = registry.Get(defaultLanguageId); + var language = registry.FindLanguage(defaultLanguageId); string? pickedLanguage = null; var shares = new List(); var packageCommands = new List(); @@ -62,7 +62,7 @@ public static CellDirectives Parse(string? source, LanguageRegistry registry, st if (TryParseShare(body, i + 1, out var share, out var error)) shares.Add(share); else errors.Add(error); } - else if (registry.Get(word) is { } named && named.NotebookKernels != null) + else if (registry.FindLanguage(word) is { } named && named.NotebookKernels != null) { pickedLanguage = named.Id; language = named; @@ -104,10 +104,10 @@ public static CellDirectives Parse(string? source, LanguageRegistry registry, st /// The language a cell's directives pick (#!python), or null; read by the same rules as /// but without taking the cell apart, so it's cheap enough to call on every keystroke. /// - public static string? LanguageOf(string? source, LanguageRegistry registry, string? defaultLanguageId) + public static string? LanguageOf(string? source, ILanguageCatalog registry, string? defaultLanguageId) { if (string.IsNullOrEmpty(source)) return null; - var language = registry.Get(defaultLanguageId); + var language = registry.FindLanguage(defaultLanguageId); string? picked = null; for (var start = 0; start <= source.Length;) { @@ -121,7 +121,7 @@ public static CellDirectives Parse(string? source, LanguageRegistry registry, st { if (trimmed.StartsWith("#!/", StringComparison.Ordinal)) continue; var word = FirstWord(trimmed[2..].Trim().ToString()); - if (word != "share" && registry.Get(word) is { NotebookKernels: not null } named) + if (word != "share" && registry.FindLanguage(word) is { NotebookKernels: not null } named) { picked = named.Id; language = named; @@ -188,6 +188,6 @@ private static string FirstWord(string text) // Keeps a "\r" line ending, so a CRLF cell keeps CRLF. private static string Blank(string line) => line.EndsWith('\r') ? "\r" : string.Empty; - private static string LanguageDirectives(LanguageRegistry registry) => - string.Join(" or ", registry.NotebookLanguages.Select(l => "#!" + l.Id)); + private static string LanguageDirectives(ILanguageCatalog registry) => + string.Join(" or ", registry.NotebookCellLanguages.Select(l => "#!" + l.Id)); } diff --git a/FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs b/FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs new file mode 100644 index 00000000..a5149079 --- /dev/null +++ b/FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs @@ -0,0 +1,112 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; + +/// +/// Runs what a cell's directives ask for before its code: package commands (#!pip install, #!npm install…) +/// and #!share --from copies of values from another language's kernel. Every host runs a notebook's cells through +/// it, so a directive behaves and fails the same way in each, with the same messages. +/// +public sealed class NotebookDirectiveRunner(ILanguageCatalog languages, NotebookKernelRouter router) +{ + /// True when can run notebook cells. + public static bool IsNotebookLanguage(ILanguageEngine language) => + language.NotebookKernels != null && language.Has(LanguageCapabilities.NotebookCells); + + /// Runs the directives in order; false (with the reason written) at the first that fails. + public async Task RunAsync( + CellDirectives directives, + ILanguageEngine language, + INotebookKernel kernel, + ToolchainQuery query, + Action write, + CancellationToken ct) + { + foreach (var command in directives.PackageCommands) + { + if (!await RunPackageCommandAsync(language, command, kernel, query, write, ct)) return false; + } + + foreach (var share in directives.Shares) + { + if (!await ShareAsync(share, language, kernel, write, ct)) return false; + } + + return true; + } + + /// Runs one package command (an install the user typed, or one for a missing dependency). + public async Task RunPackageCommandAsync( + ILanguageEngine language, + PackageCommand command, + INotebookKernel kernel, + ToolchainQuery query, + Action write, + CancellationToken ct) + { + if (language.Packages is not { } packages || language.Toolchain is not { } toolchains) + { + write($"❌ {language.DisplayName} cells can't install packages.\n"); + return false; + } + + var resolution = await toolchains.ResolveAsync(query, ct); + if (resolution.Toolchain is not { } toolchain) + { + write((resolution.Missing?.ToText() ?? $"{toolchains.ToolName} wasn't found.") + "\n"); + return false; + } + + write($"▶ {command.Text}\n"); + var result = await packages.RunAsync(command, toolchain, write, ct); + if (result.Message is { Length: > 0 } message) write((result.Success ? "" : "❌ ") + message.TrimEnd() + "\n"); + + // A new environment only takes over when the kernel next starts; until then the running one looks there too. + if (result.Success && result.AddedSearchPath is { } searchPath && kernel is ISearchPathKernel searchable && kernel.IsSessionActive) + { + await searchable.AddSearchPathAsync(searchPath, ct); + } + + return result.Success; + } + + private async Task ShareAsync(ShareDirective share, ILanguageEngine language, INotebookKernel kernel, Action write, CancellationToken ct) + { + var from = languages.FindLanguage(share.FromLanguage); + string? problem = null; + if (from == null || !IsNotebookLanguage(from)) + { + problem = $"There's no notebook language called \"{share.FromLanguage}\". Use {string.Join(" or ", languages.NotebookCellLanguages.Select(l => l.Id))}."; + } + else if (!from.Has(LanguageCapabilities.ValueSharing) || !language.Has(LanguageCapabilities.ValueSharing)) + { + problem = $"{(from.Has(LanguageCapabilities.ValueSharing) ? language.DisplayName : from.DisplayName)} cells can't share values."; + } + else if (string.Equals(from.Id, language.Id, StringComparison.OrdinalIgnoreCase)) + { + problem = $"'{share.Name}' is already a {language.DisplayName} value: #!share copies from another language's cells."; + } + else if (router.Find(from.Id) is not { IsSessionActive: true } source) + { + problem = $"No {from.DisplayName} cell has run yet, so there's no '{share.Name}' to copy. Run the {from.DisplayName} cell that sets it first."; + } + else + { + try + { + var json = await source.GetValueJsonAsync(share.Name, ct); + await kernel.SetValueFromJsonAsync(share.TargetName, json, ct); + return true; + } + catch (KernelValueException ex) + { + problem = ex.Message; + } + } + + write($"❌ #!share (line {share.LineNumber}): {problem}\n"); + return false; + } +} diff --git a/Services/Kernels/NotebookExecutionKernel.cs b/FrySharp.Core/Services/Kernels/NotebookExecutionKernel.cs similarity index 82% rename from Services/Kernels/NotebookExecutionKernel.cs rename to FrySharp.Core/Services/Kernels/NotebookExecutionKernel.cs index 5fd0eee3..b225ea51 100644 --- a/Services/Kernels/NotebookExecutionKernel.cs +++ b/FrySharp.Core/Services/Kernels/NotebookExecutionKernel.cs @@ -1,8 +1,6 @@ using System.Collections; using System.Diagnostics; using System.Reflection; -using Avalonia.Controls; -using Avalonia.Media.Imaging; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; @@ -119,13 +117,14 @@ public NotebookExecutionKernel() _scriptOptions = CachedDefaultScriptOptions.Value; } + // The host's assemblies (Display, and on the desktop Avalonia and FrySharp.Sdk) load as themselves, not as copies. private static void RegisterCoreDependencies(InteractiveAssemblyLoader loader) { - loader.RegisterDependency(typeof(Display.Display).Assembly); - loader.RegisterDependency(typeof(Control).Assembly); - loader.RegisterDependency(typeof(Bitmap).Assembly); + foreach (var assembly in ScriptHost.Profile.Assemblies) + { + loader.RegisterDependency(assembly); + } loader.RegisterDependency(typeof(System.Data.DataTable).Assembly); - loader.RegisterDependency(typeof(FrySharp.Sdk.IStudioApp).Assembly); } private static ScriptOptions CreateDefaultScriptOptionsInternal() @@ -238,6 +237,8 @@ private async Task ExecuteCellCoreAsync( using (InteractiveCancellationContext.EnterScope(ct)) using (_events.Enter()) { + // What the cell declared with `using` is disposed when it has run (see CellUsingDeclarations). + await using var cellDisposables = CellDisposables.EnterScope(); try { using (InteractiveDisplayContext.EnterScope(richOutput => @@ -349,110 +350,19 @@ private void InspectAndEmitReturnValue( return; } - if (returnValue is Control control) + var host = ScriptHost.Profile.Display; + if (host?.TryCreateElementOutput(returnValue) is { } element) { - onRichOutput?.Invoke(new RichCellOutput - { - Kind = CellOutputKind.Control, - InteractiveControl = control - }); + onRichOutput?.Invoke(element); return; } - if (returnValue is Bitmap bitmap) + if ((host?.TryEncodeImage(returnValue) ?? SkiaImages.TryEncodePng(returnValue)) is { } png) { - using var ms = new MemoryStream(); -#pragma warning disable CS0618 - bitmap.Save(ms); -#pragma warning restore CS0618 - var bmpBytes = ms.ToArray(); - onRichOutput?.Invoke(new RichCellOutput - { - Kind = CellOutputKind.Image, - ImageBytes = bmpBytes, - ImageFormat = "PNG", - ImageWidth = (int)bitmap.Size.Width, - ImageHeight = (int)bitmap.Size.Height - }); + onRichOutput?.Invoke(RichCellOutput.ForImage(png)); return; } - var typeName = returnValue.GetType().FullName ?? string.Empty; - - // SkiaSharp SKBitmap / SKImage / SKSurface - if (typeName.Contains("SkiaSharp")) - { - try - { - var encodeMethod = returnValue.GetType().GetMethod("Encode", Type.EmptyTypes); - if (encodeMethod != null) - { - var data = encodeMethod.Invoke(returnValue, null); - if (data != null) - { - var toArray = data.GetType().GetMethod("ToArray"); - if (toArray?.Invoke(data, null) is byte[] skBytes) - { - onRichOutput?.Invoke(new RichCellOutput - { - Kind = CellOutputKind.Image, - ImageBytes = skBytes, - ImageFormat = "PNG" - }); - return; - } - } - } - else if (typeName.Contains("SKBitmap")) - { - var skImageType = returnValue.GetType().Assembly.GetType("SkiaSharp.SKImage"); - var fromBitmap = skImageType?.GetMethod("FromBitmap", new[] { returnValue.GetType() }) - ?? skImageType?.GetMethods().FirstOrDefault(m => m.Name == "FromBitmap" && m.GetParameters().Length == 1); - if (fromBitmap?.Invoke(null, new[] { returnValue }) is object skImg) - { - var imgEncode = skImg.GetType().GetMethod("Encode", Type.EmptyTypes); - if (imgEncode?.Invoke(skImg, null) is object data) - { - var toArray = data.GetType().GetMethod("ToArray"); - if (toArray?.Invoke(data, null) is byte[] skBytes) - { - onRichOutput?.Invoke(new RichCellOutput - { - Kind = CellOutputKind.Image, - ImageBytes = skBytes, - ImageFormat = "PNG" - }); - return; - } - } - } - } - else if (typeName.Contains("SKSurface")) - { - var snapshot = returnValue.GetType().GetMethod("Snapshot")?.Invoke(returnValue, null); - if (snapshot != null) - { - var imgEncode = snapshot.GetType().GetMethod("Encode", Type.EmptyTypes); - if (imgEncode?.Invoke(snapshot, null) is object data) - { - var toArray = data.GetType().GetMethod("ToArray"); - if (toArray?.Invoke(data, null) is byte[] skBytes) - { - onRichOutput?.Invoke(new RichCellOutput - { - Kind = CellOutputKind.Image, - ImageBytes = skBytes, - ImageFormat = "PNG" - }); - return; - } - } - } - } - } - catch { } - } - if (returnValue is byte[] bytes && bytes.Length > 8 && (bytes[0] == 0x89 && bytes[1] == 0x50 || bytes[0] == 0xFF && bytes[1] == 0xD8)) { @@ -539,44 +449,44 @@ private static bool TryEmitVisualizer(object value) switch (value) { case VisualizerRecorder recorder: - Display.Display.Visualizer(recorder); + Scripting.Display.Visualizer(recorder); return true; case VisualizerSequence sequence: - Display.Display.Visualizer(new VisualizerOptions { Sequence = sequence, Kind = sequence.CurrentStep?.Kind ?? VisualizerKind.Matrix }); + Scripting.Display.Visualizer(new VisualizerOptions { Sequence = sequence, Kind = sequence.CurrentStep?.Kind ?? VisualizerKind.Matrix }); return true; case TreeTracker treeTracker: - Display.Display.Visualizer(treeTracker); + Scripting.Display.Visualizer(treeTracker); return true; case GraphTracker graphTracker: - Display.Display.Visualizer(graphTracker); + Scripting.Display.Visualizer(graphTracker); return true; case MatrixTracker matrixTracker: - Display.Display.Visualizer(matrixTracker); + Scripting.Display.Visualizer(matrixTracker); return true; case LinkedListTracker listTracker: - Display.Display.Visualizer(listTracker); + Scripting.Display.Visualizer(listTracker); return true; case RecursionTracker recursionTracker: - Display.Display.Visualizer(recursionTracker); + Scripting.Display.Visualizer(recursionTracker); return true; case IntervalTracker intervalTracker: - Display.Display.Visualizer(intervalTracker); + Scripting.Display.Visualizer(intervalTracker); return true; case TrieTracker trieTracker: - Display.Display.Visualizer(trieTracker); + Scripting.Display.Visualizer(trieTracker); return true; } switch (DataStructureDetector.Detect(value)) { case DataStructureShape.Tree: - Display.Display.Tree(value, title: value.GetType().Name); + Scripting.Display.Tree(value, title: value.GetType().Name); return true; case DataStructureShape.LinkedList: - Display.Display.LinkedList(value, title: value.GetType().Name); + Scripting.Display.LinkedList(value, title: value.GetType().Name); return true; case DataStructureShape.Grid: - Display.Display.Matrix(value); + Scripting.Display.Matrix(value); return true; default: return false; @@ -670,7 +580,8 @@ public void HardReset() bool INotebookKernel.CanForceStop => false; Task INotebookKernel.ExecuteAsync(KernelExecutionRequest request, CancellationToken ct) => - ExecuteCellAsync(request.Code, request.OnConsole, request.OnRichOutput, ct, request.SourceId); + ExecuteCellAsync(request.Code, request.OnConsole, request.OnRichOutput, ct, request.SourceId, + request.OnInputRequest is { } ask ? new InputRequestReader(ask, ct) : null); Task> INotebookKernel.GetVariablesAsync(CancellationToken ct) => Task.FromResult(GetActiveVariables()); @@ -757,8 +668,7 @@ private static string FormatValue(object? val) { return val.ToString() ?? ""; } - if (val is Control c) return $""; - if (val is Bitmap b) return $""; + if (ScriptHost.Profile.Display?.Describe(val) is { } host) return host.Text; var type = val.GetType(); if (type.FullName?.Contains("SkiaSharp") == true) @@ -781,8 +691,8 @@ private static string FormatValue(object? val) private static string DetermineKind(Type? type, object? val) { if (type == null || val == null) return "Value"; - if (val is Control) return "UI Control"; - if (val is Bitmap || type.FullName?.Contains("SkiaSharp") == true) return "Image"; + if (ScriptHost.Profile.Display?.Describe(val) is { } host) return host.Kind; + if (SkiaImages.IsSkiaImage(val)) return "Image"; if (val is IEnumerable and not string) return "Collection"; if (type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime)) return "Primitive"; return "Object"; diff --git a/Services/Kernels/NotebookKernelRouter.cs b/FrySharp.Core/Services/Kernels/NotebookKernelRouter.cs similarity index 89% rename from Services/Kernels/NotebookKernelRouter.cs rename to FrySharp.Core/Services/Kernels/NotebookKernelRouter.cs index e50260b9..314c27b7 100644 --- a/Services/Kernels/NotebookKernelRouter.cs +++ b/FrySharp.Core/Services/Kernels/NotebookKernelRouter.cs @@ -10,22 +10,22 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; /// public sealed class NotebookKernelRouter : IDisposable { - private readonly LanguageRegistry _registry; + private readonly ILanguageCatalog _languages; private readonly KernelCreationContext _context; private readonly INotebookKernel? _primary; private readonly object _gate = new(); private readonly Dictionary _kernels = new(StringComparer.OrdinalIgnoreCase); /// A kernel the notebook already has (C#), which stays alive across . - public NotebookKernelRouter(LanguageRegistry registry, KernelCreationContext context, INotebookKernel? primary = null) + public NotebookKernelRouter(ILanguageCatalog languages, KernelCreationContext context, INotebookKernel? primary = null) { - _registry = registry; + _languages = languages; _context = context; _primary = primary; if (primary != null) _kernels[primary.LanguageId] = primary; } - public LanguageRegistry Registry => _registry; + public ILanguageCatalog Languages => _languages; /// The kernels created so far. public IReadOnlyList Kernels @@ -39,7 +39,7 @@ public IReadOnlyList Kernels /// The kernel for a language, created on first use; null when the language can't run notebook cells. public INotebookKernel? GetOrCreate(string? languageId) { - var language = _registry.Get(languageId); + var language = _languages.FindLanguage(languageId); if (language?.NotebookKernels == null || !language.Has(LanguageCapabilities.NotebookCells)) return null; lock (_gate) @@ -54,7 +54,7 @@ public IReadOnlyList Kernels /// The language's kernel if one has been created, without creating it. public INotebookKernel? Find(string? languageId) { - var language = _registry.Get(languageId); + var language = _languages.FindLanguage(languageId); if (language == null) return null; lock (_gate) return _kernels.TryGetValue(language.Id, out var kernel) ? kernel : null; } @@ -96,7 +96,7 @@ public async Task> GetVariablesAsync(Cancell { try { - var tag = _registry.Get(kernel.LanguageId)?.DisplayName ?? kernel.LanguageId; + var tag = _languages.FindLanguage(kernel.LanguageId)?.DisplayName ?? kernel.LanguageId; foreach (var variable in await kernel.GetVariablesAsync(ct)) { variable.Kernel = tag; diff --git a/Services/Kernels/ProtocolKernel.Session.cs b/FrySharp.Core/Services/Kernels/ProtocolKernel.Session.cs similarity index 100% rename from Services/Kernels/ProtocolKernel.Session.cs rename to FrySharp.Core/Services/Kernels/ProtocolKernel.Session.cs diff --git a/Services/Kernels/ProtocolKernel.cs b/FrySharp.Core/Services/Kernels/ProtocolKernel.cs similarity index 100% rename from Services/Kernels/ProtocolKernel.cs rename to FrySharp.Core/Services/Kernels/ProtocolKernel.cs diff --git a/Services/Languages/CSharp/CSharpBuildAndRunScriptRunner.cs b/FrySharp.Core/Services/Languages/CSharp/CSharpBuildAndRunScriptRunner.cs similarity index 100% rename from Services/Languages/CSharp/CSharpBuildAndRunScriptRunner.cs rename to FrySharp.Core/Services/Languages/CSharp/CSharpBuildAndRunScriptRunner.cs diff --git a/Services/Languages/CSharp/CSharpCompilerDiagnosticParser.cs b/FrySharp.Core/Services/Languages/CSharp/CSharpCompilerDiagnosticParser.cs similarity index 100% rename from Services/Languages/CSharp/CSharpCompilerDiagnosticParser.cs rename to FrySharp.Core/Services/Languages/CSharp/CSharpCompilerDiagnosticParser.cs diff --git a/FrySharp.Core/Services/Languages/CSharp/CSharpEngine.cs b/FrySharp.Core/Services/Languages/CSharp/CSharpEngine.cs new file mode 100644 index 00000000..eab499ad --- /dev/null +++ b/FrySharp.Core/Services/Languages/CSharp/CSharpEngine.cs @@ -0,0 +1,43 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.CSharp; + +/// +/// C#, the studio's own language: compiled and run in-process by Roslyn. It has no toolchain or script runner; a host runs +/// it on its built-in path (), and notebook cells keep their state in a +/// . +/// +public sealed class CSharpEngine : LanguageEngine +{ + private static readonly string[] CSharpAliases = ["c#", "cs"]; + private static readonly string[] CSharpExtensions = [".frycs"]; + private static readonly INotebookKernelFactory Kernels = new DelegateKernelFactory(_ => new NotebookExecutionKernel()); + + public override string Id => LanguageIds.CSharp; + public override string DisplayName => "C#"; + public override JupyterLanguageInfo Jupyter { get; } = new(".net-csharp", ".NET (C#)", "C#", "csharp", ".cs", "text/x-csharp", "13.0"); + public override string ShortName => "C#"; + public override IReadOnlyList Aliases => CSharpAliases; + public override IReadOnlyList FileExtensions => CSharpExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.FryDocument; + + public override IPackageManager Packages { get; } = new NuGetPackageManager(); + public override IDiagnosticParser RunDiagnostics { get; } = new CSharpCompilerDiagnosticParser(); + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.Completion | LanguageCapabilities.QuickInfo | LanguageCapabilities.Formatting | + LanguageCapabilities.Debugging | LanguageCapabilities.Breakpoints | LanguageCapabilities.LiveDiagnostics | + LanguageCapabilities.TestCases | LanguageCapabilities.Templates | LanguageCapabilities.ExecutionModes | + LanguageCapabilities.NotebookCells | LanguageCapabilities.ValueSharing | + LanguageCapabilities.StandardInput | LanguageCapabilities.Packages; + + public override string IconKind => "LanguageCsharp"; + public override string AccentHex => "#58A6FF"; + public override string LineCommentPrefix => "//"; + public override string RuntimeDescription => "C# (.NET 10 Roslyn)"; + public override string NewFileTemplate => "// Write C# Statements or top-level code here\nConsole.WriteLine(\"Hello from FryPDF!\");"; + + public override INotebookKernelFactory NotebookKernels => Kernels; +} diff --git a/Services/Languages/CSharp/CSharpToolchainProvider.cs b/FrySharp.Core/Services/Languages/CSharp/CSharpToolchainProvider.cs similarity index 100% rename from Services/Languages/CSharp/CSharpToolchainProvider.cs rename to FrySharp.Core/Services/Languages/CSharp/CSharpToolchainProvider.cs diff --git a/Services/Languages/CSharp/NuGetPackageManager.cs b/FrySharp.Core/Services/Languages/CSharp/NuGetPackageManager.cs similarity index 100% rename from Services/Languages/CSharp/NuGetPackageManager.cs rename to FrySharp.Core/Services/Languages/CSharp/NuGetPackageManager.cs diff --git a/FrySharp.Core/Services/Languages/HeadlessLanguages.cs b/FrySharp.Core/Services/Languages/HeadlessLanguages.cs new file mode 100644 index 00000000..82484e8f --- /dev/null +++ b/FrySharp.Core/Services/Languages/HeadlessLanguages.cs @@ -0,0 +1,35 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.CSharp; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.JavaScript; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Python; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages; + +/// +/// The languages a host without the desktop runs (the web server, the CLI): each one whose engine lives in Core. A +/// language joins them when its engine moves here from the desktop plugin. +/// +public static class HeadlessLanguages +{ + /// + /// The files that run as C#. The C# engine's own extension is .frycs, a FrySharp document (JSON); a headless host + /// runs plain C# files. + /// + public static IReadOnlySet CSharpSourceExtensions { get; } = + new HashSet([".cs", ".csx"], StringComparer.OrdinalIgnoreCase); + + public static LanguageRegistry Create(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + var registry = new LanguageRegistry(); + registry.Register(new CSharpEngine()); + registry.Register(new PythonEngine(services)); + registry.Register(new JavaScriptEngine(services)); + return registry; + } + + /// The language a file runs as, or null when none of runs it. + public static ILanguageEngine? LanguageOf(this LanguageRegistry languages, string path) => + CSharpSourceExtensions.Contains(Path.GetExtension(path)) + ? languages.Get(LanguageIds.CSharp) + : languages.FindSourceFileLanguage(path); +} diff --git a/FrySharp.Core/Services/Languages/ILanguageEngine.cs b/FrySharp.Core/Services/Languages/ILanguageEngine.cs new file mode 100644 index 00000000..7391e629 --- /dev/null +++ b/FrySharp.Core/Services/Languages/ILanguageEngine.cs @@ -0,0 +1,110 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages; + +/// +/// Headless core definition for a language: execution plans, toolchain resolution, +/// package management, diagnostics, and storage. Free of any GUI or editor dependencies. +/// +public interface ILanguageEngine +{ + /// Lower-case id, e.g. python. Stored in notebook cells, so it must never change. + string Id { get; } + + string DisplayName { get; } + + /// Short chip text, e.g. "C#" or "PY". + string ShortName { get; } + + /// Other names accepted by #!name and #!share --from name, lower case. The id always works. + IReadOnlyList Aliases { get; } + + /// File extensions, lower case with the dot. The first one names new files. + IReadOnlyList FileExtensions { get; } + + LanguageStorageKind Storage { get; } + + /// True when the language relies on an ahead-of-time compiler (Java, C++, C) rather than a pure script interpreter. + bool IsCompiled { get; } + + LanguageCapabilities Capabilities { get; } + + /// A Material icon name (MaterialIconKind), e.g. LanguagePython. + string IconKind { get; } + + string AccentHex { get; } + + /// What Ctrl+/ puts in front of a line. + string LineCommentPrefix { get; } + + /// The text a new file of this language starts with. + string NewFileTemplate { get; } + + /// How the language runs, for the breadcrumbs when it has no toolchain to name (C#: "C# (.NET 10 Roslyn)"). + string RuntimeDescription { get; } + + /// Finds the program that runs this language, or null when it runs inside the studio (C#). + IToolchainProvider? Toolchain { get; } + + /// Turns a source file into the commands that build and run it, or null for the built-in C# runner. + IScriptRunner? ScriptRunner { get; } + + /// Reads errors out of a run's output for the Problems panel. + IDiagnosticParser? RunDiagnostics { get; } + + IPackageManager? Packages { get; } + + /// Creates the kernel that runs this language's notebook cells, or null when it has none. + INotebookKernelFactory? NotebookKernels { get; } + + /// How Jupyter knows the language, for .ipynb export; null when it has no Jupyter kernel. + JupyterLanguageInfo? Jupyter { get; } +} + +public static class LanguageEngineExtensions +{ + /// True when the language has every one of . + public static bool Has(this ILanguageEngine language, LanguageCapabilities capabilities) => + (language.Capabilities & capabilities) == capabilities; + + /// True for matching the id or an alias, ignoring case. + public static bool IsNamed(this ILanguageEngine language, string? name) => + !string.IsNullOrWhiteSpace(name) && + (string.Equals(language.Id, name.Trim(), StringComparison.OrdinalIgnoreCase) || + language.Aliases.Any(a => string.Equals(a, name.Trim(), StringComparison.OrdinalIgnoreCase))); + + public static string DefaultExtension(this ILanguageEngine language) => + language.FileExtensions.Count > 0 ? language.FileExtensions[0] : string.Empty; +} + +/// +/// The engine half of a language, with the defaults of a language that only has a name and file extensions: what a +/// language runs with, without the editor. The desktop adds the editor (highlighting, folding, debugger) around it. +/// +public abstract class LanguageEngine : ILanguageEngine +{ + public abstract string Id { get; } + public abstract string DisplayName { get; } + public virtual string ShortName => DisplayName; + public virtual IReadOnlyList Aliases => Array.Empty(); + public abstract IReadOnlyList FileExtensions { get; } + public virtual LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + public virtual bool IsCompiled => false; + public virtual LanguageCapabilities Capabilities => LanguageCapabilities.None; + public virtual string IconKind => "FileCodeOutline"; + public virtual string AccentHex => "#8B949E"; + public virtual string LineCommentPrefix => "#"; + public virtual string NewFileTemplate => string.Empty; + public virtual string RuntimeDescription => DisplayName; + public virtual IToolchainProvider? Toolchain => null; + public virtual IScriptRunner? ScriptRunner => null; + public virtual IDiagnosticParser? RunDiagnostics => null; + public virtual IPackageManager? Packages => null; + public virtual INotebookKernelFactory? NotebookKernels => null; + public virtual JupyterLanguageInfo? Jupyter => null; + + public override string ToString() => DisplayName; +} diff --git a/Services/Languages/JavaScript/JavaScriptDisplayRuntime.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptDisplayRuntime.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptDisplayRuntime.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptDisplayRuntime.cs diff --git a/FrySharp.Core/Services/Languages/JavaScript/JavaScriptEngine.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptEngine.cs new file mode 100644 index 00000000..03f8618c --- /dev/null +++ b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptEngine.cs @@ -0,0 +1,63 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.JavaScript; + +/// +/// JavaScript, run by the Node.js installed on this computer: .js files run like node file.js; notebook +/// cells run in a kernel process that keeps their state. +/// +public sealed class JavaScriptEngine : LanguageEngine +{ + private static readonly string[] JavaScriptAliases = ["js", "node", "nodejs"]; + private static readonly string[] JavaScriptExtensions = [".js", ".mjs", ".cjs"]; + + public JavaScriptEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + var toolchain = new JavaScriptToolchainProvider( + services.Host, + services.Processes, + services.ToolchainSettings, + Path.Combine(services.BaseDirectory, "javascript")); + JavaScriptToolchain = toolchain; + ScriptRunner = new JavaScriptScriptRunner(services.Host); + Packages = new NpmPackageManager(services.Processes, services.Host); + + var launcher = new JavaScriptKernelLauncher(toolchain, services.Host, Path.Combine(services.BaseDirectory, "javascript", "kernel")); + NotebookKernels = new DelegateKernelFactory(context => + new ProtocolKernel(LanguageIds.JavaScript, "JavaScript", launcher, services.Processes, context)); + } + + public JavaScriptToolchainProvider JavaScriptToolchain { get; } + + public override string Id => LanguageIds.JavaScript; + public override string DisplayName => "JavaScript"; + public override JupyterLanguageInfo Jupyter { get; } = new("javascript", "JavaScript", "javascript", "javascript", ".js", "application/javascript"); + public override string ShortName => "JS"; + public override IReadOnlyList Aliases => JavaScriptAliases; + public override IReadOnlyList FileExtensions => JavaScriptExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | LanguageCapabilities.Packages | + LanguageCapabilities.Debugging | LanguageCapabilities.Breakpoints; + + public override string IconKind => "LanguageJavascript"; + public override string AccentHex => "#F7DF1E"; + public override string LineCommentPrefix => "//"; + public override string RuntimeDescription => "JavaScript (Node.js)"; + + public override string NewFileTemplate => + "// Runs with the Node.js installed on this computer (F5).\n" + + "console.log(`Hello from JavaScript on Node.js ${process.version}!`);\n"; + + public override IToolchainProvider Toolchain => JavaScriptToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new JavaScriptTracebackParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/JavaScript/JavaScriptGuidance.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptGuidance.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptGuidance.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptGuidance.cs diff --git a/Services/Languages/JavaScript/JavaScriptKernelLauncher.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptKernelLauncher.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptKernelLauncher.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptKernelLauncher.cs diff --git a/Services/Languages/JavaScript/JavaScriptPackageMap.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptPackageMap.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptPackageMap.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptPackageMap.cs diff --git a/Services/Languages/JavaScript/JavaScriptScriptRunner.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptScriptRunner.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptScriptRunner.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptScriptRunner.cs diff --git a/Services/Languages/JavaScript/JavaScriptToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptToolchainProvider.Discovery.cs diff --git a/Services/Languages/JavaScript/JavaScriptToolchainProvider.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptToolchainProvider.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptToolchainProvider.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptToolchainProvider.cs diff --git a/Services/Languages/JavaScript/JavaScriptTracebackParser.cs b/FrySharp.Core/Services/Languages/JavaScript/JavaScriptTracebackParser.cs similarity index 100% rename from Services/Languages/JavaScript/JavaScriptTracebackParser.cs rename to FrySharp.Core/Services/Languages/JavaScript/JavaScriptTracebackParser.cs diff --git a/Services/Languages/JavaScript/Kernel/fry_kernel.js b/FrySharp.Core/Services/Languages/JavaScript/Kernel/fry_kernel.js similarity index 96% rename from Services/Languages/JavaScript/Kernel/fry_kernel.js rename to FrySharp.Core/Services/Languages/JavaScript/Kernel/fry_kernel.js index 3d5dc6f2..2367d9e8 100644 --- a/Services/Languages/JavaScript/Kernel/fry_kernel.js +++ b/FrySharp.Core/Services/Languages/JavaScript/Kernel/fry_kernel.js @@ -198,13 +198,15 @@ async function handleExecute(req) { try { let result; + // Rewrite top-level const and let to var so cell re-execution doesn't fail with "already declared" errors + const normalizedCode = code.replace(/^(\s*)(?:const|let)\s+/gm, "$1var "); if (code.includes("await ") || code.includes("await\n")) { // Async execution wrapper: attach top-level variable declarations to globalThis so they persist across cells - const transformed = code.replace(/^(const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/gm, "globalThis.$2 ="); + const transformed = normalizedCode.replace(/^(\s*)var\s+([A-Za-z_$][\w$]*)\s*=/gm, "$1globalThis.$2 ="); const wrapped = `(async () => {\n${transformed}\n})()`; result = await vm.runInContext(wrapped, context, { filename: req.cell ? `` : "" }); } else { - const script = new vm.Script(code, { filename: req.cell ? `` : "" }); + const script = new vm.Script(normalizedCode, { filename: req.cell ? `` : "" }); result = script.runInContext(context); } diff --git a/Services/Languages/JavaScript/NpmPackageManager.cs b/FrySharp.Core/Services/Languages/JavaScript/NpmPackageManager.cs similarity index 100% rename from Services/Languages/JavaScript/NpmPackageManager.cs rename to FrySharp.Core/Services/Languages/JavaScript/NpmPackageManager.cs diff --git a/Services/Languages/JavaScript/Runtime/fry.js b/FrySharp.Core/Services/Languages/JavaScript/Runtime/fry.js similarity index 100% rename from Services/Languages/JavaScript/Runtime/fry.js rename to FrySharp.Core/Services/Languages/JavaScript/Runtime/fry.js diff --git a/FrySharp.Core/Services/Languages/JupyterLanguageInfo.cs b/FrySharp.Core/Services/Languages/JupyterLanguageInfo.cs new file mode 100644 index 00000000..16dae8f9 --- /dev/null +++ b/FrySharp.Core/Services/Languages/JupyterLanguageInfo.cs @@ -0,0 +1,11 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages; + +/// A language as Jupyter knows it: the kernelspec and language_info of an exported .ipynb. +public sealed record JupyterLanguageInfo( + string KernelName, + string KernelDisplayName, + string KernelLanguage, + string LanguageName, + string FileExtension, + string MimeType, + string? Version = null); diff --git a/Services/Languages/LanguageCapabilities.cs b/FrySharp.Core/Services/Languages/LanguageCapabilities.cs similarity index 100% rename from Services/Languages/LanguageCapabilities.cs rename to FrySharp.Core/Services/Languages/LanguageCapabilities.cs diff --git a/FrySharp.Core/Services/Languages/LanguageEngineServices.cs b/FrySharp.Core/Services/Languages/LanguageEngineServices.cs new file mode 100644 index 00000000..58db5036 --- /dev/null +++ b/FrySharp.Core/Services/Languages/LanguageEngineServices.cs @@ -0,0 +1,36 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages; + +/// +/// What language engines share: the machine they're found on, the process launcher, the saved toolchain choices and +/// the data folder their kernels and environments are written to. Creating one touches nothing on disk. +/// +public sealed class LanguageEngineServices +{ + public LanguageEngineServices(string baseDirectory, IHostEnvironment host, IProcessLauncher processes, ToolchainSettingsStore toolchainSettings) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + BaseDirectory = baseDirectory; + Host = host ?? throw new ArgumentNullException(nameof(host)); + Processes = processes ?? throw new ArgumentNullException(nameof(processes)); + ToolchainSettings = toolchainSettings ?? throw new ArgumentNullException(nameof(toolchainSettings)); + } + + /// Services over with the real machine and process launcher. + public static LanguageEngineServices Create(string baseDirectory) => new( + baseDirectory, + new HostEnvironment(), + new ProcessLauncher(), + new ToolchainSettingsStore(Path.Combine(baseDirectory, "toolchains.json"))); + + /// Where toolchain settings, environments and kernel files live. + public string BaseDirectory { get; } + + public IHostEnvironment Host { get; } + + public IProcessLauncher Processes { get; } + + public ToolchainSettingsStore ToolchainSettings { get; } +} diff --git a/FrySharp.Core/Services/Languages/LanguageRegistry.cs b/FrySharp.Core/Services/Languages/LanguageRegistry.cs new file mode 100644 index 00000000..87dbe6fd --- /dev/null +++ b/FrySharp.Core/Services/Languages/LanguageRegistry.cs @@ -0,0 +1,146 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages; + +/// Languages by name, as notebook routing and #! directives read them, whatever host owns them. +public interface ILanguageCatalog +{ + /// The language with this id or alias ("python", "py", "c#"), or null. + ILanguageEngine? FindLanguage(string? idOrAlias); + + /// Languages a notebook cell can be written in. + IReadOnlyList NotebookCellLanguages { get; } +} + +/// +/// The languages a host knows, looked up by id, alias or file extension. The desktop keeps its full language +/// definitions (editor included) in one; a headless host keeps engines. +/// +public class LanguageRegistry : ILanguageCatalog where TLanguage : class, ILanguageEngine +{ + private readonly Lock _gate = new(); + private readonly List _languages = new(); + + public IReadOnlyList All + { + get + { + lock (_gate) return _languages.ToArray(); + } + } + + /// Languages kept as plain source files, in registration order (these get "New … File" entries). + public IReadOnlyList SourceFileLanguages => + All.Where(l => l.Storage == LanguageStorageKind.SourceFile && l.Id != LanguageIds.Text).ToArray(); + + /// Languages a notebook cell can be written in. + public IReadOnlyList NotebookLanguages => + All.Where(l => l.NotebookKernels != null && l.Has(LanguageCapabilities.NotebookCells)).ToArray(); + + /// Fired when a new language is registered. + public event Action? LanguageRegistered; + + /// Fired when an existing language is unregistered. + public event Action? LanguageUnregistered; + + /// Fired whenever languages are registered or unregistered. + public event Action? Changed; + + public IDisposable Register(TLanguage language) + { + ArgumentNullException.ThrowIfNull(language); + lock (_gate) + { + foreach (var existing in _languages) + { + if (existing.IsNamed(language.Id) || language.Aliases.Any(existing.IsNamed)) + { + throw new InvalidOperationException($"A language named '{language.Id}' is already registered ({existing.DisplayName})."); + } + + var clash = language.FileExtensions.FirstOrDefault(e => + existing.FileExtensions.Contains(e, StringComparer.OrdinalIgnoreCase)); + if (clash != null) + { + throw new InvalidOperationException($"'{clash}' files already belong to {existing.DisplayName}."); + } + } + + _languages.Add(language); + } + + LanguageRegistered?.Invoke(language); + Changed?.Invoke(); + return new RegistrationToken(this, language.Id); + } + + /// Unregisters a language by its ID or alias. Returns true if it was found and removed. + public bool Unregister(string? idOrAlias) + { + if (string.IsNullOrWhiteSpace(idOrAlias)) return false; + + TLanguage? removed = null; + lock (_gate) + { + var existing = _languages.FirstOrDefault(l => l.IsNamed(idOrAlias)); + if (existing != null) + { + _languages.Remove(existing); + removed = existing; + } + } + + if (removed != null) + { + LanguageUnregistered?.Invoke(removed); + Changed?.Invoke(); + return true; + } + + return false; + } + + private sealed class RegistrationToken(LanguageRegistry registry, string id) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + registry.Unregister(id); + } + } + } + + /// The language with this id or alias ("python", "py", "c#"), or null. + public TLanguage? Get(string? idOrAlias) + { + if (string.IsNullOrWhiteSpace(idOrAlias)) return null; + lock (_gate) return _languages.FirstOrDefault(l => l.IsNamed(idOrAlias)); + } + + /// The language a file belongs to, from a path or an extension (".py"), or null. + public TLanguage? FindByExtension(string? pathOrExtension) + { + if (string.IsNullOrWhiteSpace(pathOrExtension)) return null; + var extension = pathOrExtension.StartsWith('.') && pathOrExtension.IndexOfAny(['/', '\\']) < 0 + ? pathOrExtension + : Path.GetExtension(pathOrExtension); + if (string.IsNullOrEmpty(extension)) return null; + + lock (_gate) + { + return _languages.FirstOrDefault(l => l.FileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)); + } + } + + /// The source-file language for a path, or null when the file isn't a source file the studio opens as text. + public TLanguage? FindSourceFileLanguage(string? path) + { + var language = FindByExtension(path); + return language?.Storage == LanguageStorageKind.SourceFile ? language : null; + } + + ILanguageEngine? ILanguageCatalog.FindLanguage(string? idOrAlias) => Get(idOrAlias); + + IReadOnlyList ILanguageCatalog.NotebookCellLanguages => NotebookLanguages; +} diff --git a/Services/Languages/Python/Kernel/fry_kernel.py b/FrySharp.Core/Services/Languages/Python/Kernel/fry_kernel.py similarity index 100% rename from Services/Languages/Python/Kernel/fry_kernel.py rename to FrySharp.Core/Services/Languages/Python/Kernel/fry_kernel.py diff --git a/Services/Languages/Python/Kernel/fry_matplotlib.py b/FrySharp.Core/Services/Languages/Python/Kernel/fry_matplotlib.py similarity index 100% rename from Services/Languages/Python/Kernel/fry_matplotlib.py rename to FrySharp.Core/Services/Languages/Python/Kernel/fry_matplotlib.py diff --git a/Services/Languages/Python/PipPackageManager.cs b/FrySharp.Core/Services/Languages/Python/PipPackageManager.cs similarity index 100% rename from Services/Languages/Python/PipPackageManager.cs rename to FrySharp.Core/Services/Languages/Python/PipPackageManager.cs diff --git a/Services/Languages/Python/PythonDisplayRuntime.cs b/FrySharp.Core/Services/Languages/Python/PythonDisplayRuntime.cs similarity index 100% rename from Services/Languages/Python/PythonDisplayRuntime.cs rename to FrySharp.Core/Services/Languages/Python/PythonDisplayRuntime.cs diff --git a/FrySharp.Core/Services/Languages/Python/PythonEngine.cs b/FrySharp.Core/Services/Languages/Python/PythonEngine.cs new file mode 100644 index 00000000..9da72164 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Python/PythonEngine.cs @@ -0,0 +1,65 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Python; + +/// +/// Python, run by the Python installed on this computer, so every package installed for it works. .py files run +/// like python file.py; notebook cells run in a kernel process that keeps their state. +/// +public sealed class PythonEngine : LanguageEngine +{ + private static readonly string[] PythonAliases = ["py", "python3"]; + private static readonly string[] PythonExtensions = [".py"]; + + public PythonEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + var toolchain = new PythonToolchainProvider( + services.Host, + services.Processes, + services.ToolchainSettings, + Path.Combine(services.BaseDirectory, "python")); + PythonToolchain = toolchain; + ScriptRunner = new PythonScriptRunner(services.Host); + Packages = new PipPackageManager(toolchain, services.Processes, services.Host); + + // Each notebook gets its own kernel program, started with its first Python cell. + var launcher = new PythonKernelLauncher(toolchain, services.Host, Path.Combine(services.BaseDirectory, "python", "kernel")); + NotebookKernels = new DelegateKernelFactory(context => + new ProtocolKernel(LanguageIds.Python, "Python", launcher, services.Processes, context)); + } + + public PythonToolchainProvider PythonToolchain { get; } + + public override string Id => LanguageIds.Python; + public override string DisplayName => "Python"; + public override JupyterLanguageInfo Jupyter { get; } = new("python3", "Python 3", "python", "python", ".py", "text/x-python"); + public override string ShortName => "PY"; + public override IReadOnlyList Aliases => PythonAliases; + public override IReadOnlyList FileExtensions => PythonExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | LanguageCapabilities.Packages | + LanguageCapabilities.Debugging | LanguageCapabilities.Breakpoints; + + public override string IconKind => "LanguagePython"; + public override string AccentHex => "#4B8BBE"; + public override string LineCommentPrefix => "#"; + + public override string NewFileTemplate => + "# Runs with the Python installed on this computer (F5), so any package you've installed works here.\n" + + "import sys\n" + + "\n" + + "print(f\"Hello from Python {sys.version.split()[0]}!\")\n"; + + public override IToolchainProvider Toolchain => PythonToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new PythonTracebackParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/Python/PythonGuidance.cs b/FrySharp.Core/Services/Languages/Python/PythonGuidance.cs similarity index 100% rename from Services/Languages/Python/PythonGuidance.cs rename to FrySharp.Core/Services/Languages/Python/PythonGuidance.cs diff --git a/Services/Languages/Python/PythonKernelLauncher.cs b/FrySharp.Core/Services/Languages/Python/PythonKernelLauncher.cs similarity index 100% rename from Services/Languages/Python/PythonKernelLauncher.cs rename to FrySharp.Core/Services/Languages/Python/PythonKernelLauncher.cs diff --git a/Services/Languages/Python/PythonPackageMap.cs b/FrySharp.Core/Services/Languages/Python/PythonPackageMap.cs similarity index 100% rename from Services/Languages/Python/PythonPackageMap.cs rename to FrySharp.Core/Services/Languages/Python/PythonPackageMap.cs diff --git a/Services/Languages/Python/PythonScriptRunner.cs b/FrySharp.Core/Services/Languages/Python/PythonScriptRunner.cs similarity index 100% rename from Services/Languages/Python/PythonScriptRunner.cs rename to FrySharp.Core/Services/Languages/Python/PythonScriptRunner.cs diff --git a/Services/Languages/Python/PythonToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/Python/PythonToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/Python/PythonToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/Python/PythonToolchainProvider.Discovery.cs diff --git a/Services/Languages/Python/PythonToolchainProvider.cs b/FrySharp.Core/Services/Languages/Python/PythonToolchainProvider.cs similarity index 100% rename from Services/Languages/Python/PythonToolchainProvider.cs rename to FrySharp.Core/Services/Languages/Python/PythonToolchainProvider.cs diff --git a/Services/Languages/Python/PythonTracebackParser.cs b/FrySharp.Core/Services/Languages/Python/PythonTracebackParser.cs similarity index 100% rename from Services/Languages/Python/PythonTracebackParser.cs rename to FrySharp.Core/Services/Languages/Python/PythonTracebackParser.cs diff --git a/Services/Languages/Python/Runtime/fry.py b/FrySharp.Core/Services/Languages/Python/Runtime/fry.py similarity index 100% rename from Services/Languages/Python/Runtime/fry.py rename to FrySharp.Core/Services/Languages/Python/Runtime/fry.py diff --git a/Services/Languages/Python/Runtime/fry_channel.py b/FrySharp.Core/Services/Languages/Python/Runtime/fry_channel.py similarity index 100% rename from Services/Languages/Python/Runtime/fry_channel.py rename to FrySharp.Core/Services/Languages/Python/Runtime/fry_channel.py diff --git a/Services/Languages/Python/Runtime/fry_display.py b/FrySharp.Core/Services/Languages/Python/Runtime/fry_display.py similarity index 91% rename from Services/Languages/Python/Runtime/fry_display.py rename to FrySharp.Core/Services/Languages/Python/Runtime/fry_display.py index 57ff6cf2..a6f9eb17 100644 --- a/Services/Languages/Python/Runtime/fry_display.py +++ b/FrySharp.Core/Services/Languages/Python/Runtime/fry_display.py @@ -74,6 +74,12 @@ def to_mime(obj): except Exception: pass + if isinstance(obj, (list, tuple)) and len(obj) > 0 and isinstance(obj[0], dict): + try: + return {TABLE_MIME: records_table(obj), "text/plain": _text(obj)}, {} + except Exception: + pass + for mime, method in (("image/png", "_repr_png_"), ("image/jpeg", "_repr_jpeg_")): image = _call(obj, method) if isinstance(image, tuple): @@ -135,6 +141,26 @@ def table(frame): } +def records_table(records, title=None): + """A list of dicts as the studio's table.""" + cols = list(records[0].keys()) + numeric = [] + for c in cols: + val = records[0].get(c) + numeric.append(isinstance(val, (int, float)) and not isinstance(val, bool)) + rows = [] + for r in records[:MAX_ROWS]: + rows.append([str(r.get(c, "")) if r.get(c) is not None else None for c in cols]) + return { + "title": title or f"Records[{len(records)}]", + "columns": cols, + "numeric": numeric, + "rows": rows, + "totalRows": len(records), + "totalColumns": len(cols), + } + + def to_jsonable(value, depth=0): """A value as plain JSON types, for #!share: numpy arrays become lists, DataFrames lists of records.""" if depth > 32: diff --git a/Services/Languages/Python/Runtime/fry_specs.py b/FrySharp.Core/Services/Languages/Python/Runtime/fry_specs.py similarity index 100% rename from Services/Languages/Python/Runtime/fry_specs.py rename to FrySharp.Core/Services/Languages/Python/Runtime/fry_specs.py diff --git a/Services/Packages/IPackageManager.cs b/FrySharp.Core/Services/Packages/IPackageManager.cs similarity index 100% rename from Services/Packages/IPackageManager.cs rename to FrySharp.Core/Services/Packages/IPackageManager.cs diff --git a/Services/Processes/AsciiTableDetector.cs b/FrySharp.Core/Services/Processes/AsciiTableDetector.cs similarity index 100% rename from Services/Processes/AsciiTableDetector.cs rename to FrySharp.Core/Services/Processes/AsciiTableDetector.cs diff --git a/Services/Processes/InteractiveStdinReader.cs b/FrySharp.Core/Services/Processes/InteractiveStdinReader.cs similarity index 100% rename from Services/Processes/InteractiveStdinReader.cs rename to FrySharp.Core/Services/Processes/InteractiveStdinReader.cs diff --git a/Services/Processes/ManagedProcessExtensions.cs b/FrySharp.Core/Services/Processes/ManagedProcessExtensions.cs similarity index 100% rename from Services/Processes/ManagedProcessExtensions.cs rename to FrySharp.Core/Services/Processes/ManagedProcessExtensions.cs diff --git a/Services/Processes/ProcessLauncher.cs b/FrySharp.Core/Services/Processes/ProcessLauncher.cs similarity index 100% rename from Services/Processes/ProcessLauncher.cs rename to FrySharp.Core/Services/Processes/ProcessLauncher.cs diff --git a/Services/Processes/ProcessRegistry.cs b/FrySharp.Core/Services/Processes/ProcessRegistry.cs similarity index 100% rename from Services/Processes/ProcessRegistry.cs rename to FrySharp.Core/Services/Processes/ProcessRegistry.cs diff --git a/Services/Processes/ScriptRunExecutor.cs b/FrySharp.Core/Services/Processes/ScriptRunExecutor.cs similarity index 100% rename from Services/Processes/ScriptRunExecutor.cs rename to FrySharp.Core/Services/Processes/ScriptRunExecutor.cs diff --git a/Services/Processes/ScriptRunner.cs b/FrySharp.Core/Services/Processes/ScriptRunner.cs similarity index 100% rename from Services/Processes/ScriptRunner.cs rename to FrySharp.Core/Services/Processes/ScriptRunner.cs diff --git a/Services/Processes/TerminalTextBuffer.cs b/FrySharp.Core/Services/Processes/TerminalTextBuffer.cs similarity index 100% rename from Services/Processes/TerminalTextBuffer.cs rename to FrySharp.Core/Services/Processes/TerminalTextBuffer.cs diff --git a/Services/Processes/WindowsJobObject.cs b/FrySharp.Core/Services/Processes/WindowsJobObject.cs similarity index 100% rename from Services/Processes/WindowsJobObject.cs rename to FrySharp.Core/Services/Processes/WindowsJobObject.cs diff --git a/Services/Roslyn/CSharpClassifier.cs b/FrySharp.Core/Services/Roslyn/CSharpClassifier.cs similarity index 99% rename from Services/Roslyn/CSharpClassifier.cs rename to FrySharp.Core/Services/Roslyn/CSharpClassifier.cs index 54addb5e..586023b7 100644 --- a/Services/Roslyn/CSharpClassifier.cs +++ b/FrySharp.Core/Services/Roslyn/CSharpClassifier.cs @@ -9,7 +9,7 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; /// /// What a piece of C# is, for colouring: the roles VS Code gives C# with semantic highlighting on (Dark+ and Light+). -/// Each role is a named colour of the C# highlighting definition (). +/// Each role is a named colour of the C# highlighting definition (the desktop's CSharpSyntaxHighlightingTheme). /// public enum CSharpRole { diff --git a/Services/Roslyn/CSharpCompletionService.cs b/FrySharp.Core/Services/Roslyn/CSharpCompletionService.cs similarity index 100% rename from Services/Roslyn/CSharpCompletionService.cs rename to FrySharp.Core/Services/Roslyn/CSharpCompletionService.cs diff --git a/Services/Roslyn/CSharpQuickInfoService.cs b/FrySharp.Core/Services/Roslyn/CSharpQuickInfoService.cs similarity index 100% rename from Services/Roslyn/CSharpQuickInfoService.cs rename to FrySharp.Core/Services/Roslyn/CSharpQuickInfoService.cs diff --git a/Services/Roslyn/NuGetReferenceResolver.cs b/FrySharp.Core/Services/Roslyn/NuGetReferenceResolver.cs similarity index 100% rename from Services/Roslyn/NuGetReferenceResolver.cs rename to FrySharp.Core/Services/Roslyn/NuGetReferenceResolver.cs diff --git a/Services/Roslyn/QuickInfoSignatureBuilder.cs b/FrySharp.Core/Services/Roslyn/QuickInfoSignatureBuilder.cs similarity index 100% rename from Services/Roslyn/QuickInfoSignatureBuilder.cs rename to FrySharp.Core/Services/Roslyn/QuickInfoSignatureBuilder.cs diff --git a/Services/Roslyn/RoslynCompilerService.cs b/FrySharp.Core/Services/Roslyn/RoslynCompilerService.cs similarity index 77% rename from Services/Roslyn/RoslynCompilerService.cs rename to FrySharp.Core/Services/Roslyn/RoslynCompilerService.cs index 3fadaf9a..361c8f65 100644 --- a/Services/Roslyn/RoslynCompilerService.cs +++ b/FrySharp.Core/Services/Roslyn/RoslynCompilerService.cs @@ -16,7 +16,7 @@ public enum ExecutionLanguageMode Expression } -public class RoslynCompilerService +public partial class RoslynCompilerService { /// The namespaces every script, program and statement cell compiles with (see ). public static string DefaultScriptUsings => ScriptImports.GlobalUsingDirectives; @@ -67,15 +67,11 @@ private static (List References, List typeof(HttpClient).Assembly.Location, // System.Net.Http typeof(Uri).Assembly.Location, // System.Private.Uri (required transitively by System.Net.Http types) typeof(System.Net.HttpStatusCode).Assembly.Location, // System.Net.Primitives (required transitively by System.Net.Http types) - typeof(Display.Display).Assembly.Location, // Plugin Assembly (Display, DumpExtensions) - typeof(Avalonia.Controls.Control).Assembly.Location, // Avalonia Controls - typeof(Avalonia.Media.Imaging.Bitmap).Assembly.Location, // Avalonia Media + typeof(RoslynCompilerService).Assembly.Location, // FrySharp.Core typeof(System.Data.DataTable).Assembly.Location, // System.Data.Common typeof(System.Threading.SendOrPostCallback).Assembly.Location, // System.Threading (CS0012 SendOrPostCallback) typeof(System.ComponentModel.INotifyPropertyChanged).Assembly.Location, // System.ObjectModel (CS0012 INotifyPropertyChanged) typeof(System.ComponentModel.Component).Assembly.Location, // System.ComponentModel.Primitives - typeof(Avalonia.AvaloniaObject).Assembly.Location, // Avalonia.Base - typeof(FrySharp.Sdk.IStudioApp).Assembly.Location, // FrySharp.Sdk Path.Combine(coreDir, "System.Runtime.dll"), // System.Runtime Path.Combine(coreDir, "System.Collections.dll"), // System.Collections Path.Combine(coreDir, "System.Collections.NonGeneric.dll"), // System.Collections.NonGeneric @@ -94,64 +90,65 @@ private static (List References, List Path.Combine(coreDir, "netstandard.dll") // netstandard }; - foreach (var path in systemAssemblies) + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + void Add(string path, string description, bool isSystem, bool listed) { - if (File.Exists(path)) + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; + var name = Path.GetFileNameWithoutExtension(path); + if (!seen.Add(name)) return; + try { - try - { - var name = Path.GetFileNameWithoutExtension(path); - if (!items.Any(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) - { - references.Add(MetadataReference.CreateFromFile(path)); - items.Add(new AssemblyReferenceItem - { - Name = name, - AssemblyPath = path, - Description = "Standard .NET runtime assembly", - IsEnabled = true, - IsSystem = true - }); - } - } - catch + references.Add(MetadataReference.CreateFromFile(path)); + } + catch (Exception ex) when (ex is IOException or BadImageFormatException or UnauthorizedAccessException) + { + seen.Remove(name); + return; + } + + if (listed) + { + items.Add(new AssemblyReferenceItem { - // Ignore unresolvable reference - } + Name = name, + AssemblyPath = path, + Description = description, + IsEnabled = true, + IsSystem = isSystem + }); } } - // Add any assemblies already loaded in the host AppDomain (Avalonia, FrySharp plugins) - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + foreach (var path in systemAssemblies) + { + Add(path, "Standard .NET runtime assembly", isSystem: true, listed: true); + } + + // The host's own assemblies (the desktop plugin and Avalonia, or Core alone), listed in the References panel. + foreach (var assembly in ScriptHost.Profile.Assemblies) { - if (!asm.IsDynamic && !string.IsNullOrEmpty(asm.Location) && File.Exists(asm.Location)) + Add(assembly.Location, "Host assembly", isSystem: false, listed: true); + } + + // Everything else the host process can load by default: the rest of .NET and the app's dependencies. Taken from + // the trusted platform assemblies, so a script compiles the same however many assemblies happen to be loaded yet. + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string trusted) + { + foreach (var path in trusted.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { - try - { - var name = Path.GetFileNameWithoutExtension(asm.Location); - if (!items.Any(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) - { - references.Add(MetadataReference.CreateFromFile(asm.Location)); - items.Add(new AssemblyReferenceItem - { - Name = name, - AssemblyPath = asm.Location, - Description = "Runtime loaded assembly", - IsEnabled = true, - IsSystem = true - }); - } - } - catch - { - // Ignore unresolvable reference - } + Add(path, "Host process assembly", isSystem: true, listed: false); } } return (references, items); } + /// True when the code is a whole program (a class with a Main, or a namespace) rather than statements. + public static bool IsExplicitProgram(string code) => + (code.Contains("class ") && (code.Contains("static void Main") || code.Contains("static async Task Main") || code.Contains("static Task Main") || code.Contains("static int Main"))) || + code.Contains("namespace "); + public string WrapSourceCode(string rawCode, ExecutionLanguageMode mode) { if (string.IsNullOrWhiteSpace(rawCode)) @@ -160,9 +157,7 @@ public string WrapSourceCode(string rawCode, ExecutionLanguageMode mode) } // 1. Auto-detect if raw code is already a complete Program with an entry point - var isExplicitProgram = mode == ExecutionLanguageMode.Program || - (rawCode.Contains("class ") && (rawCode.Contains("static void Main") || rawCode.Contains("static async Task Main") || rawCode.Contains("static Task Main") || rawCode.Contains("static int Main"))) || - rawCode.Contains("namespace "); + var isExplicitProgram = mode == ExecutionLanguageMode.Program || IsExplicitProgram(rawCode); if (isExplicitProgram) { @@ -315,21 +310,9 @@ public IReadOnlyList CheckDiagnostics(string sourceCode, Executi return Array.Empty(); } - var codeToAnalyze = WrapSourceCode(sourceCode, mode); - var syntaxTree = CSharpSyntaxTree.ParseText(codeToAnalyze); - var compilation = CSharpCompilation.Create( - $"ScriptAnalysis_{Guid.NewGuid():N}", - syntaxTrees: [syntaxTree], - references: _defaultReferences, - options: new CSharpCompilationOptions( - OutputKind.ConsoleApplication, - optimizationLevel: OptimizationLevel.Debug, - allowUnsafe: false, - specificDiagnosticOptions: new Dictionary - { - ["CS0105"] = ReportDiagnostic.Suppress, - ["CS8933"] = ReportDiagnostic.Suppress - })); + var compilation = mode == ExecutionLanguageMode.Statements && !IsExplicitProgram(sourceCode) + ? ScriptAnalysisCompilation(sourceCode) + : ProgramAnalysisCompilation(sourceCode, mode); var diagnostics = compilation.GetDiagnostics(); var results = new List(); @@ -369,6 +352,47 @@ public IReadOnlyList CheckDiagnostics(string sourceCode, Executi return results.OrderByDescending(d => d.Severity).ThenBy(d => d.Line).ToList(); } + private static readonly Dictionary AnalysisDiagnosticOptions = new() + { + ["CS0105"] = ReportDiagnostic.Suppress, + ["CS8933"] = ReportDiagnostic.Suppress + }; + + // A program (or an expression) as it is compiled to run: wrapped, with the imports as global usings. + private CSharpCompilation ProgramAnalysisCompilation(string sourceCode, ExecutionLanguageMode mode) => + CSharpCompilation.Create( + $"ScriptAnalysis_{Guid.NewGuid():N}", + syntaxTrees: [CSharpSyntaxTree.ParseText(WrapSourceCode(sourceCode, mode))], + references: _defaultReferences, + options: new CSharpCompilationOptions( + OutputKind.ConsoleApplication, + optimizationLevel: OptimizationLevel.Debug, + allowUnsafe: false, + specificDiagnosticOptions: AnalysisDiagnosticOptions)); + + // Statements as the kernel runs them: a C# script, where a last expression without a semicolon is the result and + // types may sit anywhere. Prepared the same way (#r lines out, using declarations rewritten); lines don't move. + private CSharpCompilation ScriptAnalysisCompilation(string sourceCode) + { + var prepared = Kernels.CellUsingDeclarations.Rewrite(ReferenceDirective().Replace(sourceCode, string.Empty)); + var tree = CSharpSyntaxTree.ParseText(prepared, + new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script), path: "script.cs"); + return CSharpCompilation.CreateScriptCompilation( + $"ScriptAnalysis_{Guid.NewGuid():N}", + tree, + _defaultReferences, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + usings: ScriptImports.ScriptingImports, + optimizationLevel: OptimizationLevel.Debug, + allowUnsafe: false, + specificDiagnosticOptions: AnalysisDiagnosticOptions)); + } + + // #r "nuget: …" and #r "path" lines: the kernel resolves them before compiling; analysis leaves the line empty. + [GeneratedRegex(@"(?m)^[ \t]*#r[ \t]+""[^""\r\n]*""[ \t]*$")] + private static partial Regex ReferenceDirective(); + public (bool Success, byte[]? AssemblyBytes, IReadOnlyList Diagnostics) CompileToAssembly( string sourceCode, ExecutionLanguageMode mode = ExecutionLanguageMode.Statements) diff --git a/FrySharp.Core/Services/Roslyn/ScriptHostProfile.cs b/FrySharp.Core/Services/Roslyn/ScriptHostProfile.cs new file mode 100644 index 00000000..4221f765 --- /dev/null +++ b/FrySharp.Core/Services/Roslyn/ScriptHostProfile.cs @@ -0,0 +1,136 @@ +using System.Reflection; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; + +/// +/// What user C# compiles against in one host: the host's own assemblies, and the namespaces and types that a script, a +/// program with a Main, a notebook cell and a debugger watch all see without a using directive. The desktop studio and +/// the headless hosts (web server, CLI) give scripts different APIs, so each host states its profile once, before the +/// first compile, with . +/// +public sealed class ScriptHostProfile +{ + public ScriptHostProfile( + string name, + IEnumerable assemblies, + IEnumerable namespaces, + IEnumerable? staticTypes = null, + IScriptHostDisplay? display = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(assemblies); + ArgumentNullException.ThrowIfNull(namespaces); + + Name = name; + Assemblies = assemblies.Distinct().ToArray(); + Namespaces = namespaces.Distinct(StringComparer.Ordinal).ToArray(); + StaticTypes = (staticTypes ?? []).Distinct(StringComparer.Ordinal).ToArray(); + Display = display; + + if (Assemblies.FirstOrDefault(a => a.IsDynamic) is { } dynamicAssembly) + throw new ArgumentException($"A dynamic assembly ({dynamicAssembly.GetName().Name}) has no file to compile against.", nameof(assemblies)); + } + + /// Names the profile in errors, e.g. "desktop". + public string Name { get; } + + /// The host's assemblies that scripts reference, besides .NET and what the host process can load. + public IReadOnlyList Assemblies { get; } + + /// The imported namespaces. + public IReadOnlyList Namespaces { get; } + + /// Types imported with using static, so their members need no prefix. + public IReadOnlyList StaticTypes { get; } + + /// What only this host's UI can show (desktop controls and bitmaps), or null. + public IScriptHostDisplay? Display { get; } + + /// The .NET namespaces every host imports. + public static IReadOnlyList SystemNamespaces { get; } = + [ + "System", + "System.Collections", + "System.Collections.Generic", + "System.Data", + "System.Diagnostics", + "System.IO", + "System.Linq", + "System.Net.Http", + "System.Text", + "System.Text.Json", + "System.Text.RegularExpressions", + "System.Threading.Tasks", + ]; + + /// The profile of a host without the desktop studio: the CLI, the web server and tests of Core. + public static ScriptHostProfile Headless { get; } = new( + "headless", + [typeof(ScriptHostProfile).Assembly], + [ + .. SystemNamespaces, + "PdfEditorApp.Plugins.CSharpEditor.Scripting", // Display + "PdfEditorApp.Plugins.CSharpEditor.Services.Display", // Dump, EChart, Charts + "PdfEditorApp.Plugins.CSharpEditor.Services.Execution", + "PdfEditorApp.Plugins.CSharpEditor.Models", + "PdfEditorApp.Plugins.CSharpEditor.Visuals.Spec", + "PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction", + "PdfEditorApp.Plugins.CSharpEditor.Charting.Models", + "PdfEditorApp.Plugins.CSharpEditor.Charting3D.Models", + "PdfEditorApp.Plugins.CSharpEditor.Charting3D.Spatial", + "PdfEditorApp.Plugins.CSharpEditor.Charting3D.Layouts", + "PdfEditorApp.Plugins.CSharpEditor.Visualizers.Models", + "PdfEditorApp.Plugins.CSharpEditor.Visualizers.Services", + ]); +} + +/// +/// The process's . A host configures it once at start-up. The first compile fixes it, because +/// the compiler, completion and hover cache the imports and references it gives. Without a configured profile, scripts +/// compile against . +/// +public static class ScriptHost +{ + private static readonly Lock Gate = new(); + private static ScriptHostProfile? _configured; + private static volatile ScriptHostProfile? _active; + + /// The profile scripts compile against. Reading it fixes it. + public static ScriptHostProfile Profile + { + get + { + if (_active is { } active) return active; + lock (Gate) + { + return _active ??= _configured ?? ScriptHostProfile.Headless; + } + } + } + + /// + /// Sets the profile. Calling it again with the same profile does nothing. A different profile after the first one, + /// or after the first compile, throws: two hosts in one process would give scripts two APIs. + /// + public static void Configure(ScriptHostProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + lock (Gate) + { + var current = _active ?? _configured; + if (current == null) + { + _configured = profile; + return; + } + + if (!ReferenceEquals(current, profile)) + { + throw new InvalidOperationException( + $"Scripts already use the '{current.Name}' profile, so the '{profile.Name}' profile can't be configured. " + + "Configure the host's profile once, before the first compile."); + } + } + } +} diff --git a/FrySharp.Core/Services/Roslyn/ScriptImports.cs b/FrySharp.Core/Services/Roslyn/ScriptImports.cs new file mode 100644 index 00000000..b1eb9957 --- /dev/null +++ b/FrySharp.Core/Services/Roslyn/ScriptImports.cs @@ -0,0 +1,30 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; + +/// +/// The namespaces user C# sees without a using directive, the same everywhere it runs: a script, a program with a Main, +/// a notebook cell and a watch expression in the debugger. Code that compiles in one of them compiles in all of them. +/// They come from the host's , read once. +/// +public static class ScriptImports +{ + private static readonly Lazy GlobalUsings = new(() => string.Join("\n", + Namespaces.Select(name => $"global using {name};") + .Concat(StaticTypes.Select(type => $"global using static {type};")))); + + private static readonly Lazy> ScriptingImportList = new(() => [.. Namespaces, .. StaticTypes]); + + /// The imported namespaces. + public static IReadOnlyList Namespaces => ScriptHost.Profile.Namespaces; + + /// Types imported with using static, e.g. Check(...) on the desktop. + public static IReadOnlyList StaticTypes => ScriptHost.Profile.StaticTypes; + + /// + /// The imports as global using directives, for code compiled as a file. A user's own using System; then + /// repeats a global using, which the compiler only notes (CS8933), instead of warning about a duplicate (CS0105). + /// + public static string GlobalUsingDirectives => GlobalUsings.Value; + + /// The imports for Roslyn scripting (ScriptOptions.WithImports), where a type name is a static import. + public static IReadOnlyList ScriptingImports => ScriptingImportList.Value; +} diff --git a/Services/Roslyn/XmlDocumentationLookup.cs b/FrySharp.Core/Services/Roslyn/XmlDocumentationLookup.cs similarity index 100% rename from Services/Roslyn/XmlDocumentationLookup.cs rename to FrySharp.Core/Services/Roslyn/XmlDocumentationLookup.cs diff --git a/Services/Roslyn/XmlDocumentationParser.cs b/FrySharp.Core/Services/Roslyn/XmlDocumentationParser.cs similarity index 100% rename from Services/Roslyn/XmlDocumentationParser.cs rename to FrySharp.Core/Services/Roslyn/XmlDocumentationParser.cs diff --git a/FrySharp.Core/Services/Storage/AtomicFile.cs b/FrySharp.Core/Services/Storage/AtomicFile.cs new file mode 100644 index 00000000..71afc4b9 --- /dev/null +++ b/FrySharp.Core/Services/Storage/AtomicFile.cs @@ -0,0 +1,66 @@ +using System.Text; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Storage; + +/// +/// Saves a file so that it is never left half-written: the text goes to a temporary file next to it, which then replaces +/// it in one rename. A crash, a full disk or a second writer leaves the old file or the new one, never a mix. A file that +/// is a symbolic link is written where the link points (the link stays a link), and the file's permissions (an +/// executable script stays executable) are kept. +/// +public static class AtomicFile +{ + public static async Task WriteAllTextAsync(string path, string text, Encoding encoding, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(encoding); + + var target = FinalTarget(path); + var folder = Path.GetDirectoryName(target) ?? throw new IOException($"'{path}' has no folder."); + Directory.CreateDirectory(folder); + var temp = Path.Combine(folder, $".{Path.GetFileName(target)}.{Guid.NewGuid():N}.tmp"); + + try + { + await using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous)) + { + var preamble = encoding.GetPreamble(); + if (preamble.Length > 0) await stream.WriteAsync(preamble, ct).ConfigureAwait(false); + await stream.WriteAsync(encoding.GetBytes(text), ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + if (!OperatingSystem.IsWindows() && File.Exists(target)) + { + File.SetUnixFileMode(temp, File.GetUnixFileMode(target)); + } + + File.Move(temp, target, overwrite: true); + } + catch + { + TryDelete(temp); + throw; + } + } + + // Where a link (or a chain of links) ends; the path itself when it is no link. + private static string FinalTarget(string path) + { + var info = new FileInfo(path); + return info.LinkTarget != null && info.ResolveLinkTarget(returnFinalTarget: true) is { } target ? target.FullName : path; + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Left behind; it is hidden and named so it is clearly a leftover. + } + } +} diff --git a/FrySharp.Core/Services/Storage/NotebookFileFormat.cs b/FrySharp.Core/Services/Storage/NotebookFileFormat.cs new file mode 100644 index 00000000..63781ccf --- /dev/null +++ b/FrySharp.Core/Services/Storage/NotebookFileFormat.cs @@ -0,0 +1,414 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Storage; + +/// The formats a notebook file can be in. +public enum NotebookFileKind +{ + /// FrySharp's own notebook (.frynb, and the older .csnb): every output kept. + Frynb, + + /// A Jupyter notebook (.ipynb, nbformat 4), as JupyterLab, VS Code and GitHub read it. + Ipynb, +} + +/// +/// Reads and writes notebook files, the same for every host. A .frynb is written with the same options the desktop +/// always used, so a notebook saved here is byte for byte what the desktop writes. A .ipynb stays a Jupyter notebook +/// when saved: code, Markdown, text, image and HTML outputs and each cell's language survive; Jupyter metadata FrySharp +/// has no use for (cell tags, widget state) does not. +/// +public static partial class NotebookFileFormat +{ + /// How .frynb files are written. Changing it rewrites every notebook a user saves. + public static JsonSerializerOptions FrynbOptions { get; } = new() { WriteIndented = true }; + + private static readonly JsonSerializerOptions IpynbOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// True for a file a notebook is kept in. + public static bool IsNotebookPath(string? path) => + Path.GetExtension(path ?? string.Empty).ToLowerInvariant() is ".frynb" or ".csnb" or ".ipynb"; + + /// The format of the notebook file at , from its extension. + public static NotebookFileKind KindOf(string path) => + string.Equals(Path.GetExtension(path), ".ipynb", StringComparison.OrdinalIgnoreCase) ? NotebookFileKind.Ipynb : NotebookFileKind.Frynb; + + /// Reads a notebook file's text in the format its extension names. + /// The text is not a notebook of that format. + public static NotebookDocumentItem Read(string path, string text, ILanguageCatalog languages) + { + var notebook = KindOf(path) == NotebookFileKind.Ipynb ? ReadIpynb(text, languages) : ReadFrynb(text); + if (string.IsNullOrWhiteSpace(notebook.Title) || notebook.Title == new NotebookDocumentItem().Title) + { + notebook.Title = Path.GetFileNameWithoutExtension(path); + } + return notebook; + } + + /// A notebook as the text of a file of . + public static string Write(NotebookDocumentItem notebook, NotebookFileKind kind, ILanguageCatalog languages) => + kind == NotebookFileKind.Ipynb ? WriteIpynb(notebook, languages) : WriteFrynb(notebook); + + public static NotebookDocumentItem ReadFrynb(string json) => + JsonSerializer.Deserialize(json, FrynbOptions) + ?? throw new JsonException("The file holds no notebook."); + + public static string WriteFrynb(NotebookDocumentItem notebook) + { + ArgumentNullException.ThrowIfNull(notebook); + return JsonSerializer.Serialize(notebook, FrynbOptions); + } + + // ── Jupyter → FrySharp ──────────────────────────────────────────────────────────────────────────────────────── + + /// Reads a Jupyter notebook (nbformat 4). + /// The text is not a Jupyter notebook. + public static NotebookDocumentItem ReadIpynb(string json, ILanguageCatalog languages) + { + ArgumentNullException.ThrowIfNull(languages); + try + { + return ReadIpynbCore(json, languages); + } + catch (InvalidOperationException ex) + { + // A part of the wrong shape (a list where an object belongs). + throw new JsonException($"The file is not a valid Jupyter notebook: {ex.Message}", ex); + } + } + + private static NotebookDocumentItem ReadIpynbCore(string json, ILanguageCatalog languages) + { + var root = JsonNode.Parse(json) as JsonObject ?? throw new JsonException("A Jupyter notebook is a JSON object."); + if (root["cells"] is not JsonArray cells) throw new JsonException("A Jupyter notebook has a \"cells\" list."); + + var metadata = root["metadata"] as JsonObject; + var defaultLanguage = NotebookLanguage(metadata, languages); + var notebook = new NotebookDocumentItem { Kernel = defaultLanguage?.Id ?? LanguageIds.CSharp }; + + foreach (var node in cells.OfType()) + { + var type = node["cell_type"].AsText(); + var cell = new NotebookCellItem + { + Type = type == "code" ? CellType.Code : CellType.Markdown, + Source = JoinText(node["source"]), + }; + if (node["id"].AsText() is { Length: > 0 } id) cell.Id = id; + + if (cell.Type == CellType.Code) + { + if (node["execution_count"] is JsonValue count && count.TryGetValue(out var executionCount)) + { + cell.ExecutionCount = executionCount; + } + + var cellLanguage = CellKernel(node["metadata"] as JsonObject, languages); + if (cellLanguage != null && !string.Equals(cellLanguage.Id, notebook.Kernel, StringComparison.OrdinalIgnoreCase)) + { + cell.Language = cellLanguage.Id; + } + + if (node["outputs"] is JsonArray outputs) ReadOutputs(outputs, cell); + } + + notebook.Cells.Add(cell); + } + + notebook.ExecutionCount = notebook.Cells.Max(c => c.ExecutionCount) ?? 0; + return notebook; + } + + // The notebook's language: Polyglot Notebooks' default kernel, then the kernelspec, then language_info. + private static ILanguageEngine? NotebookLanguage(JsonObject? metadata, ILanguageCatalog languages) + { + if (metadata == null) return null; + var polyglotDefault = metadata["polyglot_notebook"]?["kernelInfo"]?["defaultKernelName"].AsText(); + return Find(languages, polyglotDefault) + ?? Find(languages, metadata["kernelspec"]?["language"].AsText()) + ?? Find(languages, metadata["language_info"]?["name"].AsText()) + ?? Find(languages, metadata["kernelspec"]?["name"].AsText()); + } + + // A cell's own kernel, as Polyglot Notebooks (and .NET Interactive before it) wrote it. + private static ILanguageEngine? CellKernel(JsonObject? metadata, ILanguageCatalog languages) => + Find(languages, metadata?["polyglot_notebook"]?["kernelName"].AsText()) + ?? Find(languages, metadata?["dotnet_interactive"]?["language"].AsText()); + + // A language named the Jupyter way ("python", "python3", "C#", ".net-csharp") or the FrySharp way ("csharp"). + private static ILanguageEngine? Find(ILanguageCatalog languages, string? name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + if (languages.FindLanguage(name) is { } named) return named; + return languages.NotebookCellLanguages.FirstOrDefault(l => l.Jupyter is { } j && + (string.Equals(j.LanguageName, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(j.KernelLanguage, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(j.KernelName, name, StringComparison.OrdinalIgnoreCase))); + } + + private static void ReadOutputs(JsonArray outputs, NotebookCellItem cell) + { + var text = new StringBuilder(); + foreach (var output in outputs.OfType()) + { + switch (output["output_type"].AsText()) + { + case "stream": + text.Append(JoinText(output["text"])); + if (output["name"].AsText() == "stderr") cell.HasError = true; + break; + + case "execute_result": + case "display_data": + if (output["data"] is not JsonObject data) break; + if (cell.ImageBytes == null && TryImage(data, out var bytes, out var format)) + { + cell.ImageBytes = bytes; + cell.ImageFormat = format; + } + else if (cell.HtmlContent == null && data["text/html"] is { } html) + { + cell.HtmlContent = JoinText(html); + } + else if (cell.HtmlContent == null && data["text/markdown"] is { } markdown) + { + var markdownText = JoinText(markdown); + cell.MarkdownContent = markdownText; + cell.HtmlContent = MarkdownOutput.ToBasicHtml(markdownText); + } + else if (data["text/plain"] is { } plain) + { + AppendLine(text, JoinText(plain)); + } + break; + + case "error": + cell.HasError = true; + if (output["traceback"] is JsonArray traceback && traceback.Count > 0) + { + foreach (var line in traceback) AppendLine(text, AnsiEscape().Replace(line.AsText() ?? string.Empty, string.Empty)); + } + else + { + AppendLine(text, $"{output["ename"].AsText()}: {output["evalue"].AsText()}"); + } + break; + } + } + + cell.OutputText = text.ToString(); + } + + private static bool TryImage(JsonObject data, out byte[] bytes, out string format) + { + foreach (var (mime, name) in new[] { ("image/png", "PNG"), ("image/jpeg", "JPEG"), ("image/gif", "GIF") }) + { + if (data[mime] is { } node) + { + try + { + bytes = Convert.FromBase64String(JoinText(node).Replace("\n", string.Empty)); + format = name; + return true; + } + catch (FormatException) + { + // Not base64: try the next image type, then text. + } + } + } + + bytes = []; + format = string.Empty; + return false; + } + + // Jupyter keeps multi-line text as a string or as a list of lines (each but the last ending in "\n"). + private static string JoinText(JsonNode? node) => node switch + { + JsonArray lines => string.Concat(lines.Select(l => l.AsText())), + JsonValue value when value.TryGetValue(out var s) => s, + _ => string.Empty + }; + + private static void AppendLine(StringBuilder text, string line) + { + if (text.Length > 0 && text[^1] != '\n') text.Append('\n'); + text.Append(line); + } + + // A string value, or null for anything else: a malformed notebook loses that value instead of failing to open. + private static string? AsText(this JsonNode? node) => + node is JsonValue value && value.TryGetValue(out var text) ? text : null; + + [GeneratedRegex(@"\x1B\[[0-9;]*[A-Za-z]")] + private static partial Regex AnsiEscape(); + + // ── FrySharp → Jupyter ──────────────────────────────────────────────────────────────────────────────────────── + + /// + /// A notebook as a Jupyter notebook (nbformat 4.5), for JupyterLab, VS Code and GitHub. A notebook of several + /// languages is written as Polyglot Notebooks does: the kernel of each cell in its metadata. + /// + public static string WriteIpynb(NotebookDocumentItem notebook, ILanguageCatalog languages) + { + ArgumentNullException.ThrowIfNull(notebook); + ArgumentNullException.ThrowIfNull(languages); + var defaultLanguage = DefaultLanguage(notebook, languages); + var used = notebook.Cells.Where(c => c.Type == CellType.Code) + .Select(c => CellLanguage(c, defaultLanguage, languages)) + .DistinctBy(l => l.Id) + .ToList(); + var polyglot = used.Count > 1; + + var cells = new List(); + foreach (var cell in notebook.Cells) + { + var sourceLines = SplitToLinesWithNewlines(cell.Source); + if (cell.Type == CellType.Markdown) + { + cells.Add(new + { + cell_type = "markdown", + id = cell.Id, + metadata = new Dictionary(), + source = sourceLines + }); + continue; + } + + var metadata = new Dictionary(); + if (polyglot) metadata["polyglot_notebook"] = new { kernelName = CellLanguage(cell, defaultLanguage, languages).Id }; + cells.Add(new + { + cell_type = "code", + execution_count = cell.ExecutionCount, + id = cell.Id, + metadata, + outputs = WriteOutputs(cell), + source = sourceLines + }); + } + + // The notebook's kernel: its default language's, or the one language all its cells are in. + var main = (used.Count == 1 ? used[0] : defaultLanguage).Jupyter + ?? languages.FindLanguage(LanguageIds.CSharp)?.Jupyter + ?? new JupyterLanguageInfo(".net-csharp", ".NET (C#)", "C#", "csharp", ".cs", "text/x-csharp", "13.0"); + var notebookMetadata = new Dictionary + { + ["language_info"] = main.Version != null + ? new { name = main.LanguageName, version = main.Version, file_extension = main.FileExtension, mimetype = main.MimeType } + : new { name = main.LanguageName, file_extension = main.FileExtension, mimetype = main.MimeType }, + ["kernelspec"] = new { name = main.KernelName, display_name = main.KernelDisplayName, language = main.KernelLanguage } + }; + if (polyglot) + { + notebookMetadata["polyglot_notebook"] = new + { + kernelInfo = new + { + defaultKernelName = defaultLanguage.Id, + items = used.Select(l => new { name = l.Id, languageName = l.Jupyter?.LanguageName ?? l.Id, aliases = l.Aliases }).ToList() + } + }; + } + + return JsonSerializer.Serialize(new { cells, metadata = notebookMetadata, nbformat = 4, nbformat_minor = 5 }, IpynbOptions); + } + + private static List WriteOutputs(NotebookCellItem cell) + { + var outputs = new List(); + if (!string.IsNullOrEmpty(cell.OutputText)) + { + outputs.Add(new + { + output_type = "stream", + name = cell.HasError ? "stderr" : "stdout", + text = SplitToLinesWithNewlines(cell.OutputText) + }); + } + + if (cell.ImageBytes is { Length: > 0 } image) + { + var mime = (cell.ImageFormat ?? "PNG").ToUpperInvariant() switch + { + "JPEG" or "JPG" => "image/jpeg", + "GIF" => "image/gif", + _ => "image/png" + }; + outputs.Add(new + { + output_type = "display_data", + data = new Dictionary { [mime] = Convert.ToBase64String(image) }, + metadata = new Dictionary() + }); + } + + // A Markdown output is written as its Markdown, which Jupyter renders itself. + if (!string.IsNullOrEmpty(cell.MarkdownContent)) + { + outputs.Add(new + { + output_type = "display_data", + data = new Dictionary { ["text/markdown"] = SplitToLinesWithNewlines(cell.MarkdownContent) }, + metadata = new Dictionary() + }); + } + else if (!string.IsNullOrEmpty(cell.HtmlContent)) + { + outputs.Add(new + { + output_type = "display_data", + data = new Dictionary { ["text/html"] = SplitToLinesWithNewlines(cell.HtmlContent) }, + metadata = new Dictionary() + }); + } + + return outputs; + } + + // The notebook's default language (NotebookDocumentItem.Kernel), C# when it names none the host knows. + private static ILanguageEngine DefaultLanguage(NotebookDocumentItem notebook, ILanguageCatalog languages) => + languages.FindLanguage(notebook.Kernel) + ?? languages.FindLanguage(LanguageIds.CSharp) + ?? languages.NotebookCellLanguages.FirstOrDefault() + ?? throw new InvalidOperationException("No notebook language is registered."); + + // A code cell's language: its #!python (or other) directive, its own, or the notebook's. + private static ILanguageEngine CellLanguage(NotebookCellItem cell, ILanguageEngine defaultLanguage, ILanguageCatalog languages) + { + var own = languages.FindLanguage(cell.Language) ?? defaultLanguage; + return languages.FindLanguage(NotebookCellDirectives.LanguageOf(cell.Source, languages, own.Id)) ?? own; + } + + private static string[] SplitToLinesWithNewlines(string? text) + { + if (string.IsNullOrEmpty(text)) return []; + + // Jupyter's multiline strings: every line keeps its '\n' except the last, and text ending in a newline has no empty + // last line ("hi\n" is ["hi\n"], not ["hi\n", ""]). + var rawLines = text.Split(["\r\n", "\r", "\n"], StringSplitOptions.None); + var count = rawLines[^1].Length == 0 ? rawLines.Length - 1 : rawLines.Length; + var result = new string[count]; + for (int i = 0; i < count; i++) + { + result[i] = i < rawLines.Length - 1 ? rawLines[i] + "\n" : rawLines[i]; + } + return result; + } +} diff --git a/FrySharp.Core/Services/Storage/ScriptStorageIdHelper.cs b/FrySharp.Core/Services/Storage/ScriptStorageIdHelper.cs new file mode 100644 index 00000000..f7dbccca --- /dev/null +++ b/FrySharp.Core/Services/Storage/ScriptStorageIdHelper.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; +using System.Text; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Storage; + +public static class ScriptStorageIdHelper +{ + public const string SourceFileIdPrefix = "src-"; + + public static bool IsSourceFileId(string? id) => id != null && id.StartsWith(SourceFileIdPrefix, StringComparison.Ordinal); + + public static string SourceFileId(string path) + { + var full = Path.GetFullPath(path); + if (!OperatingSystem.IsLinux()) full = full.ToUpperInvariant(); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(full)); + return SourceFileIdPrefix + Convert.ToHexString(hash, 0, 12).ToLowerInvariant(); + } +} diff --git a/FrySharp.Core/Services/Storage/TextFileReader.cs b/FrySharp.Core/Services/Storage/TextFileReader.cs new file mode 100644 index 00000000..02358fb2 --- /dev/null +++ b/FrySharp.Core/Services/Storage/TextFileReader.cs @@ -0,0 +1,43 @@ +using System.Text; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Storage; + +/// A text file as read for editing: its text, and how it was encoded so a save writes it back the same way. +public sealed record TextFileContent(string Text, Encoding Encoding); + +/// Reads files for the editor: the encoding from the byte-order mark (UTF-8 without one), binary files refused. +public static class TextFileReader +{ + private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + private static readonly Encoding Utf8WithBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + + /// True when the bytes look like a binary file (a NUL in the first 8 KB, as git decides). + public static bool LooksBinary(ReadOnlySpan bytes) + { + var start = bytes.Length >= 2 && ((bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF)) + ? bytes.Length // UTF-16 text has NULs by design + : 0; + return bytes[start..Math.Max(start, Math.Min(bytes.Length, 8000))].Contains((byte)0); + } + + /// Decodes a text file's bytes. + public static TextFileContent Decode(byte[] bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + { + return new TextFileContent(Utf8NoBom.GetString(bytes, 3, bytes.Length - 3), Utf8WithBom); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) + { + return new TextFileContent(Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2), Encoding.Unicode); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) + { + return new TextFileContent(Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2), Encoding.BigEndianUnicode); + } + + return new TextFileContent(Utf8NoBom.GetString(bytes), Utf8NoBom); + } +} diff --git a/Services/Toolchains/ExecutableSearch.cs b/FrySharp.Core/Services/Toolchains/ExecutableSearch.cs similarity index 100% rename from Services/Toolchains/ExecutableSearch.cs rename to FrySharp.Core/Services/Toolchains/ExecutableSearch.cs diff --git a/Services/Toolchains/HostEnvironment.cs b/FrySharp.Core/Services/Toolchains/HostEnvironment.cs similarity index 100% rename from Services/Toolchains/HostEnvironment.cs rename to FrySharp.Core/Services/Toolchains/HostEnvironment.cs diff --git a/Services/Toolchains/IToolchainProvider.cs b/FrySharp.Core/Services/Toolchains/IToolchainProvider.cs similarity index 100% rename from Services/Toolchains/IToolchainProvider.cs rename to FrySharp.Core/Services/Toolchains/IToolchainProvider.cs diff --git a/Services/Toolchains/ToolchainModels.cs b/FrySharp.Core/Services/Toolchains/ToolchainModels.cs similarity index 100% rename from Services/Toolchains/ToolchainModels.cs rename to FrySharp.Core/Services/Toolchains/ToolchainModels.cs diff --git a/Services/Toolchains/ToolchainSettingsStore.cs b/FrySharp.Core/Services/Toolchains/ToolchainSettingsStore.cs similarity index 100% rename from Services/Toolchains/ToolchainSettingsStore.cs rename to FrySharp.Core/Services/Toolchains/ToolchainSettingsStore.cs diff --git a/Services/Toolchains/ToolchainSetupRunner.cs b/FrySharp.Core/Services/Toolchains/ToolchainSetupRunner.cs similarity index 100% rename from Services/Toolchains/ToolchainSetupRunner.cs rename to FrySharp.Core/Services/Toolchains/ToolchainSetupRunner.cs diff --git a/Services/Workspace/Git/GitCommitMessageGenerator.cs b/FrySharp.Core/Services/Workspace/Git/GitCommitMessageGenerator.cs similarity index 100% rename from Services/Workspace/Git/GitCommitMessageGenerator.cs rename to FrySharp.Core/Services/Workspace/Git/GitCommitMessageGenerator.cs diff --git a/Services/Workspace/Git/GitDiffParser.cs b/FrySharp.Core/Services/Workspace/Git/GitDiffParser.cs similarity index 100% rename from Services/Workspace/Git/GitDiffParser.cs rename to FrySharp.Core/Services/Workspace/Git/GitDiffParser.cs diff --git a/Services/Workspace/Git/GitService.cs b/FrySharp.Core/Services/Workspace/Git/GitService.cs similarity index 100% rename from Services/Workspace/Git/GitService.cs rename to FrySharp.Core/Services/Workspace/Git/GitService.cs diff --git a/Services/Workspace/Git/GitStatusParser.cs b/FrySharp.Core/Services/Workspace/Git/GitStatusParser.cs similarity index 100% rename from Services/Workspace/Git/GitStatusParser.cs rename to FrySharp.Core/Services/Workspace/Git/GitStatusParser.cs diff --git a/Services/Workspace/Git/IGitService.cs b/FrySharp.Core/Services/Workspace/Git/IGitService.cs similarity index 100% rename from Services/Workspace/Git/IGitService.cs rename to FrySharp.Core/Services/Workspace/Git/IGitService.cs diff --git a/Services/Workspace/TextMatcher.cs b/FrySharp.Core/Services/Workspace/TextMatcher.cs similarity index 100% rename from Services/Workspace/TextMatcher.cs rename to FrySharp.Core/Services/Workspace/TextMatcher.cs diff --git a/Services/Workspace/WorkspaceFileIndex.cs b/FrySharp.Core/Services/Workspace/WorkspaceFileIndex.cs similarity index 100% rename from Services/Workspace/WorkspaceFileIndex.cs rename to FrySharp.Core/Services/Workspace/WorkspaceFileIndex.cs diff --git a/Services/Workspace/WorkspaceTextSearch.cs b/FrySharp.Core/Services/Workspace/WorkspaceTextSearch.cs similarity index 99% rename from Services/Workspace/WorkspaceTextSearch.cs rename to FrySharp.Core/Services/Workspace/WorkspaceTextSearch.cs index c31f5354..c0a665e7 100644 --- a/Services/Workspace/WorkspaceTextSearch.cs +++ b/FrySharp.Core/Services/Workspace/WorkspaceTextSearch.cs @@ -159,7 +159,7 @@ void Collect(string text, int cell) var hasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF; var text = new UTF8Encoding(false).GetString(bytes, hasBom ? 3 : 0, bytes.Length - (hasBom ? 3 : 0)); - if (openDocuments is { Count: > 0 } && openDocuments.TryGetValue(LocalScriptStorageService.SourceFileId(fullPath), out var open)) + if (openDocuments is { Count: > 0 } && openDocuments.TryGetValue(ScriptStorageIdHelper.SourceFileId(fullPath), out var open)) { text = open; } diff --git a/Services/Workspace/WorkspaceWalker.cs b/FrySharp.Core/Services/Workspace/WorkspaceWalker.cs similarity index 99% rename from Services/Workspace/WorkspaceWalker.cs rename to FrySharp.Core/Services/Workspace/WorkspaceWalker.cs index e6d28a51..eba1db5f 100644 --- a/Services/Workspace/WorkspaceWalker.cs +++ b/FrySharp.Core/Services/Workspace/WorkspaceWalker.cs @@ -8,7 +8,7 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; /// could lead outside the workspace or round in a circle. The Explorer still shows those folders (all but VS Code's hidden /// ones), closed: see and . /// -internal static class WorkspaceWalker +public static class WorkspaceWalker { /// /// Enough for a big project; stops a walk of a huge folder (someone's home) from taking forever. A folder bigger than this diff --git a/Services/Workspace/WorkspaceWatcher.cs b/FrySharp.Core/Services/Workspace/WorkspaceWatcher.cs similarity index 98% rename from Services/Workspace/WorkspaceWatcher.cs rename to FrySharp.Core/Services/Workspace/WorkspaceWatcher.cs index 5a001190..9db4b780 100644 --- a/Services/Workspace/WorkspaceWatcher.cs +++ b/FrySharp.Core/Services/Workspace/WorkspaceWatcher.cs @@ -7,7 +7,7 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; /// (git, another editor, a build). Without it the studio would only notice on a rescan, and rescanning the whole workspace /// on every tab switch is exactly what it no longer does. /// -internal sealed class WorkspaceWatcher : IDisposable +public sealed class WorkspaceWatcher : IDisposable { // A git checkout or a build writes hundreds of files in a burst: report once, after it has gone quiet. private static readonly TimeSpan Settle = TimeSpan.FromMilliseconds(300); diff --git a/Visualizers/Layouts/GraphLayoutEngine.cs b/FrySharp.Core/Visualizers/Layouts/GraphLayoutEngine.cs similarity index 100% rename from Visualizers/Layouts/GraphLayoutEngine.cs rename to FrySharp.Core/Visualizers/Layouts/GraphLayoutEngine.cs diff --git a/Visualizers/Layouts/TreeLayoutEngine.cs b/FrySharp.Core/Visualizers/Layouts/TreeLayoutEngine.cs similarity index 100% rename from Visualizers/Layouts/TreeLayoutEngine.cs rename to FrySharp.Core/Visualizers/Layouts/TreeLayoutEngine.cs diff --git a/Visualizers/Models/ArrayPointerData.cs b/FrySharp.Core/Visualizers/Models/ArrayPointerData.cs similarity index 100% rename from Visualizers/Models/ArrayPointerData.cs rename to FrySharp.Core/Visualizers/Models/ArrayPointerData.cs diff --git a/Visualizers/Models/BarChartVisualizerData.cs b/FrySharp.Core/Visualizers/Models/BarChartVisualizerData.cs similarity index 100% rename from Visualizers/Models/BarChartVisualizerData.cs rename to FrySharp.Core/Visualizers/Models/BarChartVisualizerData.cs diff --git a/Visualizers/Models/BoardVisualizerData.cs b/FrySharp.Core/Visualizers/Models/BoardVisualizerData.cs similarity index 100% rename from Visualizers/Models/BoardVisualizerData.cs rename to FrySharp.Core/Visualizers/Models/BoardVisualizerData.cs diff --git a/Visualizers/Models/GraphData.cs b/FrySharp.Core/Visualizers/Models/GraphData.cs similarity index 100% rename from Visualizers/Models/GraphData.cs rename to FrySharp.Core/Visualizers/Models/GraphData.cs diff --git a/Visualizers/Models/GridMatrixData.cs b/FrySharp.Core/Visualizers/Models/GridMatrixData.cs similarity index 100% rename from Visualizers/Models/GridMatrixData.cs rename to FrySharp.Core/Visualizers/Models/GridMatrixData.cs diff --git a/Visualizers/Models/IslandData.cs b/FrySharp.Core/Visualizers/Models/IslandData.cs similarity index 100% rename from Visualizers/Models/IslandData.cs rename to FrySharp.Core/Visualizers/Models/IslandData.cs diff --git a/Visualizers/Models/LinkedListData.cs b/FrySharp.Core/Visualizers/Models/LinkedListData.cs similarity index 100% rename from Visualizers/Models/LinkedListData.cs rename to FrySharp.Core/Visualizers/Models/LinkedListData.cs diff --git a/Visualizers/Models/StepWatch.cs b/FrySharp.Core/Visualizers/Models/StepWatch.cs similarity index 100% rename from Visualizers/Models/StepWatch.cs rename to FrySharp.Core/Visualizers/Models/StepWatch.cs diff --git a/Visualizers/Models/TreeNodeData.cs b/FrySharp.Core/Visualizers/Models/TreeNodeData.cs similarity index 100% rename from Visualizers/Models/TreeNodeData.cs rename to FrySharp.Core/Visualizers/Models/TreeNodeData.cs diff --git a/Visualizers/Models/VisualizerOptions.cs b/FrySharp.Core/Visualizers/Models/VisualizerOptions.cs similarity index 100% rename from Visualizers/Models/VisualizerOptions.cs rename to FrySharp.Core/Visualizers/Models/VisualizerOptions.cs diff --git a/Visualizers/Models/VisualizerScene.cs b/FrySharp.Core/Visualizers/Models/VisualizerScene.cs similarity index 100% rename from Visualizers/Models/VisualizerScene.cs rename to FrySharp.Core/Visualizers/Models/VisualizerScene.cs diff --git a/Visualizers/Models/VisualizerSequence.cs b/FrySharp.Core/Visualizers/Models/VisualizerSequence.cs similarity index 99% rename from Visualizers/Models/VisualizerSequence.cs rename to FrySharp.Core/Visualizers/Models/VisualizerSequence.cs index d197d1a2..e4f2dce0 100644 --- a/Visualizers/Models/VisualizerSequence.cs +++ b/FrySharp.Core/Visualizers/Models/VisualizerSequence.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; namespace PdfEditorApp.Plugins.CSharpEditor.Visualizers.Models; diff --git a/Visualizers/Models/VisualizerStep.cs b/FrySharp.Core/Visualizers/Models/VisualizerStep.cs similarity index 100% rename from Visualizers/Models/VisualizerStep.cs rename to FrySharp.Core/Visualizers/Models/VisualizerStep.cs diff --git a/Visualizers/Models/VisualizerViewState.cs b/FrySharp.Core/Visualizers/Models/VisualizerViewState.cs similarity index 100% rename from Visualizers/Models/VisualizerViewState.cs rename to FrySharp.Core/Visualizers/Models/VisualizerViewState.cs diff --git a/Visualizers/Services/ArrayPointerDataParser.cs b/FrySharp.Core/Visualizers/Services/ArrayPointerDataParser.cs similarity index 100% rename from Visualizers/Services/ArrayPointerDataParser.cs rename to FrySharp.Core/Visualizers/Services/ArrayPointerDataParser.cs diff --git a/Visualizers/Services/DataStructureDetector.cs b/FrySharp.Core/Visualizers/Services/DataStructureDetector.cs similarity index 100% rename from Visualizers/Services/DataStructureDetector.cs rename to FrySharp.Core/Visualizers/Services/DataStructureDetector.cs diff --git a/Visualizers/Services/GraphDataParser.cs b/FrySharp.Core/Visualizers/Services/GraphDataParser.cs similarity index 100% rename from Visualizers/Services/GraphDataParser.cs rename to FrySharp.Core/Visualizers/Services/GraphDataParser.cs diff --git a/Visualizers/Services/GraphTracker.cs b/FrySharp.Core/Visualizers/Services/GraphTracker.cs similarity index 100% rename from Visualizers/Services/GraphTracker.cs rename to FrySharp.Core/Visualizers/Services/GraphTracker.cs diff --git a/Visualizers/Services/IntervalTracker.cs b/FrySharp.Core/Visualizers/Services/IntervalTracker.cs similarity index 100% rename from Visualizers/Services/IntervalTracker.cs rename to FrySharp.Core/Visualizers/Services/IntervalTracker.cs diff --git a/Visualizers/Services/IslandDetectorService.cs b/FrySharp.Core/Visualizers/Services/IslandDetectorService.cs similarity index 100% rename from Visualizers/Services/IslandDetectorService.cs rename to FrySharp.Core/Visualizers/Services/IslandDetectorService.cs diff --git a/Visualizers/Services/LinkedListDataParser.cs b/FrySharp.Core/Visualizers/Services/LinkedListDataParser.cs similarity index 100% rename from Visualizers/Services/LinkedListDataParser.cs rename to FrySharp.Core/Visualizers/Services/LinkedListDataParser.cs diff --git a/Visualizers/Services/LinkedListTracker.cs b/FrySharp.Core/Visualizers/Services/LinkedListTracker.cs similarity index 100% rename from Visualizers/Services/LinkedListTracker.cs rename to FrySharp.Core/Visualizers/Services/LinkedListTracker.cs diff --git a/Visualizers/Services/MatrixDataParser.cs b/FrySharp.Core/Visualizers/Services/MatrixDataParser.cs similarity index 100% rename from Visualizers/Services/MatrixDataParser.cs rename to FrySharp.Core/Visualizers/Services/MatrixDataParser.cs diff --git a/Visualizers/Services/MatrixTracker.cs b/FrySharp.Core/Visualizers/Services/MatrixTracker.cs similarity index 100% rename from Visualizers/Services/MatrixTracker.cs rename to FrySharp.Core/Visualizers/Services/MatrixTracker.cs diff --git a/Visualizers/Services/MatrixTraversalEngine.cs b/FrySharp.Core/Visualizers/Services/MatrixTraversalEngine.cs similarity index 100% rename from Visualizers/Services/MatrixTraversalEngine.cs rename to FrySharp.Core/Visualizers/Services/MatrixTraversalEngine.cs diff --git a/Visualizers/Services/RecursionTracker.cs b/FrySharp.Core/Visualizers/Services/RecursionTracker.cs similarity index 100% rename from Visualizers/Services/RecursionTracker.cs rename to FrySharp.Core/Visualizers/Services/RecursionTracker.cs diff --git a/Visualizers/Services/StepChanges.cs b/FrySharp.Core/Visualizers/Services/StepChanges.cs similarity index 100% rename from Visualizers/Services/StepChanges.cs rename to FrySharp.Core/Visualizers/Services/StepChanges.cs diff --git a/Visualizers/Services/TreeDataParser.cs b/FrySharp.Core/Visualizers/Services/TreeDataParser.cs similarity index 100% rename from Visualizers/Services/TreeDataParser.cs rename to FrySharp.Core/Visualizers/Services/TreeDataParser.cs diff --git a/Visualizers/Services/TreeTracker.cs b/FrySharp.Core/Visualizers/Services/TreeTracker.cs similarity index 100% rename from Visualizers/Services/TreeTracker.cs rename to FrySharp.Core/Visualizers/Services/TreeTracker.cs diff --git a/Visualizers/Services/TrieTracker.cs b/FrySharp.Core/Visualizers/Services/TrieTracker.cs similarity index 100% rename from Visualizers/Services/TrieTracker.cs rename to FrySharp.Core/Visualizers/Services/TrieTracker.cs diff --git a/Visualizers/Services/VisualizerPaletteService.cs b/FrySharp.Core/Visualizers/Services/VisualizerPaletteService.cs similarity index 100% rename from Visualizers/Services/VisualizerPaletteService.cs rename to FrySharp.Core/Visualizers/Services/VisualizerPaletteService.cs diff --git a/Visualizers/Services/VisualizerRecorder.Bars.cs b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.Bars.cs similarity index 100% rename from Visualizers/Services/VisualizerRecorder.Bars.cs rename to FrySharp.Core/Visualizers/Services/VisualizerRecorder.Bars.cs diff --git a/Visualizers/Services/VisualizerRecorder.Board.cs b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.Board.cs similarity index 100% rename from Visualizers/Services/VisualizerRecorder.Board.cs rename to FrySharp.Core/Visualizers/Services/VisualizerRecorder.Board.cs diff --git a/Visualizers/Services/VisualizerRecorder.Canvas.cs b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.Canvas.cs similarity index 100% rename from Visualizers/Services/VisualizerRecorder.Canvas.cs rename to FrySharp.Core/Visualizers/Services/VisualizerRecorder.Canvas.cs diff --git a/Visualizers/Services/VisualizerRecorder.Graph.cs b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.Graph.cs similarity index 100% rename from Visualizers/Services/VisualizerRecorder.Graph.cs rename to FrySharp.Core/Visualizers/Services/VisualizerRecorder.Graph.cs diff --git a/Visualizers/Services/VisualizerRecorder.Matrix.cs b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.Matrix.cs similarity index 100% rename from Visualizers/Services/VisualizerRecorder.Matrix.cs rename to FrySharp.Core/Visualizers/Services/VisualizerRecorder.Matrix.cs diff --git a/Visualizers/Services/VisualizerRecorder.Tree.cs b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.Tree.cs similarity index 100% rename from Visualizers/Services/VisualizerRecorder.Tree.cs rename to FrySharp.Core/Visualizers/Services/VisualizerRecorder.Tree.cs diff --git a/Visualizers/Services/VisualizerRecorder.cs b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.cs similarity index 86% rename from Visualizers/Services/VisualizerRecorder.cs rename to FrySharp.Core/Visualizers/Services/VisualizerRecorder.cs index ccb224a8..51e1ed56 100644 --- a/Visualizers/Services/VisualizerRecorder.cs +++ b/FrySharp.Core/Visualizers/Services/VisualizerRecorder.cs @@ -197,16 +197,4 @@ private void ExtractPointers(object pointers, VisualizerStep step) /// The visualizer as a spec, as every language describes one. public VisualSpec ToVisualSpec() => VisualizerOptionsConverter.ToSpec(Options); - - /// Exports the recorded visualizer steps into an animated GIF file. - public Task SaveGifAsync(string path, VisualizerGifExportOptions? exportOptions = null, IProgress? progress = null, System.Threading.CancellationToken cancellationToken = default) => - VisualizerGifExportService.ExportToFileAsync(Options, path, exportOptions, progress, cancellationToken); - - /// Exports the recorded visualizer steps into an animated GIF file synchronously. - public void SaveGif(string path, VisualizerGifExportOptions? exportOptions = null) => - SaveGifAsync(path, exportOptions).GetAwaiter().GetResult(); - - /// Exports the recorded visualizer steps into animated GIF bytes. - public Task ToGifBytesAsync(VisualizerGifExportOptions? exportOptions = null, IProgress? progress = null, System.Threading.CancellationToken cancellationToken = default) => - VisualizerGifExportService.ExportToGifBytesAsync(Options, exportOptions, progress, cancellationToken); } diff --git a/Visualizers/Services/VisualizerReflectionHelper.cs b/FrySharp.Core/Visualizers/Services/VisualizerReflectionHelper.cs similarity index 100% rename from Visualizers/Services/VisualizerReflectionHelper.cs rename to FrySharp.Core/Visualizers/Services/VisualizerReflectionHelper.cs diff --git a/Visualizers/Services/VisualizerValueFormatter.cs b/FrySharp.Core/Visualizers/Services/VisualizerValueFormatter.cs similarity index 100% rename from Visualizers/Services/VisualizerValueFormatter.cs rename to FrySharp.Core/Visualizers/Services/VisualizerValueFormatter.cs diff --git a/Visualizers/Services/WatchCapture.cs b/FrySharp.Core/Visualizers/Services/WatchCapture.cs similarity index 100% rename from Visualizers/Services/WatchCapture.cs rename to FrySharp.Core/Visualizers/Services/WatchCapture.cs diff --git a/Visuals/Building/ChartOptionsConverter.cs b/FrySharp.Core/Visuals/Building/ChartOptionsConverter.cs similarity index 98% rename from Visuals/Building/ChartOptionsConverter.cs rename to FrySharp.Core/Visuals/Building/ChartOptionsConverter.cs index d8d70a4c..3880fe8d 100644 --- a/Visuals/Building/ChartOptionsConverter.cs +++ b/FrySharp.Core/Visuals/Building/ChartOptionsConverter.cs @@ -57,7 +57,7 @@ public static ChartSeriesSpec ToSpec(ChartSeries series) return new ChartSeriesSpec { Name = series.Name, - Color = series.Color, + Color = string.IsNullOrEmpty(series.Color) ? null : series.Color, LineWidth = series.StrokeThickness == ChartRenderDefaults.LineWidth ? null : series.StrokeThickness, Kind = series.Kind, Axis = series.Axis == AxisSide.Left ? null : series.Axis, diff --git a/Visuals/Building/ChartSpecBuilder.cs b/FrySharp.Core/Visuals/Building/ChartSpecBuilder.cs similarity index 100% rename from Visuals/Building/ChartSpecBuilder.cs rename to FrySharp.Core/Visuals/Building/ChartSpecBuilder.cs diff --git a/Visuals/Building/DataReader.cs b/FrySharp.Core/Visuals/Building/DataReader.cs similarity index 100% rename from Visuals/Building/DataReader.cs rename to FrySharp.Core/Visuals/Building/DataReader.cs diff --git a/Visuals/Building/Plot3DOptionsConverter.cs b/FrySharp.Core/Visuals/Building/Plot3DOptionsConverter.cs similarity index 100% rename from Visuals/Building/Plot3DOptionsConverter.cs rename to FrySharp.Core/Visuals/Building/Plot3DOptionsConverter.cs diff --git a/Visuals/Building/Plot3DSpecBuilder.cs b/FrySharp.Core/Visuals/Building/Plot3DSpecBuilder.cs similarity index 100% rename from Visuals/Building/Plot3DSpecBuilder.cs rename to FrySharp.Core/Visuals/Building/Plot3DSpecBuilder.cs diff --git a/Visuals/Building/VisualizerOptionsConverter.cs b/FrySharp.Core/Visuals/Building/VisualizerOptionsConverter.cs similarity index 100% rename from Visuals/Building/VisualizerOptionsConverter.cs rename to FrySharp.Core/Visuals/Building/VisualizerOptionsConverter.cs diff --git a/Visuals/Building/VisualizerSpecBuilder.cs b/FrySharp.Core/Visuals/Building/VisualizerSpecBuilder.cs similarity index 100% rename from Visuals/Building/VisualizerSpecBuilder.cs rename to FrySharp.Core/Visuals/Building/VisualizerSpecBuilder.cs diff --git a/Visuals/Interaction/DisplayHandle.cs b/FrySharp.Core/Visuals/Interaction/DisplayHandle.cs similarity index 100% rename from Visuals/Interaction/DisplayHandle.cs rename to FrySharp.Core/Visuals/Interaction/DisplayHandle.cs diff --git a/Visuals/Interaction/ExternalVisualSession.cs b/FrySharp.Core/Visuals/Interaction/ExternalVisualSession.cs similarity index 100% rename from Visuals/Interaction/ExternalVisualSession.cs rename to FrySharp.Core/Visuals/Interaction/ExternalVisualSession.cs diff --git a/Visuals/Interaction/ProgramVisuals.cs b/FrySharp.Core/Visuals/Interaction/ProgramVisuals.cs similarity index 100% rename from Visuals/Interaction/ProgramVisuals.cs rename to FrySharp.Core/Visuals/Interaction/ProgramVisuals.cs diff --git a/Visuals/Interaction/VisualEvent.cs b/FrySharp.Core/Visuals/Interaction/VisualEvent.cs similarity index 100% rename from Visuals/Interaction/VisualEvent.cs rename to FrySharp.Core/Visuals/Interaction/VisualEvent.cs diff --git a/Visuals/Interaction/VisualEventHub.cs b/FrySharp.Core/Visuals/Interaction/VisualEventHub.cs similarity index 100% rename from Visuals/Interaction/VisualEventHub.cs rename to FrySharp.Core/Visuals/Interaction/VisualEventHub.cs diff --git a/Visuals/Interaction/VisualEventLoop.cs b/FrySharp.Core/Visuals/Interaction/VisualEventLoop.cs similarity index 100% rename from Visuals/Interaction/VisualEventLoop.cs rename to FrySharp.Core/Visuals/Interaction/VisualEventLoop.cs diff --git a/Visuals/Json/ChartSpecRules.cs b/FrySharp.Core/Visuals/Json/ChartSpecRules.cs similarity index 100% rename from Visuals/Json/ChartSpecRules.cs rename to FrySharp.Core/Visuals/Json/ChartSpecRules.cs diff --git a/Visuals/Json/VisualJson.cs b/FrySharp.Core/Visuals/Json/VisualJson.cs similarity index 100% rename from Visuals/Json/VisualJson.cs rename to FrySharp.Core/Visuals/Json/VisualJson.cs diff --git a/Visuals/Json/VisualJsonErrors.cs b/FrySharp.Core/Visuals/Json/VisualJsonErrors.cs similarity index 100% rename from Visuals/Json/VisualJsonErrors.cs rename to FrySharp.Core/Visuals/Json/VisualJsonErrors.cs diff --git a/Visuals/Json/VisualLimits.cs b/FrySharp.Core/Visuals/Json/VisualLimits.cs similarity index 100% rename from Visuals/Json/VisualLimits.cs rename to FrySharp.Core/Visuals/Json/VisualLimits.cs diff --git a/Visuals/Json/VisualMimeTypes.cs b/FrySharp.Core/Visuals/Json/VisualMimeTypes.cs similarity index 100% rename from Visuals/Json/VisualMimeTypes.cs rename to FrySharp.Core/Visuals/Json/VisualMimeTypes.cs diff --git a/Visuals/Json/VisualSchemas.cs b/FrySharp.Core/Visuals/Json/VisualSchemas.cs similarity index 100% rename from Visuals/Json/VisualSchemas.cs rename to FrySharp.Core/Visuals/Json/VisualSchemas.cs diff --git a/Visuals/Json/VisualSpecValidator.cs b/FrySharp.Core/Visuals/Json/VisualSpecValidator.cs similarity index 100% rename from Visuals/Json/VisualSpecValidator.cs rename to FrySharp.Core/Visuals/Json/VisualSpecValidator.cs diff --git a/Visuals/Json/VisualizerSpecRules.cs b/FrySharp.Core/Visuals/Json/VisualizerSpecRules.cs similarity index 100% rename from Visuals/Json/VisualizerSpecRules.cs rename to FrySharp.Core/Visuals/Json/VisualizerSpecRules.cs diff --git a/Visuals/Json/VisualizerStateRules.cs b/FrySharp.Core/Visuals/Json/VisualizerStateRules.cs similarity index 100% rename from Visuals/Json/VisualizerStateRules.cs rename to FrySharp.Core/Visuals/Json/VisualizerStateRules.cs diff --git a/Visuals/Kinds/ArrayKind.cs b/FrySharp.Core/Visuals/Kinds/ArrayKind.cs similarity index 100% rename from Visuals/Kinds/ArrayKind.cs rename to FrySharp.Core/Visuals/Kinds/ArrayKind.cs diff --git a/Visuals/Kinds/BarsKind.cs b/FrySharp.Core/Visuals/Kinds/BarsKind.cs similarity index 100% rename from Visuals/Kinds/BarsKind.cs rename to FrySharp.Core/Visuals/Kinds/BarsKind.cs diff --git a/Visuals/Kinds/BoardKind.cs b/FrySharp.Core/Visuals/Kinds/BoardKind.cs similarity index 100% rename from Visuals/Kinds/BoardKind.cs rename to FrySharp.Core/Visuals/Kinds/BoardKind.cs diff --git a/Visuals/Kinds/CanvasKind.cs b/FrySharp.Core/Visuals/Kinds/CanvasKind.cs similarity index 100% rename from Visuals/Kinds/CanvasKind.cs rename to FrySharp.Core/Visuals/Kinds/CanvasKind.cs diff --git a/Visuals/Kinds/ElementStates.cs b/FrySharp.Core/Visuals/Kinds/ElementStates.cs similarity index 100% rename from Visuals/Kinds/ElementStates.cs rename to FrySharp.Core/Visuals/Kinds/ElementStates.cs diff --git a/Visuals/Kinds/GraphKind.cs b/FrySharp.Core/Visuals/Kinds/GraphKind.cs similarity index 100% rename from Visuals/Kinds/GraphKind.cs rename to FrySharp.Core/Visuals/Kinds/GraphKind.cs diff --git a/Visuals/Kinds/GridKind.cs b/FrySharp.Core/Visuals/Kinds/GridKind.cs similarity index 100% rename from Visuals/Kinds/GridKind.cs rename to FrySharp.Core/Visuals/Kinds/GridKind.cs diff --git a/Visuals/Kinds/IVisualizerKind.cs b/FrySharp.Core/Visuals/Kinds/IVisualizerKind.cs similarity index 100% rename from Visuals/Kinds/IVisualizerKind.cs rename to FrySharp.Core/Visuals/Kinds/IVisualizerKind.cs diff --git a/Visuals/Kinds/LinkedListKind.cs b/FrySharp.Core/Visuals/Kinds/LinkedListKind.cs similarity index 100% rename from Visuals/Kinds/LinkedListKind.cs rename to FrySharp.Core/Visuals/Kinds/LinkedListKind.cs diff --git a/Visuals/Kinds/StateDiff.cs b/FrySharp.Core/Visuals/Kinds/StateDiff.cs similarity index 100% rename from Visuals/Kinds/StateDiff.cs rename to FrySharp.Core/Visuals/Kinds/StateDiff.cs diff --git a/Visuals/Kinds/TreeKind.cs b/FrySharp.Core/Visuals/Kinds/TreeKind.cs similarity index 100% rename from Visuals/Kinds/TreeKind.cs rename to FrySharp.Core/Visuals/Kinds/TreeKind.cs diff --git a/Visuals/Output/VisualOriginCapture.cs b/FrySharp.Core/Visuals/Output/VisualOriginCapture.cs similarity index 100% rename from Visuals/Output/VisualOriginCapture.cs rename to FrySharp.Core/Visuals/Output/VisualOriginCapture.cs diff --git a/Visuals/Output/VisualOutput.cs b/FrySharp.Core/Visuals/Output/VisualOutput.cs similarity index 100% rename from Visuals/Output/VisualOutput.cs rename to FrySharp.Core/Visuals/Output/VisualOutput.cs diff --git a/Visuals/Output/VisualOutputRegistry.cs b/FrySharp.Core/Visuals/Output/VisualOutputRegistry.cs similarity index 100% rename from Visuals/Output/VisualOutputRegistry.cs rename to FrySharp.Core/Visuals/Output/VisualOutputRegistry.cs diff --git a/Visuals/Output/VisualOutputSnapshot.cs b/FrySharp.Core/Visuals/Output/VisualOutputSnapshot.cs similarity index 100% rename from Visuals/Output/VisualOutputSnapshot.cs rename to FrySharp.Core/Visuals/Output/VisualOutputSnapshot.cs diff --git a/Visuals/Output/VisualOutputs.cs b/FrySharp.Core/Visuals/Output/VisualOutputs.cs similarity index 100% rename from Visuals/Output/VisualOutputs.cs rename to FrySharp.Core/Visuals/Output/VisualOutputs.cs diff --git a/Visuals/Rendering/ChartRenderModelBuilder.cs b/FrySharp.Core/Visuals/Rendering/ChartRenderModelBuilder.cs similarity index 95% rename from Visuals/Rendering/ChartRenderModelBuilder.cs rename to FrySharp.Core/Visuals/Rendering/ChartRenderModelBuilder.cs index 9cd49c07..89fe7bdf 100644 --- a/Visuals/Rendering/ChartRenderModelBuilder.cs +++ b/FrySharp.Core/Visuals/Rendering/ChartRenderModelBuilder.cs @@ -22,7 +22,7 @@ public static ChartOptions Build(ChartSpec spec) Subtitle = spec.Subtitle ?? string.Empty, // A histogram's bins are counted here; what is drawn is bars of the counts. Type = spec.Kind == ChartType.Histogram ? ChartType.Bar : spec.Kind, - PrimaryColor = spec.Color ?? ChartPaletteService.DefaultCyan, + PrimaryColor = spec.Color ?? ChartPalette.DefaultCyan, ShowGrid = spec.Grid ?? spec.Kind is not (ChartType.Pie or ChartType.Donut), ShowPoints = spec.ShowPoints ?? spec.Series.All(s => s.Y.Count <= ChartRenderDefaults.MaxValuesWithMarkers), ShowStats = spec.ShowStats ?? true, @@ -56,8 +56,10 @@ public static ChartOptions Build(ChartSpec spec) Reverse = axis.Reverse ?? false }; + // A later series without its own colour stays empty: the renderer draws it in its theme's colour for that index, so + // a theme change recolours charts already shown. private static string SeriesColor(ChartSeriesSpec series, int index, ChartOptions options) => - series.Color ?? (index == 0 ? options.PrimaryColor : ChartPaletteService.GetSeriesColor(index)); + series.Color ?? (index == 0 ? options.PrimaryColor : string.Empty); private static void AddSeries(ChartOptions options, ChartSpec spec) { diff --git a/Visuals/Rendering/Notices.cs b/FrySharp.Core/Visuals/Rendering/Notices.cs similarity index 100% rename from Visuals/Rendering/Notices.cs rename to FrySharp.Core/Visuals/Rendering/Notices.cs diff --git a/Visuals/Rendering/Plot3DRenderModelBuilder.cs b/FrySharp.Core/Visuals/Rendering/Plot3DRenderModelBuilder.cs similarity index 100% rename from Visuals/Rendering/Plot3DRenderModelBuilder.cs rename to FrySharp.Core/Visuals/Rendering/Plot3DRenderModelBuilder.cs diff --git a/Visuals/Rendering/VisualDrawing.cs b/FrySharp.Core/Visuals/Rendering/VisualDrawing.cs similarity index 100% rename from Visuals/Rendering/VisualDrawing.cs rename to FrySharp.Core/Visuals/Rendering/VisualDrawing.cs diff --git a/Visuals/Rendering/VisualSize.cs b/FrySharp.Core/Visuals/Rendering/VisualSize.cs similarity index 100% rename from Visuals/Rendering/VisualSize.cs rename to FrySharp.Core/Visuals/Rendering/VisualSize.cs diff --git a/Visuals/Rendering/VisualizerRenderModelBuilder.cs b/FrySharp.Core/Visuals/Rendering/VisualizerRenderModelBuilder.cs similarity index 100% rename from Visuals/Rendering/VisualizerRenderModelBuilder.cs rename to FrySharp.Core/Visuals/Rendering/VisualizerRenderModelBuilder.cs diff --git a/Visuals/Rendering/VisualizerStepStore.cs b/FrySharp.Core/Visuals/Rendering/VisualizerStepStore.cs similarity index 100% rename from Visuals/Rendering/VisualizerStepStore.cs rename to FrySharp.Core/Visuals/Rendering/VisualizerStepStore.cs diff --git a/Visuals/Spec/CanvasSpec.cs b/FrySharp.Core/Visuals/Spec/CanvasSpec.cs similarity index 100% rename from Visuals/Spec/CanvasSpec.cs rename to FrySharp.Core/Visuals/Spec/CanvasSpec.cs diff --git a/Visuals/Spec/ChartSpec.cs b/FrySharp.Core/Visuals/Spec/ChartSpec.cs similarity index 100% rename from Visuals/Spec/ChartSpec.cs rename to FrySharp.Core/Visuals/Spec/ChartSpec.cs diff --git a/Visuals/Spec/ElementRef.cs b/FrySharp.Core/Visuals/Spec/ElementRef.cs similarity index 100% rename from Visuals/Spec/ElementRef.cs rename to FrySharp.Core/Visuals/Spec/ElementRef.cs diff --git a/Visuals/Spec/GraphSpec.cs b/FrySharp.Core/Visuals/Spec/GraphSpec.cs similarity index 100% rename from Visuals/Spec/GraphSpec.cs rename to FrySharp.Core/Visuals/Spec/GraphSpec.cs diff --git a/Visuals/Spec/Plot3DSpec.cs b/FrySharp.Core/Visuals/Spec/Plot3DSpec.cs similarity index 100% rename from Visuals/Spec/Plot3DSpec.cs rename to FrySharp.Core/Visuals/Spec/Plot3DSpec.cs diff --git a/Visuals/Spec/ScalarValue.cs b/FrySharp.Core/Visuals/Spec/ScalarValue.cs similarity index 100% rename from Visuals/Spec/ScalarValue.cs rename to FrySharp.Core/Visuals/Spec/ScalarValue.cs diff --git a/Visuals/Spec/VisualSpec.cs b/FrySharp.Core/Visuals/Spec/VisualSpec.cs similarity index 100% rename from Visuals/Spec/VisualSpec.cs rename to FrySharp.Core/Visuals/Spec/VisualSpec.cs diff --git a/Visuals/Spec/VisualVocabulary.cs b/FrySharp.Core/Visuals/Spec/VisualVocabulary.cs similarity index 100% rename from Visuals/Spec/VisualVocabulary.cs rename to FrySharp.Core/Visuals/Spec/VisualVocabulary.cs diff --git a/Visuals/Spec/VisualizerSpec.cs b/FrySharp.Core/Visuals/Spec/VisualizerSpec.cs similarity index 100% rename from Visuals/Spec/VisualizerSpec.cs rename to FrySharp.Core/Visuals/Spec/VisualizerSpec.cs diff --git a/Visuals/Spec/VisualizerStates.cs b/FrySharp.Core/Visuals/Spec/VisualizerStates.cs similarity index 100% rename from Visuals/Spec/VisualizerStates.cs rename to FrySharp.Core/Visuals/Spec/VisualizerStates.cs diff --git a/FrySharp.Sidecar/FrySharp.Sidecar.csproj b/FrySharp.Sidecar/FrySharp.Sidecar.csproj new file mode 100644 index 00000000..b51c150d --- /dev/null +++ b/FrySharp.Sidecar/FrySharp.Sidecar.csproj @@ -0,0 +1,18 @@ + + + Exe + frysharp-sidecar + FrySharp.Sidecar + Headless stdio sidecar for FrySharp: powers the VS Code extension over JSON-RPC 2.0 + enable + enable + + + + + + + + + + diff --git a/FrySharp.Sidecar/Hosting/SidecarOptions.cs b/FrySharp.Sidecar/Hosting/SidecarOptions.cs new file mode 100644 index 00000000..992375ff --- /dev/null +++ b/FrySharp.Sidecar/Hosting/SidecarOptions.cs @@ -0,0 +1,19 @@ +namespace FrySharp.Sidecar.Hosting; + +/// +/// Configuration for the headless sidecar. +/// +public sealed record SidecarOptions +{ + /// The root workspace directory the sidecar operates in. + public required string WorkspaceRoot { get; init; } + + /// Directory where language environments and cache are stored. + public string DataDirectory { get; init; } = DefaultDataDirectory(); + + /// The largest file opened in memory (default 20MB). + public long MaxOpenFileBytes { get; init; } = 20L * 1024 * 1024; + + public static string DefaultDataDirectory() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".frysharp", "sidecar"); +} diff --git a/FrySharp.Sidecar/Program.cs b/FrySharp.Sidecar/Program.cs new file mode 100644 index 00000000..04bc01f1 --- /dev/null +++ b/FrySharp.Sidecar/Program.cs @@ -0,0 +1,100 @@ +using System.CommandLine; +using FrySharp.Sidecar.Hosting; +using FrySharp.Sidecar.Services; +using FrySharp.Sidecar.Sessions; +using Microsoft.Extensions.Logging; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; +using StreamJsonRpc; + +namespace FrySharp.Sidecar; + +/// +/// Entry point for the headless FrySharp sidecar process. +/// Communicates with the VS Code extension over stdio using JSON-RPC 2.0. +/// +public static class Program +{ + public static async Task Main(string[] args) + { + var workspaceOption = new Option("--workspace", "-w") + { + Description = "Folder the sidecar operates on.", + DefaultValueFactory = _ => new DirectoryInfo(Environment.CurrentDirectory), + }; + + var root = new RootCommand("FrySharp Sidecar: JSON-RPC engine for the VS Code extension.") + { + workspaceOption + }; + + root.SetAction(async (parse, ct) => + { + var workspaceDir = parse.GetValue(workspaceOption)!.FullName; + await RunSidecarAsync(workspaceDir, ct).ConfigureAwait(false); + return 0; + }); + + return await root.Parse(args).InvokeAsync().ConfigureAwait(false); + } + + public static async Task RunSidecarAsync(string workspaceRoot, CancellationToken ct) + { + ScriptHost.Configure(ScriptHostProfile.Headless); + + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; // ALL logs to stderr! + }); + builder.SetMinimumLevel(LogLevel.Information); + }); + + var logger = loggerFactory.CreateLogger("FrySharp.Sidecar"); + logger.LogInformation("Starting FrySharp sidecar in {Workspace}", workspaceRoot); + + var options = new SidecarOptions + { + WorkspaceRoot = workspaceRoot + }; + + var engines = LanguageEngineServices.Create(options.DataDirectory); + var languages = HeadlessLanguages.Create(engines); + var compiler = new RoslynCompilerService(); + var runner = new FileRunner(languages, compiler, engines.Processes); + + var workspace = new WorkspaceService(options, loggerFactory.CreateLogger()); + var languageFeatures = new LanguageFeatures(languages, compiler, loggerFactory.CreateLogger()); + var documents = new DocumentService(options, workspace, languageFeatures); + var runs = new RunService(languages, workspace, runner, loggerFactory.CreateLogger()); + var notebooks = new NotebookService(languages, workspace, loggerFactory.CreateLogger()); + + var stdin = Console.OpenStandardInput(); + var stdout = Console.OpenStandardOutput(); + var formatter = new JsonMessageFormatter(); + formatter.JsonSerializer.ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(); + var handler = new HeaderDelimitedMessageHandler(stdout, stdin, formatter); + using var jsonRpc = new JsonRpc(handler); + + var rpc = new SidecarRpc( + jsonRpc, + options, + workspace, + documents, + languageFeatures, + languages, + runs, + notebooks, + logger); + + jsonRpc.AddLocalRpcTarget(rpc); + jsonRpc.StartListening(); + + logger.LogInformation("FrySharp sidecar listening on stdio."); + await jsonRpc.Completion.ConfigureAwait(false); + logger.LogInformation("FrySharp sidecar exiting."); + } +} diff --git a/FrySharp.Sidecar/Protocol/Protocol.cs b/FrySharp.Sidecar/Protocol/Protocol.cs new file mode 100644 index 00000000..7db294c7 --- /dev/null +++ b/FrySharp.Sidecar/Protocol/Protocol.cs @@ -0,0 +1,175 @@ +using System.Text.Json.Nodes; + +namespace FrySharp.Sidecar.Protocol; + +// The FrySharp session protocol: JSON-RPC 2.0 over stdio for the VS Code extension. + +/// The protocol's version. A client sends the one it speaks; the server answers with its own. +public static class ProtocolVersion +{ + public const int Current = 1; +} + +/// JSON-RPC error codes the server returns, besides the standard ones. +public static class ErrorCodes +{ + public const int NotFound = -32001; + public const int AccessDenied = -32002; + public const int Conflict = -32003; + public const int Invalid = -32004; + public const int TooLarge = -32005; + public const int OutOfSync = -32006; + public const int NotSupported = -32007; + public const int AlreadyExists = -32008; +} + +/// The names of the server's notifications and of the requests it makes of the client. +public static class ClientMethods +{ + public const string WorkspaceChanged = "fry/workspace/changed"; + public const string SearchHits = "fry/workspace/searchHits"; + public const string DocumentChangedOnDisk = "fry/document/changedOnDisk"; + public const string DocumentResync = "fry/document/resync"; + public const string Diagnostics = "fry/language/diagnostics"; + public const string RunOutput = "fry/run/output"; + public const string RunDisplay = "fry/run/display"; + public const string RunInputRequested = "fry/run/inputRequested"; + public const string RunExited = "fry/run/exited"; + public const string NotebookOutput = "fry/notebook/output"; + public const string NotebookCellState = "fry/notebook/cellState"; + public const string InputRequest = "fry/input/request"; + public const string SessionReplaced = "fry/session/replaced"; +} + +// ── Session ────────────────────────────────────────────────────────────────────────────────────────────────────── + +public sealed record InitializeParams(string ClientId, string? ClientName = null, int ProtocolVersion = ProtocolVersion.Current); + +public sealed record InitializeResult( + int ProtocolVersion, + string ServerVersion, + string WorkspaceRoot, + string WorkspaceName, + IReadOnlyList Languages, + bool Resumed, + long MaxOpenFileBytes); + +public sealed record LanguageInfo( + string Id, + string DisplayName, + string ShortName, + IReadOnlyList Extensions, + IReadOnlyList Aliases, + string LineComment, + string Icon, + string Accent, + bool CanRun, + bool CanRunCells, + bool HasCompletion, + string NewFileTemplate); + +public sealed record EmptyParams; + +// ── Workspace ──────────────────────────────────────────────────────────────────────────────────────────────────── + +public sealed record PathParams(string Path); + +public sealed record FolderEntry(string Name, string Path, bool IsFolder, long? Size, DateTimeOffset? Modified); + +public sealed record FolderListing(string Path, IReadOnlyList Entries, bool Truncated); + +public sealed record FindFilesParams(string Query, int Limit = 50); + +public sealed record FindFilesResult(IReadOnlyList Matches, bool Indexing); + +public sealed record FileMatch(string Path, string Name); + +public sealed record SearchParams(string SearchId, string Query, bool IsRegex = false, bool MatchCase = false, bool WholeWord = false); + +public sealed record SearchHit(int Line, int Column, int Length, string Text); + +public sealed record SearchHitsNotification(string SearchId, string Path, IReadOnlyList Hits); + +public sealed record SearchSummary(int FilesSearched, int FilesWithMatches, int Matches, bool HitLimit); + +public sealed record CreateFileParams(string Path, string? Content = null); + +public sealed record RenameParams(string From, string To); + +public sealed record WorkspaceChangedNotification(bool Structure); + +// ── Documents ──────────────────────────────────────────────────────────────────────────────────────────────────── + +public sealed record DocumentContent(string Path, string Text, string LanguageId, string DiskVersion, bool IsReadOnly); + +public sealed record DidOpenParams(string Path, int Version, string Text, string DiskVersion); + +public sealed record TextChange(int Offset, int Length, string Text); + +public sealed record DidChangeParams(string Path, int Version, IReadOnlyList Changes); + +public sealed record SaveParams(string Path, int Version, bool Force = false); + +public sealed record SaveResult(string DiskVersion); + +public sealed record DocumentNotification(string Path); + +// ── Language features ──────────────────────────────────────────────────────────────────────────────────────────── + +public sealed record PositionParams(string Path, int Version, int Offset); + +public sealed record CompletionItem(string Label, string InsertText, string Kind, string? Detail, string? Documentation, string SortText); + +public sealed record CompletionResult(IReadOnlyList Items); + +public sealed record HoverResult(IReadOnlyList Markdown); + +public sealed record DiagnosticDto(string Severity, string Code, string Message, int StartLine, int StartColumn, int EndLine, int EndColumn); + +public sealed record DiagnosticsNotification(string Path, int Version, IReadOnlyList Diagnostics); + +// ── Runs ───────────────────────────────────────────────────────────────────────────────────────────────────────── + +public sealed record RunStartParams(string RunId, string? Path = null, string? Code = null, string? LanguageId = null, IReadOnlyList? Arguments = null); + +public sealed record RunStartResult(string RunId, string Title); + +public sealed record RunIdParams(string RunId); + +public sealed record RunInputParams(string RunId, string Text); + +public sealed record RunInfo(string RunId, string Title, string? Path, string Output, bool Running, int? ExitCode); + +public sealed record RunOutputNotification(string RunId, string Text); + +public sealed record RunDisplayNotification(string RunId, object Data, object? Metadata = null); + +public sealed record RunExitedNotification(string RunId, int ExitCode, long DurationMs, IReadOnlyList Problems); + +public sealed record RunProblem(string? Path, string Severity, string Code, string Message, int Line, int Column); + +// ── Notebooks ──────────────────────────────────────────────────────────────────────────────────────────────────── + +public sealed record CellOutput(string Kind, string? Text = null, object? Data = null, object? Metadata = null); + +public sealed record NotebookCell(string Id, string Type, string? Language, string Source, int? ExecutionCount, bool HasError, IReadOnlyList Outputs); + +public sealed record NotebookContent(string Path, string Format, string Title, string DefaultLanguage, string DiskVersion, IReadOnlyList Cells); + +public sealed record NotebookEdit(string Op, string CellId, int? Index = null, string? Type = null, string? Language = null, string? Source = null); + +public sealed record NotebookEditParams(string Path, IReadOnlyList Edits); + +public sealed record ExecuteCellParams(string Path, string CellId, string Source, string? Language = null); + +public sealed record ExecuteCellResult(int ExecutionCount, bool Success, long DurationMs); + +public sealed record NotebookSaveParams(string Path, bool Force = false); + +public sealed record VariableDto(string Name, string Type, string Value, string Kernel); + +public sealed record NotebookOutputNotification(string Path, string CellId, CellOutput Output); + +public sealed record CellStateNotification(string Path, string CellId, bool Running, int? ExecutionCount, bool? Success, long? DurationMs); + +public sealed record InputRequest(string Prompt, bool Password); diff --git a/FrySharp.Sidecar/Protocol/WorkspacePaths.cs b/FrySharp.Sidecar/Protocol/WorkspacePaths.cs new file mode 100644 index 00000000..71aa773e --- /dev/null +++ b/FrySharp.Sidecar/Protocol/WorkspacePaths.cs @@ -0,0 +1,115 @@ +namespace FrySharp.Sidecar.Protocol; + +/// +/// Keeps every file the sidecar touches safely inside the workspace. A path from the client is relative to the workspace root, +/// with '/' separators; it is refused when it is absolute, climbs out with "..", or reaches outside through a symbolic +/// link anywhere along the way. +/// +public sealed class WorkspacePaths +{ + private const int MaxLinkDepth = 40; + private static readonly char Separator = Path.DirectorySeparatorChar; + + public WorkspacePaths(string root) + { + Root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + RealRoot = RealPath(Root, 0); + } + + /// The workspace folder as the user named it. + public string Root { get; } + + /// The workspace folder with every link along its path resolved. + public string RealRoot { get; } + + /// + /// The full path of inside the workspace, or false when it isn't inside. The root + /// itself counts only when is set: nothing may delete or rename the workspace. + /// + public bool TryResolve(string? relativePath, out string fullPath, bool allowRoot = false) + { + fullPath = string.Empty; + if (relativePath == null || relativePath.Contains('\0')) return false; + + var normalized = relativePath.Replace('\\', '/'); + if (normalized.StartsWith('/') || Path.IsPathRooted(normalized) || (OperatingSystem.IsWindows() && normalized.Contains(':'))) + { + return false; + } + + normalized = normalized.TrimEnd('/'); + if (normalized.Length == 0 || normalized == ".") + { + fullPath = Root; + return allowRoot; + } + + string candidate; + try + { + candidate = Path.GetFullPath(Path.Combine(Root, normalized.Replace('/', Separator))); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + + if (string.Equals(candidate, Root, StringComparison.Ordinal)) + { + fullPath = Root; + return allowRoot; + } + + if (!candidate.StartsWith(Root + Separator, StringComparison.Ordinal)) return false; + + var real = RealPath(candidate, 0); + if (!string.Equals(real, RealRoot, StringComparison.Ordinal) && !real.StartsWith(RealRoot + Separator, StringComparison.Ordinal)) + { + return false; + } + + fullPath = candidate; + return true; + } + + /// The path relative to the workspace root, with '/' separators ("" for the root). + public string ToRelative(string fullPath) + { + var relative = Path.GetRelativePath(Root, fullPath); + return relative == "." ? string.Empty : relative.Replace(Separator, '/'); + } + + private static string RealPath(string fullPath, int depth) + { + if (depth > MaxLinkDepth) throw new IOException($"Too many symbolic links resolving '{fullPath}'."); + + var root = Path.GetPathRoot(fullPath) ?? string.Empty; + var parts = fullPath[root.Length..].Split(Separator, StringSplitOptions.RemoveEmptyEntries); + var current = root; + for (var i = 0; i < parts.Length; i++) + { + var next = Path.Combine(current, parts[i]); + FileSystemInfo info = new FileInfo(next); + if (!info.Exists) + { + info = new DirectoryInfo(next); + if (!info.Exists && info.LinkTarget == null) + { + return Path.Combine([next, .. parts[(i + 1)..]]); + } + } + + if (info.LinkTarget != null) + { + var target = info.ResolveLinkTarget(returnFinalTarget: true); + next = target == null + ? Path.GetFullPath(info.LinkTarget, current) + : RealPath(target.FullName, depth + 1); + } + + current = next; + } + + return Path.TrimEndingDirectorySeparator(current); + } +} diff --git a/FrySharp.Sidecar/Services/DocumentService.cs b/FrySharp.Sidecar/Services/DocumentService.cs new file mode 100644 index 00000000..c4cf2aee --- /dev/null +++ b/FrySharp.Sidecar/Services/DocumentService.cs @@ -0,0 +1,140 @@ +using System.Text; +using FrySharp.Sidecar.Hosting; +using FrySharp.Sidecar.Protocol; +using FrySharp.Sidecar.Sessions; +using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Services; + +/// +/// Files opened and saved by the VS Code extension. +/// +public sealed class DocumentService +{ + private readonly SidecarOptions _options; + private readonly WorkspaceService _workspace; + private readonly LanguageFeatures _languageFeatures; + + public DocumentService(SidecarOptions options, WorkspaceService workspace, LanguageFeatures languageFeatures) + { + _options = options; + _workspace = workspace; + _languageFeatures = languageFeatures; + } + + public async Task OpenAsync(string path, CancellationToken ct) + { + var full = _workspace.Resolve(path); + var info = new FileInfo(full); + if (!info.Exists) throw Error(ErrorCodes.NotFound, $"'{path}' doesn't exist."); + if (info.Length > _options.MaxOpenFileBytes) + { + throw Error(ErrorCodes.TooLarge, $"'{path}' is {info.Length / 1048576.0:F1} MB; files over {_options.MaxOpenFileBytes / 1048576} MB cannot be opened."); + } + + var bytes = await File.ReadAllBytesAsync(full, ct).ConfigureAwait(false); + if (TextFileReader.LooksBinary(bytes)) throw Error(ErrorCodes.NotSupported, $"'{path}' is a binary file."); + + var content = TextFileReader.Decode(bytes); + return new DocumentContent(_workspace.Paths.ToRelative(full), content.Text, _languageFeatures.LanguageOf(full), + DiskVersionOf(info), info.IsReadOnly); + } + + public void DidOpen(SidecarSession session, DidOpenParams request) + { + var full = _workspace.Resolve(request.Path); + var encoding = File.Exists(full) ? EncodingOf(full) : new UTF8Encoding(false); + var document = new OpenDocument(_workspace.Paths.ToRelative(full), full, request.Text, request.Version, request.DiskVersion, encoding); + session.Documents[document.Path] = document; + _languageFeatures.DocumentChanged(session, document); + } + + public void DidChange(SidecarSession session, DidChangeParams request) + { + if (!session.Documents.TryGetValue(request.Path, out var document)) return; + + lock (document.Gate) + { + if (document.OutOfSync) return; + if (request.Version != document.Version + 1) + { + document.OutOfSync = true; + session.Notify(ClientMethods.DocumentResync, new DocumentNotification(document.Path)); + return; + } + + var text = new StringBuilder(document.Text); + foreach (var change in request.Changes.OrderByDescending(c => c.Offset)) + { + if (change.Offset < 0 || change.Length < 0 || change.Offset + change.Length > text.Length) + { + document.OutOfSync = true; + session.Notify(ClientMethods.DocumentResync, new DocumentNotification(document.Path)); + return; + } + text.Remove(change.Offset, change.Length).Insert(change.Offset, change.Text); + } + + document.Text = text.ToString(); + document.Version = request.Version; + } + + _languageFeatures.DocumentChanged(session, document); + } + + public void DidClose(SidecarSession session, string path) + { + if (session.Documents.TryRemove(path, out var document)) _languageFeatures.Forget(session, document); + } + + public async Task SaveAsync(SidecarSession session, SaveParams request, CancellationToken ct) + { + if (!session.Documents.TryGetValue(request.Path, out var document)) throw Error(ErrorCodes.NotFound, $"'{request.Path}' is not open."); + + string text; + Encoding encoding; + lock (document.Gate) + { + if (document.OutOfSync || document.Version != request.Version) + { + throw Error(ErrorCodes.OutOfSync, "The sidecar's copy of the document is behind the editor's."); + } + + var onDisk = File.Exists(document.FullPath) ? DiskVersionOf(new FileInfo(document.FullPath)) : null; + if (!request.Force && onDisk != null && onDisk != document.DiskVersion) + { + throw Error(ErrorCodes.Conflict, $"'{document.Path}' changed on disk since it was opened."); + } + + text = document.Text; + encoding = document.Encoding; + } + + await AtomicFile.WriteAllTextAsync(document.FullPath, text, encoding, ct).ConfigureAwait(false); + var saved = DiskVersionOf(new FileInfo(document.FullPath)); + lock (document.Gate) + { + document.DiskVersion = saved; + document.ChangeOnDiskReported = false; + } + return new SaveResult(saved); + } + + public IReadOnlyDictionary OpenTexts(SidecarSession session) => + session.Documents.Values.ToDictionary( + d => ScriptStorageIdHelper.SourceFileId(d.FullPath), + d => { lock (d.Gate) return d.Text; }); + + public static string DiskVersionOf(FileInfo info) => $"{info.LastWriteTimeUtc.Ticks:x}-{info.Length:x}"; + + private static Encoding EncodingOf(string path) + { + Span head = stackalloc byte[3]; + using var stream = File.OpenRead(path); + var read = stream.Read(head); + return TextFileReader.Decode(head[..read].ToArray()).Encoding; + } + + private static LocalRpcException Error(int code, string message) => new(message) { ErrorCode = code }; +} diff --git a/FrySharp.Sidecar/Services/LanguageFeatures.cs b/FrySharp.Sidecar/Services/LanguageFeatures.cs new file mode 100644 index 00000000..0767b175 --- /dev/null +++ b/FrySharp.Sidecar/Services/LanguageFeatures.cs @@ -0,0 +1,155 @@ +using System.Collections.Concurrent; +using FrySharp.Sidecar.Protocol; +using FrySharp.Sidecar.Sessions; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.Logging; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Services; + +/// +/// Language features powered by Roslyn: C# completions, quick info hover, and live diagnostics. +/// +public sealed class LanguageFeatures +{ + public static readonly TimeSpan DiagnosticsDelay = TimeSpan.FromMilliseconds(400); + + private readonly LanguageRegistry _languages; + private readonly RoslynCompilerService _compiler; + private readonly CSharpCompletionService _completion; + private readonly CSharpQuickInfoService _quickInfo; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _pendingDiagnostics = new(StringComparer.Ordinal); + + public LanguageFeatures(LanguageRegistry languages, RoslynCompilerService compiler, ILogger logger) + { + _languages = languages; + _compiler = compiler; + _completion = new CSharpCompletionService(compiler); + _quickInfo = new CSharpQuickInfoService(compiler); + _logger = logger; + } + + public string LanguageOf(string path) => _languages.LanguageOf(path)?.Id ?? string.Empty; + + public static bool IsCSharp(string path) => HeadlessLanguages.CSharpSourceExtensions.Contains(Path.GetExtension(path)); + + public async Task CompleteAsync(SidecarSession session, PositionParams request, CancellationToken ct) + { + var (text, offset) = TextAt(session, request); + if (!IsCSharp(request.Path)) return new CompletionResult([]); + + var items = await Task.Run(() => _completion.GetCompletionsAsync(text, offset, ct: ct), ct).ConfigureAwait(false); + return new CompletionResult(items.Select(item => new CompletionItem( + item.DisplayText, + item.InsertionText, + item.Kind.ToString(), + item.Signature ?? item.ReturnType, + item.Documentation, + $"{1000 - Math.Clamp((int)item.Priority, 0, 999):D4}{item.DisplayText}")).ToList()); + } + + public async Task HoverAsync(SidecarSession session, PositionParams request, CancellationToken ct) + { + var (text, offset) = TextAt(session, request); + if (!IsCSharp(request.Path)) return null; + + var info = await Task.Run(() => _quickInfo.GetQuickInfoAsync(text, offset, ct), ct).ConfigureAwait(false); + if (info == null) return null; + + var parts = new List(); + var signature = string.Concat(info.Signature.Select(s => s.Text)); + if (signature.Length > 0) parts.Add($"```csharp\n{signature}\n```"); + if (info.Container.Count > 0) parts.Add($"*{string.Concat(info.Container.Select(c => c.Text))}*"); + if (!info.Documentation.IsEmpty) + { + var summary = string.Concat(info.Documentation.Summary.Select(s => s.Text)).Trim(); + if (summary.Length > 0) parts.Add(summary); + } + return parts.Count == 0 ? null : new HoverResult(parts); + } + + public void DocumentChanged(SidecarSession session, OpenDocument document) + { + if (!IsCSharp(document.Path)) return; + + var next = new CancellationTokenSource(); + _pendingDiagnostics.AddOrUpdate(document.Path, next, (_, previous) => + { + previous.Cancel(); + previous.Dispose(); + return next; + }); + _ = PublishDiagnosticsAsync(session, document, next.Token); + } + + public void Forget(SidecarSession session, OpenDocument document) + { + if (_pendingDiagnostics.TryRemove(document.Path, out var pending)) + { + pending.Cancel(); + pending.Dispose(); + } + } + + private async Task PublishDiagnosticsAsync(SidecarSession session, OpenDocument document, CancellationToken ct) + { + try + { + await Task.Delay(DiagnosticsDelay, ct).ConfigureAwait(false); + string text; + int version; + lock (document.Gate) + { + text = document.Text; + version = document.Version; + } + + var diagnostics = await Task.Run(() => _compiler.CheckDiagnostics(text), ct).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + session.Notify(ClientMethods.Diagnostics, new DiagnosticsNotification(document.Path, version, + diagnostics.Where(d => d.Severity != DiagnosticSeverity.Hidden).Select(ToDto).ToList())); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Diagnostics for {Path} failed.", document.Path); + } + } + + public static DiagnosticDto ToDto(DiagnosticItem item) => new( + item.Severity switch + { + DiagnosticSeverity.Error => "error", + DiagnosticSeverity.Warning => "warning", + _ => "info" + }, + item.Id, + item.Message, + Math.Max(1, item.Line), + Math.Max(1, item.Column), + Math.Max(1, item.EndLine > 0 ? item.EndLine : item.Line), + Math.Max(1, item.EndColumn > 0 ? item.EndColumn : item.Column + 1)); + + private static (string Text, int Offset) TextAt(SidecarSession session, PositionParams request) + { + if (!session.Documents.TryGetValue(request.Path, out var document)) + { + throw new LocalRpcException($"'{request.Path}' is not open.") { ErrorCode = ErrorCodes.NotFound }; + } + + lock (document.Gate) + { + if (document.OutOfSync || document.Version != request.Version) + { + throw new LocalRpcException("The server's copy of the document is not at that version.") { ErrorCode = ErrorCodes.OutOfSync }; + } + return (document.Text, Math.Clamp(request.Offset, 0, document.Text.Length)); + } + } +} diff --git a/FrySharp.Sidecar/Services/NotebookService.cs b/FrySharp.Sidecar/Services/NotebookService.cs new file mode 100644 index 00000000..a25163de --- /dev/null +++ b/FrySharp.Sidecar/Services/NotebookService.cs @@ -0,0 +1,532 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using FrySharp.Sidecar.Protocol; +using FrySharp.Sidecar.Sessions; +using Microsoft.Extensions.Logging; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; +using PdfEditorApp.Plugins.CSharpEditor.Visuals.Output; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Services; + +/// +/// Manages open polyglot notebooks and cell evaluation pipelines. +/// +public sealed class NotebookService +{ + private readonly ConcurrentDictionary _open = new(StringComparer.Ordinal); + private readonly LanguageRegistry _languages; + private readonly WorkspaceService _workspace; + private readonly ILogger _logger; + + public NotebookService(LanguageRegistry languages, WorkspaceService workspace, ILogger logger) + { + _languages = languages; + _workspace = workspace; + _logger = logger; + } + + public async Task OpenAsync(SidecarSession session, string path, CancellationToken ct) + { + var full = _workspace.Resolve(path); + if (!NotebookFileFormat.IsNotebookPath(full)) throw Error(ErrorCodes.NotSupported, $"'{path}' is not a notebook."); + if (!File.Exists(full)) throw Error(ErrorCodes.NotFound, $"'{path}' doesn't exist."); + + var relative = _workspace.Paths.ToRelative(full); + if (!_open.TryGetValue(relative, out var notebook)) + { + var text = await File.ReadAllTextAsync(full, ct).ConfigureAwait(false); + NotebookDocumentItem document; + try + { + document = NotebookFileFormat.Read(full, text, _languages); + } + catch (System.Text.Json.JsonException ex) + { + throw Error(ErrorCodes.Invalid, $"'{relative}' is not a valid notebook: {ex.Message}"); + } + + var created = new OpenNotebook(relative, full, document, NotebookFileFormat.KindOf(full), + DocumentService.DiskVersionOf(new FileInfo(full)), _languages, _workspace.Paths.Root); + notebook = _open.GetOrAdd(relative, created); + if (!ReferenceEquals(notebook, created)) created.Dispose(); + } + + lock (notebook.Gate) + { + return new NotebookContent(notebook.Path, notebook.Kind == NotebookFileKind.Ipynb ? "ipynb" : "frynb", + notebook.Document.Title, notebook.Document.Kernel, notebook.DiskVersion, + notebook.Document.Cells.Select(ToDto).ToList()); + } + } + + public void Edit(SidecarSession session, NotebookEditParams request) + { + var notebook = Get(request.Path); + lock (notebook.Gate) + { + var cells = notebook.Document.Cells; + foreach (var edit in request.Edits) + { + var index = cells.FindIndex(c => c.Id == edit.CellId); + switch (edit.Op) + { + case "insert": + if (index >= 0) throw Error(ErrorCodes.AlreadyExists, $"Cell '{edit.CellId}' already exists."); + cells.Insert(Math.Clamp(edit.Index ?? cells.Count, 0, cells.Count), new NotebookCellItem + { + Id = edit.CellId, + Type = edit.Type == "markdown" ? CellType.Markdown : CellType.Code, + Language = edit.Language, + Source = edit.Source ?? string.Empty, + }); + break; + case "delete": + if (index >= 0) cells.RemoveAt(index); + break; + case "move": + if (index < 0) throw MissingCell(edit.CellId); + var moved = cells[index]; + cells.RemoveAt(index); + cells.Insert(Math.Clamp(edit.Index ?? cells.Count, 0, cells.Count), moved); + break; + case "source": + if (index < 0) throw MissingCell(edit.CellId); + cells[index].Source = edit.Source ?? string.Empty; + break; + case "language": + if (index < 0) throw MissingCell(edit.CellId); + cells[index].Language = string.IsNullOrEmpty(edit.Language) || edit.Language == notebook.Document.Kernel ? null : edit.Language; + break; + case "type": + if (index < 0) throw MissingCell(edit.CellId); + cells[index].Type = edit.Type == "markdown" ? CellType.Markdown : CellType.Code; + break; + default: + throw Error(ErrorCodes.Invalid, $"'{edit.Op}' is not a notebook edit."); + } + } + notebook.Dirty = true; + } + } + + public async Task ExecuteAsync(SidecarSession session, ExecuteCellParams request, CancellationToken ct) + { + var notebook = await GetOrOpenAsync(session, request.Path, ct).ConfigureAwait(false); + await notebook.ExecutionGate.WaitAsync(ct).ConfigureAwait(false); + try + { + NotebookCellItem? cell; + int executionCount; + lock (notebook.Gate) + { + cell = notebook.Document.Cells.FirstOrDefault(c => c.Id == request.CellId); + if (cell == null) + { + cell = new NotebookCellItem + { + Id = request.CellId, + Type = CellType.Code, + Language = request.Language, + Source = request.Source, + }; + notebook.Document.Cells.Add(cell); + } + + if (cell.Type != CellType.Code) throw Error(ErrorCodes.Invalid, "Only code cells run."); + cell.Source = request.Source; + if (!string.IsNullOrWhiteSpace(request.Language)) + { + cell.Language = request.Language; + } + ClearOutputs(cell); + executionCount = ++notebook.Document.ExecutionCount; + cell.ExecutionCount = executionCount; + cell.IsExecuting = true; + notebook.Dirty = true; + } + + session.Notify(ClientMethods.NotebookCellState, new CellStateNotification(notebook.Path, cell.Id, true, executionCount, null, null)); + using var execution = new CancellationTokenSource(); + notebook.BeginExecution(execution); + var clock = Stopwatch.StartNew(); + var success = false; + + using var output = new RunOutput(text => session.Notify(ClientMethods.NotebookOutput, + new NotebookOutputNotification(notebook.Path, cell.Id, new CellOutput("stream", text)))); + void Write(string text) + { + lock (notebook.Gate) cell.OutputText += text; + output.Append(text); + } + void Show(RichCellOutput rich) + { + output.Flush(); + lock (notebook.Gate) Keep(cell, rich); + if (RichOutputMime.ToBundle(rich, out var metadata) is { } bundle) + { + var data = Newtonsoft.Json.Linq.JObject.Parse(bundle.ToJsonString()); + var meta = metadata.Count > 0 ? Newtonsoft.Json.Linq.JObject.Parse(metadata.ToJsonString()) : null; + session.Notify(ClientMethods.NotebookOutput, + new NotebookOutputNotification(notebook.Path, cell.Id, new CellOutput("display", Data: data, Metadata: meta))); + } + } + + try + { + success = await RunCellAsync(notebook, cell, session, Write, Show, execution.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Write("\n⚠️ Stopped.\n"); + } + catch (Exception ex) + { + try + { + _logger.LogWarning(ex, "Running a cell of {Path} failed.", notebook.Path); + } + catch + { + // Logging failures must not crash the cell execution RPC + } + Write($"\n❌ {ex.Message}\n"); + } + finally + { + output.Flush(); + notebook.EndExecution(); + lock (notebook.Gate) + { + cell.IsExecuting = false; + cell.HasError = !success; + cell.ExecutionTimeText = $"{clock.Elapsed.TotalMilliseconds:N0} ms"; + } + session.Notify(ClientMethods.NotebookCellState, + new CellStateNotification(notebook.Path, cell.Id, false, executionCount, success, clock.ElapsedMilliseconds)); + } + + return new ExecuteCellResult(executionCount, success, clock.ElapsedMilliseconds); + } + finally + { + notebook.ExecutionGate.Release(); + } + } + + private async Task RunCellAsync(OpenNotebook notebook, NotebookCellItem cell, SidecarSession session, + Action write, Action show, CancellationToken ct) + { + var own = cell.Language ?? notebook.Document.Kernel; + var directives = NotebookCellDirectives.Parse(cell.Source, _languages, own); + var language = _languages.Get(directives.LanguageId ?? own); + var kernel = language == null ? null : notebook.Router.GetOrCreate(language.Id); + + if (language == null || kernel == null) + { + write(language == null + ? $"❌ This cell is written in \"{own}\", which cannot run here.\n" + : $"❌ {language.DisplayName} code cannot run in notebook cells.\n"); + return false; + } + + if (directives.Errors.Count > 0) + { + foreach (var error in directives.Errors) write($"❌ {error}\n"); + return false; + } + + var query = new ToolchainQuery(Path.GetDirectoryName(notebook.FullPath), _workspace.Paths.Root); + if (!await notebook.Directives.RunAsync(directives, language, kernel, query, write, ct).ConfigureAwait(false)) return false; + + var request = new KernelExecutionRequest + { + Code = directives.Code, + SourceId = cell.Id, + Label = $"[{cell.ExecutionCount}]", + OnConsole = write, + OnRichOutput = show, + OnInputRequest = (prompt, password, token) => session.RequestInputAsync(new InputRequest(prompt, password), token), + }; + + var execution = kernel.CanForceStop + ? kernel.ExecuteAsync(request, ct) + : Task.Run(() => kernel.ExecuteAsync(request, ct), CancellationToken.None); + if (!kernel.CanForceStop && !await ExecutionAbandonment.WaitWithGraceAsync(execution, ct).ConfigureAwait(false)) + { + write("\n⚠️ The cell did not respond to Stop and was abandoned; its kernel keeps running in the background.\n"); + return false; + } + + var result = await execution.ConfigureAwait(false); + if (!result.Success && result.Diagnostics.Count > 0) + { + foreach (var diagnostic in result.Diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)) + { + write($"{diagnostic.LocationString}: {diagnostic.Id} {diagnostic.Message}\n"); + } + } + return result.Success; + } + + public void Interrupt(string path) => Get(path).CancelExecution(); + + public void Restart(string path) + { + var notebook = Get(path); + notebook.CancelExecution(); + notebook.Router.ResetAll(); + } + + public async Task> VariablesAsync(string path, CancellationToken ct) + { + var variables = await Get(path).Router.GetVariablesAsync(ct).ConfigureAwait(false); + return variables.Select(v => new VariableDto(v.Name, v.TypeName, v.ValueDisplay, v.Kernel)).ToList(); + } + + public async Task SaveAsync(NotebookSaveParams request, CancellationToken ct) + { + var notebook = Get(request.Path); + string text; + lock (notebook.Gate) + { + if (!notebook.Dirty) return new SaveResult(notebook.DiskVersion); + + var onDisk = File.Exists(notebook.FullPath) ? DocumentService.DiskVersionOf(new FileInfo(notebook.FullPath)) : null; + if (!request.Force && onDisk != null && onDisk != notebook.DiskVersion) + { + throw Error(ErrorCodes.Conflict, $"'{notebook.Path}' changed on disk since it was opened."); + } + + notebook.Document.LastModified = DateTime.UtcNow; + text = NotebookFileFormat.Write(notebook.Document, notebook.Kind, _languages); + } + + await AtomicFile.WriteAllTextAsync(notebook.FullPath, text, new UTF8Encoding(false), ct).ConfigureAwait(false); + lock (notebook.Gate) + { + notebook.DiskVersion = DocumentService.DiskVersionOf(new FileInfo(notebook.FullPath)); + notebook.Dirty = false; + return new SaveResult(notebook.DiskVersion); + } + } + + public Task CloseAsync(string path) + { + if (_open.TryRemove(path, out var notebook)) + { + notebook.Dispose(); + } + else if (_workspace.Paths.TryResolve(path, out var full)) + { + var relative = _workspace.Paths.ToRelative(full); + if (_open.TryRemove(relative, out notebook)) + { + notebook.Dispose(); + } + } + return Task.CompletedTask; + } + + private async Task GetOrOpenAsync(SidecarSession session, string path, CancellationToken ct) + { + if (_open.TryGetValue(path, out var open)) return open; + + var full = _workspace.Resolve(path); + var relative = _workspace.Paths.ToRelative(full); + if (_open.TryGetValue(relative, out open)) return open; + + if (File.Exists(full) && NotebookFileFormat.IsNotebookPath(full)) + { + await OpenAsync(session, relative, ct).ConfigureAwait(false); + if (_open.TryGetValue(relative, out open)) return open; + } + + var doc = new NotebookDocumentItem + { + Title = Path.GetFileNameWithoutExtension(full), + Kernel = LanguageIds.CSharp + }; + var kind = NotebookFileFormat.IsNotebookPath(full) + ? NotebookFileFormat.KindOf(full) + : NotebookFileKind.Frynb; + + var created = new OpenNotebook(relative, full, doc, kind, "0", _languages, _workspace.Paths.Root); + return _open.GetOrAdd(relative, created); + } + + private OpenNotebook Get(string path) + { + if (_open.TryGetValue(path, out var notebook)) return notebook; + if (_workspace.Paths.TryResolve(path, out var full)) + { + var relative = _workspace.Paths.ToRelative(full); + if (_open.TryGetValue(relative, out notebook)) return notebook; + } + throw Error(ErrorCodes.NotFound, $"'{path}' is not open."); + } + + private static void Keep(NotebookCellItem cell, RichCellOutput rich) + { + if (rich.Visual is { } visual) + { + (cell.Visuals ??= []).Add(VisualOutputSnapshot.From(visual)); + if (VisualSvgRenderer.TryRender(visual.Spec, out var html)) + { + cell.HtmlContent = html; + } + return; + } + + switch (rich.Kind) + { + case CellOutputKind.Image when rich.ImageBytes is { Length: > 0 }: + cell.ImageBytes = rich.ImageBytes; + cell.ImageFormat = rich.ImageFormat ?? "PNG"; + cell.ImageWidth = rich.ImageWidth; + cell.ImageHeight = rich.ImageHeight; + var mime = (rich.ImageFormat ?? "PNG").ToUpperInvariant() switch + { + "JPEG" or "JPG" => "image/jpeg", + "GIF" => "image/gif", + "SVG" => "image/svg+xml", + _ => "image/png" + }; + var base64 = Convert.ToBase64String(rich.ImageBytes); + cell.HtmlContent = $$""" +
+ Synthesized Image +
+ """; + break; + case CellOutputKind.Html: + cell.HtmlContent = rich.HtmlContent; + cell.MarkdownContent = rich.MarkdownSource; + break; + case CellOutputKind.Table when rich.TableResult is { } table: + cell.TableSnapshot = table.ToSnapshot(); + cell.HtmlContent = InteractiveTableHtmlRenderer.Render(table); + break; + case CellOutputKind.ObjectInspector when rich.InspectorNode is { } node: + cell.InspectorSnapshot = node.ToSnapshot(); + break; + case CellOutputKind.Control: + cell.HadInteractiveControl = true; + break; + } + } + + private static void ClearOutputs(NotebookCellItem cell) + { + cell.OutputText = string.Empty; + cell.HasError = false; + cell.ImageBytes = null; + cell.ImageFormat = null; + cell.ImageWidth = null; + cell.ImageHeight = null; + cell.HtmlContent = null; + cell.MarkdownContent = null; + cell.TableSnapshot = null; + cell.InspectorSnapshot = null; + cell.Visuals = null; + cell.HadInteractiveControl = false; + } + + private static NotebookCell ToDto(NotebookCellItem cell) + { + var outputs = new List(); + if (!string.IsNullOrEmpty(cell.OutputText)) outputs.Add(new CellOutput(cell.HasError ? "error" : "stream", cell.OutputText)); + + foreach (var rich in SavedOutputs(cell)) + { + if (RichOutputMime.ToBundle(rich, out var metadata) is { } bundle) + { + var data = Newtonsoft.Json.Linq.JObject.Parse(bundle.ToJsonString()); + var meta = metadata.Count > 0 ? Newtonsoft.Json.Linq.JObject.Parse(metadata.ToJsonString()) : null; + outputs.Add(new CellOutput("display", Data: data, Metadata: meta)); + } + } + + return new NotebookCell(cell.Id, cell.Type == CellType.Markdown ? "markdown" : "code", cell.Language, cell.Source, + cell.ExecutionCount, cell.HasError, outputs); + } + + private static IEnumerable SavedOutputs(NotebookCellItem cell) + { + if (cell.ImageBytes is { Length: > 0 } image) + { + yield return new RichCellOutput { Kind = CellOutputKind.Image, ImageBytes = image, ImageFormat = cell.ImageFormat, ImageWidth = cell.ImageWidth, ImageHeight = cell.ImageHeight }; + } + if (!string.IsNullOrEmpty(cell.HtmlContent)) + { + yield return new RichCellOutput { Kind = CellOutputKind.Html, HtmlContent = cell.HtmlContent, MarkdownSource = cell.MarkdownContent }; + } + if (cell.TableSnapshot is { } table) yield return new RichCellOutput { Kind = CellOutputKind.Table, TableResult = table.ToLive() }; + if (cell.InspectorSnapshot is { } inspector) yield return new RichCellOutput { Kind = CellOutputKind.ObjectInspector, InspectorNode = inspector.ToLive() }; + foreach (var visual in cell.Visuals ?? []) + { + if (visual.ToOutput() is { } live) yield return new RichCellOutput { Visual = live }; + } + } + + private static LocalRpcException MissingCell(string id) => Error(ErrorCodes.NotFound, $"There is no cell '{id}'."); + + private static LocalRpcException Error(int code, string message) => new(message) { ErrorCode = code }; + + private sealed class OpenNotebook : IDisposable + { + public OpenNotebook(string path, string fullPath, NotebookDocumentItem document, NotebookFileKind kind, string diskVersion, + ILanguageCatalog languages, string workspaceRoot) + { + Path = path; + FullPath = fullPath; + Document = document; + Kind = kind; + DiskVersion = diskVersion; + Router = new NotebookKernelRouter(languages, new KernelCreationContext(() => System.IO.Path.GetDirectoryName(fullPath), () => workspaceRoot)); + Directives = new NotebookDirectiveRunner(languages, Router); + } + + private CancellationTokenSource? _execution; + + public Lock Gate { get; } = new(); + public string Path { get; } + public string FullPath { get; } + public NotebookDocumentItem Document { get; } + public NotebookFileKind Kind { get; } + public string DiskVersion { get; set; } + public bool Dirty { get; set; } + public NotebookKernelRouter Router { get; } + public NotebookDirectiveRunner Directives { get; } + public SemaphoreSlim ExecutionGate { get; } = new(1, 1); + + public void BeginExecution(CancellationTokenSource execution) + { + lock (Gate) _execution = execution; + } + + public void EndExecution() + { + lock (Gate) _execution = null; + } + + public void CancelExecution() + { + lock (Gate) _execution?.Cancel(); + } + + public void Dispose() + { + CancelExecution(); + Router.Dispose(); + ExecutionGate.Dispose(); + } + } +} diff --git a/FrySharp.Sidecar/Services/RunOutput.cs b/FrySharp.Sidecar/Services/RunOutput.cs new file mode 100644 index 00000000..c441c2ad --- /dev/null +++ b/FrySharp.Sidecar/Services/RunOutput.cs @@ -0,0 +1,96 @@ +using System.Text; + +namespace FrySharp.Sidecar.Services; + +/// +/// A run's output on its way to the client. Text is sent in batches: when 8 KB is pending, +/// or 50 ms after the first character, so a program printing 100,000 lines sends a few hundred messages, not 100,000. +/// +public sealed class RunOutput : IDisposable +{ + public const int FlushChars = 8 * 1024; + public static readonly TimeSpan FlushDelay = TimeSpan.FromMilliseconds(50); + public const int MaxTranscriptChars = 1024 * 1024; + public const string TrimmedMarker = "… (earlier output trimmed) …\r\n"; + + private readonly Action _send; + private readonly Lock _gate = new(); + private readonly StringBuilder _pending = new(); + private readonly StringBuilder _transcript = new(); + private readonly Timer _timer; + private bool _timerArmed; + private bool _trimmed; + + public RunOutput(Action send) + { + _send = send; + _timer = new Timer(_ => Flush(), null, Timeout.Infinite, Timeout.Infinite); + } + + public void Append(string text) + { + if (string.IsNullOrEmpty(text)) return; + var flushNow = false; + lock (_gate) + { + _pending.Append(text); + AppendTranscript(text); + if (_pending.Length >= FlushChars) + { + flushNow = true; + } + else if (!_timerArmed) + { + _timerArmed = true; + _timer.Change(FlushDelay, Timeout.InfiniteTimeSpan); + } + } + + if (flushNow) Flush(); + } + + public void Flush() + { + string batch; + lock (_gate) + { + _timerArmed = false; + if (_pending.Length == 0) return; + batch = _pending.ToString(); + _pending.Clear(); + _send(batch); + } + } + + public string Transcript + { + get + { + lock (_gate) return (_trimmed ? TrimmedMarker : string.Empty) + _transcript; + } + } + + private void AppendTranscript(string text) + { + _transcript.Append(text); + if (_transcript.Length <= MaxTranscriptChars) return; + + var cut = _transcript.Length - MaxTranscriptChars * 3 / 4; + for (var i = cut; i < _transcript.Length && i < cut + 4096; i++) + { + if (_transcript[i] == '\n') + { + cut = i + 1; + break; + } + } + _transcript.Remove(0, cut); + _trimmed = true; + } + + public void Dispose() + { + Flush(); + _timer.Dispose(); + } +} diff --git a/FrySharp.Sidecar/Services/RunService.cs b/FrySharp.Sidecar/Services/RunService.cs new file mode 100644 index 00000000..cd869b5e --- /dev/null +++ b/FrySharp.Sidecar/Services/RunService.cs @@ -0,0 +1,214 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using FrySharp.Sidecar.Protocol; +using FrySharp.Sidecar.Sessions; +using Microsoft.Extensions.Logging; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Services; + +/// +/// Executes scripts through and streams results back over JSON-RPC. +/// +public sealed class RunService +{ + private readonly ConcurrentDictionary _runs = new(StringComparer.Ordinal); + private readonly LanguageRegistry _languages; + private readonly WorkspaceService _workspace; + private readonly FileRunner _runner; + private readonly ILogger _logger; + + public RunService( + LanguageRegistry languages, + WorkspaceService workspace, + FileRunner runner, + ILogger logger) + { + _languages = languages; + _workspace = workspace; + _runner = runner; + _logger = logger; + } + + public RunStartResult Start(SidecarSession session, RunStartParams request) + { + if (!IsValidRunId(request.RunId)) + { + throw Error(ErrorCodes.Invalid, "A run id is 1 to 64 letters, digits, '-' or '_'."); + } + + string? fullPath = null; + if (request.Path is { Length: > 0 } path) + { + fullPath = _workspace.Resolve(path); + if (!File.Exists(fullPath)) throw Error(ErrorCodes.NotFound, $"'{path}' doesn't exist."); + } + else if (string.IsNullOrEmpty(request.Code)) + { + throw Error(ErrorCodes.Invalid, "Name a file to run, or give C# code."); + } + + var language = request.LanguageId is { Length: > 0 } id ? _languages.Get(id) + : fullPath != null ? _languages.LanguageOf(fullPath) + : _languages.Get(LanguageIds.CSharp); + if (language == null || !FileRunner.CanRun(language)) + { + throw Error(ErrorCodes.NotSupported, fullPath != null + ? $"'{_workspace.Paths.ToRelative(fullPath)}' can't be run here." + : $"{request.LanguageId} code can't be run here."); + } + if (fullPath == null && language.Id != LanguageIds.CSharp) + { + throw Error(ErrorCodes.NotSupported, $"{language.DisplayName} code can only be run from a file."); + } + + var run = new Run(request.RunId, fullPath == null ? "C# code" : Path.GetFileName(fullPath), + fullPath == null ? null : _workspace.Paths.ToRelative(fullPath), session); + if (!_runs.TryAdd(run.Id, run)) + { + throw Error(ErrorCodes.AlreadyExists, $"Already has an active run '{run.Id}'."); + } + + _ = Task.Run(async () => + { + var clock = Stopwatch.StartNew(); + var exitCode = 1; + IReadOnlyList problems = []; + try + { + using var stdin = new InteractiveStdinReader(run.Token); + stdin.WaitingStarted += () => session.Notify(ClientMethods.RunInputRequested, new RunIdParams(run.Id)); + run.Stdin = stdin; + var options = new FileRunOptions + { + OnOutput = text => run.Output.Append(ToTerminal(text)), + OnDisplay = output => Display(run, output), + CSharpInput = stdin, + OnProcessStarted = process => run.Process = process, + Arguments = request.Arguments ?? [], + WorkspaceRoot = _workspace.Paths.Root, + }; + + var result = fullPath != null + ? await _runner.RunFileAsync(fullPath, language, options, run.Token).ConfigureAwait(false) + : await _runner.RunCSharpAsync(request.Code!, options, run.Token).ConfigureAwait(false); + exitCode = result.ExitCode; + problems = result.Problems.Select(d => Problem(run.Path, d)).ToList(); + } + catch (OperationCanceledException) + { + exitCode = 130; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Run {RunId} failed.", run.Id); + run.Output.Append($"\r\n❌ {ex.Message}\r\n"); + } + finally + { + run.Finish(exitCode); + run.Output.Flush(); + session.Notify(ClientMethods.RunExited, new RunExitedNotification(run.Id, exitCode, clock.ElapsedMilliseconds, problems)); + } + }); + + return new RunStartResult(run.Id, run.Title); + } + + public bool Stop(SidecarSession session, string runId) + { + if (!TryGet(runId, out var run) || !run.Running) return false; + run.Stop(); + return true; + } + + public async Task InputAsync(SidecarSession session, RunInputParams request) + { + if (!TryGet(request.RunId, out var run) || !run.Running) return; + run.Output.Append(request.Text + "\r\n"); + if (run.Process is { AcceptsInput: true } process) + { + await process.SendInputAsync(request.Text + "\n").ConfigureAwait(false); + } + else + { + run.Stdin?.PostInput(request.Text); + } + } + + public IReadOnlyList List(SidecarSession session) => + _runs.Values + .OrderBy(r => r.Started) + .Select(r => new RunInfo(r.Id, r.Title, r.Path, r.Output.Transcript, r.Running, r.ExitCode)) + .ToList(); + + private static void Display(Run run, RichCellOutput output) + { + if (RichOutputMime.ToBundle(output, out var metadata) is not { } bundle) return; + run.Output.Flush(); + var data = Newtonsoft.Json.Linq.JObject.Parse(bundle.ToJsonString()); + var meta = metadata.Count > 0 ? Newtonsoft.Json.Linq.JObject.Parse(metadata.ToJsonString()) : null; + run.Owner.Notify(ClientMethods.RunDisplay, new RunDisplayNotification(run.Id, data, meta)); + } + + private static string ToTerminal(string text) => text.ReplaceLineEndings("\r\n"); + + private static RunProblem Problem(string? path, DiagnosticItem d) => + new(path, LanguageFeatures.ToDto(d).Severity, d.Id, d.Message, Math.Max(1, d.Line), Math.Max(1, d.Column)); + + private bool TryGet(string runId, out Run run) => _runs.TryGetValue(runId, out run!); + + private static bool IsValidRunId(string? id) => + id is { Length: > 0 and <= 64 } && id.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_'); + + private static LocalRpcException Error(int code, string message) => new(message) { ErrorCode = code }; + + private sealed class Run(string id, string title, string? path, SidecarSession owner) + { + private readonly CancellationTokenSource _cancel = new(); + private readonly Lock _gate = new(); + + public string Id { get; } = id; + public string Title { get; } = title; + public string? Path { get; } = path; + public SidecarSession Owner { get; } = owner; + public DateTime Started { get; } = DateTime.UtcNow; + public DateTime Ended { get; private set; } = DateTime.MaxValue; + public bool Running { get; private set; } = true; + public int? ExitCode { get; private set; } + public CancellationToken Token => _cancel.Token; + public RunOutput Output { get; } = new(text => owner.Notify(ClientMethods.RunOutput, new RunOutputNotification(id, text))); + public InteractiveStdinReader? Stdin { get; set; } + public ScriptRunSession? Process { get; set; } + + public void Stop() + { + ScriptRunSession? process; + lock (_gate) + { + if (!Running) return; + _cancel.Cancel(); + process = Process; + } + process?.Stop(); + } + + public void Finish(int exitCode) + { + lock (_gate) + { + Running = false; + ExitCode = exitCode; + Ended = DateTime.UtcNow; + Stdin = null; + Process = null; + _cancel.Dispose(); + } + } + } +} diff --git a/FrySharp.Sidecar/Services/WorkspaceService.cs b/FrySharp.Sidecar/Services/WorkspaceService.cs new file mode 100644 index 00000000..946cfa0a --- /dev/null +++ b/FrySharp.Sidecar/Services/WorkspaceService.cs @@ -0,0 +1,179 @@ +using FrySharp.Sidecar.Hosting; +using FrySharp.Sidecar.Protocol; +using Microsoft.Extensions.Logging; +using PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Services; + +/// +/// The workspace folder shared by the sidecar: one file index (for Go to File and Find in Files), kept current by a +/// watcher, folders listed one level at a time, and atomic file operations. +/// +public sealed class WorkspaceService : IDisposable +{ + public const int MaxFolderEntries = 5000; + + private readonly WorkspaceFileIndex _index = new(WorkspaceWalker.IsExplorerFile); + private readonly WorkspaceWatcher? _watcher; + private readonly ILogger _logger; + + public WorkspaceService(SidecarOptions options, ILogger logger) + { + _logger = logger; + Paths = new WorkspacePaths(options.WorkspaceRoot); + _ = _index.RebuildAsync(Paths.Root); + _watcher = WorkspaceWatcher.TryStart(Paths.Root, OnChangedOnDisk); + if (_watcher == null) _logger.LogWarning("Not watching {Root} for changes; workspace refreshes when requested.", Paths.Root); + } + + public WorkspacePaths Paths { get; } + + /// Raised after files changed on disk; true when files or folders came or went. + public event Action? ChangedOnDisk; + + private void OnChangedOnDisk(bool structural) + { + if (structural) _ = _index.RebuildAsync(Paths.Root); + ChangedOnDisk?.Invoke(structural); + } + + /// One folder's folders and files, folders first, each group by name; hidden ones left out. + public Task ListFolderAsync(string path, CancellationToken ct) => Task.Run(() => + { + var folder = ResolveExisting(path, allowRoot: true, mustBeFolder: true); + var entries = new List(); + var truncated = false; + var info = new DirectoryInfo(folder); + var options = new EnumerationOptions { IgnoreInaccessible = true, AttributesToSkip = 0, RecurseSubdirectories = false }; + + foreach (var entry in info.EnumerateFileSystemInfos("*", options)) + { + ct.ThrowIfCancellationRequested(); + if (entries.Count >= MaxFolderEntries) + { + truncated = true; + break; + } + + if (entry.LinkTarget != null && !Paths.TryResolve(Paths.ToRelative(entry.FullName), out _)) continue; + + if (entry is DirectoryInfo directory) + { + if (WorkspaceWalker.IsHiddenInExplorer(directory.Name)) continue; + entries.Add(new FolderEntry(directory.Name, Paths.ToRelative(directory.FullName), true, null, null)); + } + else if (WorkspaceWalker.IsExplorerFile(entry.FullName)) + { + var file = (FileInfo)entry; + entries.Add(new FolderEntry(file.Name, Paths.ToRelative(file.FullName), false, file.Length, file.LastWriteTimeUtc)); + } + } + + entries.Sort((a, b) => a.IsFolder != b.IsFolder + ? (a.IsFolder ? -1 : 1) + : StringComparer.OrdinalIgnoreCase.Compare(a.Name, b.Name)); + return new FolderListing(Paths.ToRelative(folder), entries, truncated); + }, ct); + + public FindFilesResult FindFiles(string query, int limit) + { + var matches = string.IsNullOrWhiteSpace(query) + ? [] + : _index.Find(query, Math.Clamp(limit, 1, 500)).Select(m => new FileMatch(m.RelativePath, m.Name)).ToList(); + return new FindFilesResult(matches, _index.IsBuilding); + } + + public async Task SearchAsync(SearchParams request, IReadOnlyDictionary? openDocuments, + Action> onFile, CancellationToken ct) + { + var matcher = new TextMatcher(request.Query, request.MatchCase, request.WholeWord, request.IsRegex); + if (!matcher.IsValid) + { + throw new LocalRpcException(matcher.Error ?? "Type something to search for.") { ErrorCode = ErrorCodes.Invalid }; + } + + var summary = await WorkspaceTextSearch.RunAsync(_index.Paths, Paths.Root, matcher, openDocuments, + hits => onFile(hits.RelativePath, hits.Hits.Select(h => new SearchHit(h.Line, h.Column, h.Length, h.Text)).ToList()), + ct).ConfigureAwait(false); + return new SearchSummary(summary.FilesSearched, summary.FilesWithMatches, summary.Matches, summary.HitLimit); + } + + public async Task CreateFileAsync(string path, string content, CancellationToken ct) + { + var full = Resolve(path); + if (File.Exists(full) || Directory.Exists(full)) throw Error(ErrorCodes.AlreadyExists, $"'{path}' already exists."); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + await using (var stream = new FileStream(full, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + await using (var writer = new StreamWriter(stream)) + { + await writer.WriteAsync(content.AsMemory(), ct).ConfigureAwait(false); + } + return Paths.ToRelative(full); + } + + public string CreateFolder(string path) + { + var full = Resolve(path); + if (File.Exists(full) || Directory.Exists(full)) throw Error(ErrorCodes.AlreadyExists, $"'{path}' already exists."); + Directory.CreateDirectory(full); + return Paths.ToRelative(full); + } + + public void Delete(string path) + { + var full = Resolve(path); + var info = new FileInfo(full); + if (info.LinkTarget != null) + { + info.Delete(); + } + else if (Directory.Exists(full)) + { + Directory.Delete(full, recursive: true); + } + else if (File.Exists(full)) + { + File.Delete(full); + } + else + { + throw Error(ErrorCodes.NotFound, $"'{path}' doesn't exist."); + } + } + + public string Rename(string from, string to) + { + var source = Resolve(from); + var target = Resolve(to); + if (!File.Exists(source) && !Directory.Exists(source)) throw Error(ErrorCodes.NotFound, $"'{from}' doesn't exist."); + + var caseOnly = string.Equals(source, target, StringComparison.OrdinalIgnoreCase) && !string.Equals(source, target, StringComparison.Ordinal); + if (!caseOnly && (File.Exists(target) || Directory.Exists(target))) throw Error(ErrorCodes.AlreadyExists, $"'{to}' already exists."); + if (Directory.Exists(source) && target.StartsWith(source + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + throw Error(ErrorCodes.Invalid, "A folder can't be moved into itself."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + if (Directory.Exists(source)) Directory.Move(source, target); + else File.Move(source, target, overwrite: false); + return Paths.ToRelative(target); + } + + public string Resolve(string? path, bool allowRoot = false) => + Paths.TryResolve(path, out var full, allowRoot) + ? full + : throw Error(ErrorCodes.AccessDenied, $"'{path}' is not a path inside the workspace."); + + private string ResolveExisting(string? path, bool allowRoot, bool mustBeFolder) + { + var full = Resolve(path, allowRoot); + if (mustBeFolder ? !Directory.Exists(full) : !File.Exists(full)) throw Error(ErrorCodes.NotFound, $"'{path}' doesn't exist."); + return full; + } + + private static LocalRpcException Error(int code, string message) => new(message) { ErrorCode = code }; + + public void Dispose() => _watcher?.Dispose(); +} diff --git a/FrySharp.Sidecar/Sessions/SidecarRpc.cs b/FrySharp.Sidecar/Sessions/SidecarRpc.cs new file mode 100644 index 00000000..2512dbd3 --- /dev/null +++ b/FrySharp.Sidecar/Sessions/SidecarRpc.cs @@ -0,0 +1,199 @@ +using FrySharp.Sidecar.Hosting; +using FrySharp.Sidecar.Protocol; +using FrySharp.Sidecar.Services; +using Microsoft.Extensions.Logging; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Sessions; + +/// +/// JSON-RPC 2.0 dispatch for the VS Code extension sidecar over stdio. +/// +public sealed class SidecarRpc( + JsonRpc connection, + SidecarOptions options, + WorkspaceService workspace, + DocumentService documents, + LanguageFeatures languageFeatures, + LanguageRegistry languages, + RunService runs, + NotebookService notebooks, + ILogger logger) +{ + private SidecarSession? _session; + + // ── Session ────────────────────────────────────────────────────────────────────────────────────────────────── + + [JsonRpcMethod("fry/initialize", UseSingleObjectParameterDeserialization = true)] + public InitializeResult Initialize(InitializeParams request) + { + if (request.ProtocolVersion != ProtocolVersion.Current) + { + throw new LocalRpcException($"Extension speaks protocol {request.ProtocolVersion}, sidecar speaks {ProtocolVersion.Current}.") + { ErrorCode = ErrorCodes.NotSupported }; + } + + _session = new SidecarSession(request.ClientId, connection, logger); + return new InitializeResult( + ProtocolVersion.Current, + typeof(SidecarRpc).Assembly.GetName().Version?.ToString(3) ?? "1.0.0", + workspace.Paths.Root, + Path.GetFileName(workspace.Paths.Root), + Languages(), + Resumed: false, + options.MaxOpenFileBytes); + } + + private List Languages() + { + return languages.All.Select(l => new LanguageInfo( + l.Id, l.DisplayName, l.ShortName, + l.Id == LanguageIds.CSharp ? [.. HeadlessLanguages.CSharpSourceExtensions] : l.FileExtensions, + l.Aliases, l.LineCommentPrefix, l.IconKind, l.AccentHex, + CanRun: FileRunner.CanRun(l), + CanRunCells: NotebookDirectiveRunner.IsNotebookLanguage(l), + HasCompletion: l.Id == LanguageIds.CSharp, + l.NewFileTemplate)).ToList(); + } + + // ── Workspace ──────────────────────────────────────────────────────────────────────────────────────────────── + + [JsonRpcMethod("fry/workspace/listFolder", UseSingleObjectParameterDeserialization = true)] + public Task ListFolderAsync(PathParams request, CancellationToken ct) + { + Require(); + return workspace.ListFolderAsync(request.Path, ct); + } + + [JsonRpcMethod("fry/workspace/findFiles", UseSingleObjectParameterDeserialization = true)] + public FindFilesResult FindFiles(FindFilesParams request) + { + Require(); + return workspace.FindFiles(request.Query, request.Limit); + } + + [JsonRpcMethod("fry/workspace/search", UseSingleObjectParameterDeserialization = true)] + public Task SearchAsync(SearchParams request, CancellationToken ct) + { + var session = Require(); + return workspace.SearchAsync(request, documents.OpenTexts(session), + (path, hits) => session.Notify(ClientMethods.SearchHits, new SearchHitsNotification(request.SearchId, path, hits)), ct); + } + + [JsonRpcMethod("fry/workspace/createFile", UseSingleObjectParameterDeserialization = true)] + public async Task CreateFileAsync(CreateFileParams request, CancellationToken ct) + { + Require(); + var content = request.Content ?? languages.FindSourceFileLanguage(request.Path)?.NewFileTemplate ?? string.Empty; + if (request.Content == null && LanguageFeatures.IsCSharp(request.Path)) + { + content = languages.Get(LanguageIds.CSharp)?.NewFileTemplate ?? string.Empty; + } + return new PathParams(await workspace.CreateFileAsync(request.Path, content, ct)); + } + + [JsonRpcMethod("fry/workspace/createFolder", UseSingleObjectParameterDeserialization = true)] + public PathParams CreateFolder(PathParams request) + { + Require(); + return new PathParams(workspace.CreateFolder(request.Path)); + } + + [JsonRpcMethod("fry/workspace/delete", UseSingleObjectParameterDeserialization = true)] + public void Delete(PathParams request) + { + Require(); + workspace.Delete(request.Path); + } + + [JsonRpcMethod("fry/workspace/rename", UseSingleObjectParameterDeserialization = true)] + public PathParams Rename(RenameParams request) + { + Require(); + return new PathParams(workspace.Rename(request.From, request.To)); + } + + // ── Documents ──────────────────────────────────────────────────────────────────────────────────────────────── + + [JsonRpcMethod("fry/document/open", UseSingleObjectParameterDeserialization = true)] + public Task OpenDocumentAsync(PathParams request, CancellationToken ct) + { + Require(); + return documents.OpenAsync(request.Path, ct); + } + + [JsonRpcMethod("fry/document/didOpen", UseSingleObjectParameterDeserialization = true)] + public void DidOpen(DidOpenParams request) => documents.DidOpen(Require(), request); + + [JsonRpcMethod("fry/document/didChange", UseSingleObjectParameterDeserialization = true)] + public void DidChange(DidChangeParams request) => documents.DidChange(Require(), request); + + [JsonRpcMethod("fry/document/didClose", UseSingleObjectParameterDeserialization = true)] + public void DidClose(PathParams request) => documents.DidClose(Require(), request.Path); + + [JsonRpcMethod("fry/document/save", UseSingleObjectParameterDeserialization = true)] + public Task SaveAsync(SaveParams request, CancellationToken ct) => documents.SaveAsync(Require(), request, ct); + + // ── Language features ──────────────────────────────────────────────────────────────────────────────────────── + + [JsonRpcMethod("fry/language/completion", UseSingleObjectParameterDeserialization = true)] + public Task CompletionAsync(PositionParams request, CancellationToken ct) => + languageFeatures.CompleteAsync(Require(), request, ct); + + [JsonRpcMethod("fry/language/hover", UseSingleObjectParameterDeserialization = true)] + public Task HoverAsync(PositionParams request, CancellationToken ct) => + languageFeatures.HoverAsync(Require(), request, ct); + + // ── Runs ───────────────────────────────────────────────────────────────────────────────────────────────────── + + [JsonRpcMethod("fry/run/start", UseSingleObjectParameterDeserialization = true)] + public RunStartResult StartRun(RunStartParams request) => runs.Start(Require(), request); + + [JsonRpcMethod("fry/run/stop", UseSingleObjectParameterDeserialization = true)] + public bool StopRun(RunIdParams request) => runs.Stop(Require(), request.RunId); + + [JsonRpcMethod("fry/run/input", UseSingleObjectParameterDeserialization = true)] + public Task RunInputAsync(RunInputParams request) => runs.InputAsync(Require(), request); + + [JsonRpcMethod("fry/run/list", UseSingleObjectParameterDeserialization = true)] + public IReadOnlyList ListRuns(EmptyParams request) => runs.List(Require()); + + // ── Notebooks ──────────────────────────────────────────────────────────────────────────────────────────────── + + [JsonRpcMethod("fry/notebook/open", UseSingleObjectParameterDeserialization = true)] + public Task OpenNotebookAsync(PathParams request, CancellationToken ct) => notebooks.OpenAsync(Require(), request.Path, ct); + + [JsonRpcMethod("fry/notebook/edit", UseSingleObjectParameterDeserialization = true)] + public void EditNotebook(NotebookEditParams request) => notebooks.Edit(Require(), request); + + [JsonRpcMethod("fry/notebook/execute", UseSingleObjectParameterDeserialization = true)] + public Task ExecuteCellAsync(ExecuteCellParams request, CancellationToken ct) => notebooks.ExecuteAsync(Require(), request, ct); + + [JsonRpcMethod("fry/notebook/interrupt", UseSingleObjectParameterDeserialization = true)] + public void InterruptNotebook(PathParams request) => notebooks.Interrupt(request.Path); + + [JsonRpcMethod("fry/notebook/restart", UseSingleObjectParameterDeserialization = true)] + public void RestartNotebook(PathParams request) => notebooks.Restart(request.Path); + + [JsonRpcMethod("fry/notebook/variables", UseSingleObjectParameterDeserialization = true)] + public Task> NotebookVariablesAsync(PathParams request, CancellationToken ct) => + notebooks.VariablesAsync(request.Path, ct); + + [JsonRpcMethod("fry/notebook/save", UseSingleObjectParameterDeserialization = true)] + public Task SaveNotebookAsync(NotebookSaveParams request, CancellationToken ct) => notebooks.SaveAsync(request, ct); + + [JsonRpcMethod("fry/notebook/close", UseSingleObjectParameterDeserialization = true)] + public Task CloseNotebookAsync(PathParams request) => notebooks.CloseAsync(request.Path); + + private SidecarSession Require() + { + if (_session is not { } session) + { + throw new LocalRpcException("Call fry/initialize first.") { ErrorCode = ErrorCodes.Invalid }; + } + return session; + } +} diff --git a/FrySharp.Sidecar/Sessions/SidecarSession.cs b/FrySharp.Sidecar/Sessions/SidecarSession.cs new file mode 100644 index 00000000..94d37610 --- /dev/null +++ b/FrySharp.Sidecar/Sessions/SidecarSession.cs @@ -0,0 +1,73 @@ +using System.Collections.Concurrent; +using System.Text; +using FrySharp.Sidecar.Protocol; +using Microsoft.Extensions.Logging; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Sessions; + +public sealed class OpenDocument(string path, string fullPath, string text, int version, string diskVersion, Encoding encoding) +{ + public Lock Gate { get; } = new(); + public string Path { get; } = path; + public string FullPath { get; } = fullPath; + public string Text { get; set; } = text; + public int Version { get; set; } = version; + public string DiskVersion { get; set; } = diskVersion; + public Encoding Encoding { get; set; } = encoding; + public bool ChangeOnDiskReported { get; set; } + public bool OutOfSync { get; set; } +} + +/// +/// The sidecar's active session with VS Code over the stdio pipe. +/// +public sealed class SidecarSession : IAsyncDisposable +{ + private readonly JsonRpc _connection; + private readonly ILogger _logger; + + public SidecarSession(string clientId, JsonRpc connection, ILogger logger) + { + ClientId = clientId; + _connection = connection; + _logger = logger; + } + + public string ClientId { get; } + + public ConcurrentDictionary Documents { get; } = new(StringComparer.Ordinal); + + public bool IsConnected => !_connection.IsDisposed; + + public void Notify(string method, object payload) + { + if (_connection.IsDisposed) return; + try + { + _ = _connection.NotifyWithParameterObjectAsync(method, payload); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send notification {Method}", method); + } + } + + public async Task RequestInputAsync(InputRequest request, CancellationToken ct) + { + if (_connection.IsDisposed) return null; + try + { + return await _connection.InvokeWithParameterObjectAsync(ClientMethods.InputRequest, request, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/Makefile b/Makefile index ead735f2..8e8dee88 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,25 @@ run: run-release: dotnet run --project Runner/CSharpEditorPlugin.Runner.csproj -c Release +# The headless sidecar that powers the VS Code extension over stdio JSON-RPC. +sidecar: + dotnet run --project FrySharp.Sidecar -- $(ARGS) + +cli: + dotnet run --project FrySharp.Cli -- $(ARGS) + build: dotnet build CSharpEditorPlugin.slnx +ext-install: + cd vscode-frysharp && npm install + +ext-build: + cd vscode-frysharp && npm run compile + +ext-package: + cd vscode-frysharp && npx @vscode/vsce package + test: ifeq ($(time),1) TIME=1 dotnet test Tests/CSharpEditorPlugin.Tests.csproj -c Release @@ -19,4 +35,22 @@ endif test-time: TIME=1 dotnet test Tests/CSharpEditorPlugin.Tests.csproj -c Release -.PHONY: run run-release build test test-time +# Headless core tests (Roslyn script runner, kernels, notebook file formats, storage). +test-core: + dotnet test Tests.Core/FrySharp.Core.Tests.csproj + +# Headless JSON-RPC sidecar tests (stdio protocol, notebooks, script execution, diagnostics). +test-sidecar: + dotnet test Tests.Sidecar/FrySharp.Sidecar.Tests.csproj + +test-all: test test-core test-sidecar + +# FrySharp.Core's build fails if an Avalonia package reaches it (its AssertNoAvalonia target). +verify-core: + dotnet build FrySharp.Core + +clean: + dotnet clean CSharpEditorPlugin.slnx + rm -rf vscode-frysharp/out vscode-frysharp/*.vsix + +.PHONY: run run-release sidecar cli build ext-install ext-build ext-package test test-time test-core test-sidecar test-all verify-core clean diff --git a/Runner/Program.cs b/Runner/Program.cs index 259c2e57..d62cffaf 100644 --- a/Runner/Program.cs +++ b/Runner/Program.cs @@ -14,6 +14,7 @@ public static void Main(string[] args) TaskScheduler.UnobservedTaskException += (_, e) => Console.Error.WriteLine($"[TaskScheduler.UnobservedTaskException] {e.Exception}"); + PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn.DesktopScriptHost.Configure(); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } diff --git a/Services/Display/ImageDecoder.cs b/Services/Display/ImageDecoder.cs index db2d6e9e..75c487bc 100644 --- a/Services/Display/ImageDecoder.cs +++ b/Services/Display/ImageDecoder.cs @@ -8,7 +8,7 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; /// /// Turns image files and image bytes into bitmaps without stalling the studio. Sizes come from the file header (no -/// decode); decoding runs off the UI thread and never at more than pixels across (a +/// decode, ); decoding runs off the UI thread and never at more than pixels across (a /// 24-megapixel photo decoded in full is ~100 MB and ~1 s of work on the UI thread, for a picture the screen shows at a /// fraction of that). /// @@ -20,95 +20,13 @@ public static class ImageDecoder /// Files larger than this are not decoded at all; the viewer says why instead. public const long MaxDecodeBytes = 256L * 1024 * 1024; - /// Reads an image file's pixel size from its header (PNG, GIF, BMP, JPEG), without decoding it. - public static bool TryReadDimensions(string path, out int width, out int height) - { - width = 0; - height = 0; - try - { - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - var header = new byte[(int)Math.Min(64 * 1024, fs.Length)]; - int read = fs.ReadAtLeast(header, header.Length, throwOnEndOfStream: false); - return TryReadDimensions(header.AsSpan(0, read), out width, out height); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return false; - } - } - - /// Reads an image's pixel size from the first bytes of its data (PNG, GIF, BMP, JPEG). - public static bool TryReadDimensions(ReadOnlySpan header, out int width, out int height) - { - width = 0; - height = 0; - int read = header.Length; - if (read < 10) return false; - - // PNG: 89 50 4E 47 0D 0A 1A 0A, then the IHDR chunk. - if (read >= 24 && header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47 && - header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A) - { - width = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; - height = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; - return width > 0 && height > 0; - } - - // GIF87a / GIF89a. - if (header[0] == 'G' && header[1] == 'I' && header[2] == 'F') - { - width = header[6] | (header[7] << 8); - height = header[8] | (header[9] << 8); - return width > 0 && height > 0; - } - - // BMP. - if (read >= 26 && header[0] == 'B' && header[1] == 'M') - { - width = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); - height = Math.Abs(header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24)); - return width > 0 && height > 0; - } - - // JPEG: walk the segments to the start-of-frame marker. - if (header[0] == 0xFF && header[1] == 0xD8) - { - int pos = 2; - while (pos + 4 < read) - { - if (header[pos] != 0xFF) { pos++; continue; } - while (pos < read && header[pos] == 0xFF) pos++; - if (pos >= read) break; - - byte marker = header[pos++]; - if (marker == 0xD9 || marker == 0xDA) break; // end of image, or start of scan - if (pos + 2 > read) break; - int chunkLen = (header[pos] << 8) | header[pos + 1]; - if (marker is 0xC0 or 0xC1 or 0xC2 or 0xC3 or 0xC5 or 0xC6 or 0xC7 or 0xC9 or 0xCA or 0xCB) - { - if (pos + 7 <= read) - { - height = (header[pos + 3] << 8) | header[pos + 4]; - width = (header[pos + 5] << 8) | header[pos + 6]; - return width > 0 && height > 0; - } - } - - pos += chunkLen; - } - } - - return false; - } - /// /// Decodes for display: at most pixels across when the header /// says it is wider, in full otherwise. Safe on any thread. /// public static Bitmap Decode(ReadOnlySpan header, Stream data, int maxWidth = MaxDisplayWidth) { - if (TryReadDimensions(header, out int width, out _) && width > maxWidth) + if (ImageHeader.TryReadDimensions(header, out int width, out _) && width > maxWidth) { return Bitmap.DecodeToWidth(data, maxWidth, BitmapInterpolationMode.MediumQuality); } diff --git a/Services/Display/InteractiveDisplayService.Gif.cs b/Services/Display/InteractiveDisplayService.Gif.cs index 4c93584c..abba4349 100644 --- a/Services/Display/InteractiveDisplayService.Gif.cs +++ b/Services/Display/InteractiveDisplayService.Gif.cs @@ -13,28 +13,8 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; -public static partial class Display +public sealed partial class Display { - /// - /// Displays an animated GIF in the interactive output cell. - /// - public static void Gif(byte[] gifBytes) - { - Image(gifBytes, "GIF"); - } - - /// - /// Displays an animated GIF from a file path in the interactive output cell. - /// - public static void Gif(string filePath) - { - if (File.Exists(filePath)) - { - var bytes = File.ReadAllBytes(filePath); - Image(bytes, "GIF"); - } - } - /// /// Exports recorded algorithm visualizer steps directly to an animated GIF file. /// diff --git a/Services/Display/InteractiveDisplayService.cs b/Services/Display/InteractiveDisplayService.cs index a0984979..cf7d4de5 100644 --- a/Services/Display/InteractiveDisplayService.cs +++ b/Services/Display/InteractiveDisplayService.cs @@ -1,240 +1,34 @@ -using System.Collections; -using System.Net; -using System.Text.Json; -using System.Text.RegularExpressions; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Media.Imaging; using PdfEditorApp.Plugins.CSharpEditor.Controls.Visuals; -using PdfEditorApp.Plugins.CSharpEditor.Models; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; -public static partial class Display +/// +/// The desktop studio's Display: everything shows in every host (images, HTML, +/// tables, charts, visualizers…), plus what only the desktop can show: live Avalonia controls (, +/// ), Avalonia bitmaps and GIFs drawn with a . A desktop script calls +/// both through this one name. +/// +public sealed partial class Display : Scripting.Display { - public static void Image(byte[] bytes, string format = "PNG") + private Display() { - int? width = null; - int? height = null; - - // The size is in the header: decoding the whole image just to read it (and never disposing the bitmap) cost a - // full decode and leaked native memory on every call, every frame of a Display.Image animation. - if (ImageDecoder.TryReadDimensions(bytes.AsSpan(0, Math.Min(bytes.Length, 64 * 1024)), out int w, out int h)) - { - width = w; - height = h; - } - - InteractiveDisplayContext.Emit(new RichCellOutput - { - Kind = CellOutputKind.Image, - ImageBytes = bytes, - ImageFormat = format, - ImageWidth = width, - ImageHeight = height - }); } public static void Image(Bitmap bitmap) + { + Image(EncodePng(bitmap), "PNG"); + } + + internal static byte[] EncodePng(Bitmap bitmap) { using var ms = new MemoryStream(); #pragma warning disable CS0618 bitmap.Save(ms); #pragma warning restore CS0618 - Image(ms.ToArray(), "PNG"); - } - - public static void Image(string base64OrPath) - { - if (string.IsNullOrWhiteSpace(base64OrPath)) return; - - if (base64OrPath.StartsWith("data:image", StringComparison.OrdinalIgnoreCase)) - { - var commaIdx = base64OrPath.IndexOf(','); - if (commaIdx >= 0) - { - var base64 = base64OrPath.Substring(commaIdx + 1); - var bytes = Convert.FromBase64String(base64); - Image(bytes, "PNG"); - return; - } - } - - if (File.Exists(base64OrPath)) - { - var bytes = File.ReadAllBytes(base64OrPath); - var ext = Path.GetExtension(base64OrPath).TrimStart('.').ToUpperInvariant(); - Image(bytes, string.IsNullOrEmpty(ext) ? "PNG" : ext); - return; - } - - try - { - var bytes = Convert.FromBase64String(base64OrPath); - Image(bytes, "PNG"); - } - catch - { - Console.WriteLine($"[Display] Could not decode image data: {base64OrPath}"); - } - } - - public static void Image(object anyImage) - { - if (anyImage == null) return; - - if (anyImage is byte[] bytes) - { - Image(bytes); - return; - } - - if (anyImage is Bitmap avBmp) - { - Image(avBmp); - return; - } - - if (anyImage is string str) - { - Image(str); - return; - } - - // Handle SkiaSharp SKBitmap, SKImage, SKData, SKSurface via reflection - var typeName = anyImage.GetType().FullName ?? string.Empty; - - if (typeName.Contains("SkiaSharp.SKBitmap")) - { - try - { - // 1. Try SKImage.FromBitmap(skBitmap) -> skImage.Encode() - var skImageType = anyImage.GetType().Assembly.GetType("SkiaSharp.SKImage"); - var fromBitmapMethod = skImageType?.GetMethod("FromBitmap", new[] { anyImage.GetType() }) - ?? skImageType?.GetMethods().FirstOrDefault(m => m.Name == "FromBitmap" && m.GetParameters().Length == 1); - if (fromBitmapMethod?.Invoke(null, new[] { anyImage }) is object skImage) - { - Image(skImage); - return; - } - - // 2. Try skBitmap.Encode(SKEncodedImageFormat.Png, 100) - var formatEnum = anyImage.GetType().Assembly.GetType("SkiaSharp.SKEncodedImageFormat"); - if (formatEnum != null && Enum.TryParse(formatEnum, "Png", out var pngVal)) - { - var formatEncodeMethod = anyImage.GetType().GetMethod("Encode", new[] { formatEnum, typeof(int) }); - if (formatEncodeMethod?.Invoke(anyImage, new[] { pngVal, 100 }) is object dataObj) - { - var toArrayMethod = dataObj.GetType().GetMethod("ToArray"); - if (toArrayMethod?.Invoke(dataObj, null) is byte[] skBytes) - { - Image(skBytes, "PNG"); - return; - } - } - } - } - catch - { - // Fallback - } - } - else if (typeName.Contains("SkiaSharp.SKImage")) - { - try - { - var encodeMethod = anyImage.GetType().GetMethod("Encode", Type.EmptyTypes); - if (encodeMethod != null) - { - var data = encodeMethod.Invoke(anyImage, null); - if (data != null) - { - var toArrayMethod = data.GetType().GetMethod("ToArray"); - if (toArrayMethod?.Invoke(data, null) is byte[] skBytes) - { - Image(skBytes, "PNG"); - return; - } - } - } - } - catch - { - // Fallback - } - } - else if (typeName.Contains("SkiaSharp.SKSurface")) - { - try - { - var snapshotMethod = anyImage.GetType().GetMethod("Snapshot"); - if (snapshotMethod?.Invoke(anyImage, null) is object snapshot) - { - Image(snapshot); - return; - } - } - catch - { - // Fallback - } - } - else if (typeName.Contains("SkiaSharp.SKData")) - { - try - { - var toArrayMethod = anyImage.GetType().GetMethod("ToArray"); - if (toArrayMethod?.Invoke(anyImage, null) is byte[] skBytes) - { - Image(skBytes, "PNG"); - return; - } - } - catch - { - // Fallback - } - } - - Console.WriteLine($"[Display] Unsupported image type: {typeName}"); - } - - public static void Html(string html) - { - InteractiveDisplayContext.Emit(new RichCellOutput - { - Kind = CellOutputKind.Html, - HtmlContent = html - }); - } - - /// Renders a small common subset of Markdown (headers, bold/italic, inline code, links) - /// through the existing Html rich-output path — notebooks already have a Markdown cell type and an - /// Html renderer, so this reuses both rather than introducing a new output kind. - public static void Markdown(string markdown) - { - Html(ConvertMarkdownToHtml(markdown ?? string.Empty)); - } - - private static string ConvertMarkdownToHtml(string markdown) - { - // Encode the raw text first so injected tags below are the only real HTML — user-typed - // Markdown must never be able to smuggle a ", "<script>alert(1)</script>")] + [InlineData("[x](\" onclick=\"alert(1))", "[x](" onclick="alert(1))")] // not a link: an address has no spaces + [InlineData("[C](https://en.wikipedia.org/wiki/C_(programming_language))", "C")] + public void TheBasicHtml_HasOnlySafeLinksAndNoMarkup(string markdown, string html) + { + Assert.Equal(html, MarkdownOutput.ToBasicHtml(markdown)); + } + + [Fact] + public void AJupyterMarkdownOutput_IsReadAsMarkdown_AndWrittenBackAsMarkdown() + { + const string ipynb = """ + { "cells": [ { "cell_type": "code", "metadata": {}, "source": "", "execution_count": 1, + "outputs": [ { "output_type": "display_data", "metadata": {}, "data": { "text/markdown": ["**bold**\n", "text"], "text/plain": [""] } } ] } ], + "metadata": {}, "nbformat": 4, "nbformat_minor": 5 } + """; + var notebook = NotebookFileFormat.ReadIpynb(ipynb, ToolchainFactAttribute.Registry); + var cell = Assert.Single(notebook.Cells); + Assert.Equal("**bold**\ntext", cell.MarkdownContent); + Assert.Equal("bold
text", cell.HtmlContent); + + using var written = System.Text.Json.JsonDocument.Parse(NotebookFileFormat.WriteIpynb(notebook, ToolchainFactAttribute.Registry)); + var data = written.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0].GetProperty("data"); + Assert.True(data.TryGetProperty("text/markdown", out _)); + Assert.False(data.TryGetProperty("text/html", out _)); + } + + [Fact] + public void ANotebookWithoutMarkdownOutputs_IsSavedWithoutTheNewField() + { + var notebook = new NotebookDocumentItem(); + notebook.Cells.Add(new NotebookCellItem { Source = "1", HtmlContent = "x" }); + Assert.DoesNotContain("MarkdownContent", NotebookFileFormat.WriteFrynb(notebook)); + } +} + +public sealed class InputRequestReaderTests +{ + [Fact] + public void EachLineRead_IsAsked_AndNoAnswerEndsTheInput() + { + var answers = new Queue(["Ada", "42", null]); + var asked = 0; + using var reader = new PdfEditorApp.Plugins.CSharpEditor.Services.Kernels.InputRequestReader((_, _, _) => + { + asked++; + return Task.FromResult(answers.Dequeue()); + }, CancellationToken.None); + + Assert.Equal('A', (char)reader.Peek()); + Assert.Equal('A', (char)reader.Read()); + Assert.Equal("da", reader.ReadLine()); // the rest of the line Read started + Assert.Equal("42", reader.ReadLine()); + Assert.Null(reader.ReadLine()); + Assert.Equal(-1, reader.Read()); + Assert.Equal(3, asked); // nothing is asked once the input has ended + } + + [Fact] + public async Task ACSharpCell_ReadsWhatThePersonTyped() + { + var kernel = (PdfEditorApp.Plugins.CSharpEditor.Services.Kernels.INotebookKernel)new PdfEditorApp.Plugins.CSharpEditor.Services.Kernels.NotebookExecutionKernel(); + var output = new StringBuilder(); + var result = await kernel.ExecuteAsync(new PdfEditorApp.Plugins.CSharpEditor.Services.Kernels.KernelExecutionRequest + { + Code = "var name = Console.ReadLine();\nConsole.WriteLine($\"hi {name}\");", + OnConsole = text => { lock (output) output.Append(text); }, + OnInputRequest = (_, _, _) => Task.FromResult("Grace"), + }, CancellationToken.None); + + Assert.True(result.Success, output.ToString()); + Assert.Contains("hi Grace", output.ToString()); + } +} + +public sealed class InteractiveTableAndInspectorTests +{ + [Fact] + public void InteractiveTableHtmlRenderer_RendersInteractiveComponents_WithBadgesAndSort() + { + var table = new DumpTableResult("Cluster Health", "Telemetry"); + table.Columns.Add(new DumpTableColumn { Header = "Node", IsNumeric = false }); + table.Columns.Add(new DumpTableColumn { Header = "Latency", IsNumeric = true }); + table.Columns.Add(new DumpTableColumn { Header = "Status", IsNumeric = false }); + + table.Rows.Add(new DumpTableRow(0, [ + new DumpTableCell { DisplayText = "node-01" }, + new DumpTableCell { DisplayText = "14.2", IsNumeric = true }, + new DumpTableCell { DisplayText = "Online" } + ])); + + var html = InteractiveTableHtmlRenderer.Render(table); + + Assert.Contains("fry-tbl-container", html); + Assert.Contains("📊 Cluster Health", html); + Assert.Contains("fry-badge-green", html); + Assert.Contains("Online", html); + Assert.Contains("fry-expander-btn", html); + Assert.Contains("fryTblSort", html); + Assert.Contains("fryTblFilter", html); + Assert.Contains("fryTblCopy", html); + } + + [Fact] + public void InteractiveTableHtmlRenderer_InspectorRendersExpandableDetails() + { + var node = new ObjectInspectorNode("ServerConfig"); + node.Properties.Add(new ObjectInspectorPropertyRow + { + Name = "Host", + SimpleValueText = "localhost", + IsString = true + }); + node.Properties.Add(new ObjectInspectorPropertyRow + { + Name = "IsActive", + SimpleValueText = "true" + }); + + var html = InteractiveTableHtmlRenderer.RenderInspector(node); + + Assert.Contains("fry-inspector-card", html); + Assert.Contains("ServerConfig", html); + Assert.Contains("Host", html); + Assert.Contains("localhost", html); + Assert.Contains("fry-badge-green", html); + } + + [Fact] + public void RichOutputMime_TableBundleIncludesInteractiveHtml() + { + var table = new DumpTableResult("Test Table"); + table.Columns.Add(new DumpTableColumn { Header = "Name" }); + table.Rows.Add(new DumpTableRow(0, [new DumpTableCell { DisplayText = "Alpha" }])); + + var richOutput = new RichCellOutput { Kind = CellOutputKind.Table, TableResult = table }; + var bundle = RichOutputMime.ToBundle(richOutput, out _); + + Assert.NotNull(bundle); + Assert.True(bundle.ContainsKey("text/html")); + Assert.Contains("fry-tbl-container", bundle["text/html"]!.ToString()); + } + + [Fact] + public void RichOutputMime_InspectorBundleIncludesInteractiveHtml() + { + var node = new ObjectInspectorNode("TestNode"); + node.Properties.Add(new ObjectInspectorPropertyRow { Name = "Key", SimpleValueText = "Value" }); + + var richOutput = new RichCellOutput { Kind = CellOutputKind.ObjectInspector, InspectorNode = node }; + var bundle = RichOutputMime.ToBundle(richOutput, out _); + + Assert.NotNull(bundle); + Assert.True(bundle.ContainsKey("text/html")); + Assert.Contains("fry-inspector-card", bundle["text/html"]!.ToString()); + } +} + diff --git a/Tests.Core/TestSupport.cs b/Tests.Core/TestSupport.cs new file mode 100644 index 00000000..02860098 --- /dev/null +++ b/Tests.Core/TestSupport.cs @@ -0,0 +1,78 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// A fresh folder under the system temp folder, deleted with the test. +public sealed class TempFolder : IDisposable +{ + public TempFolder() + { + Path = Directory.CreateTempSubdirectory("frysharp-core-tests-").FullName; + } + + public string Path { get; } + + public string File(string name, string content) + { + var path = System.IO.Path.Combine(Path, name); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + System.IO.File.WriteAllText(path, content); + return path; + } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + // A process the test started may still hold a file; the OS cleans temp. + } + } +} + +/// Runs C# the way a headless host does, collecting what it prints and shows. +public static class Headless +{ + private static readonly RoslynCompilerService Compiler = new(); + + public static async Task<(CSharpRunResult Result, string Output, List Displays)> RunAsync(string code, TimeSpan? timeout = null, CancellationToken ct = default) + { + var output = new System.Text.StringBuilder(); + var displays = new List(); + using var limit = CancellationTokenSource.CreateLinkedTokenSource(ct); + limit.CancelAfter(timeout ?? TimeSpan.FromMinutes(1)); + var result = await new InProcessCSharpRunner(Compiler).RunAsync( + code, + text => { lock (output) output.Append(text); }, + display => { lock (displays) displays.Add(display); }, + stdin: null, + limit.Token); + return (result, output.ToString(), displays); + } + + public static IReadOnlyList Diagnostics(string code) => Compiler.CheckDiagnostics(code); +} + +/// A test that needs a language's program (Python, Node.js) installed; skipped where it isn't. +public sealed class ToolchainFactAttribute : FactAttribute +{ + private static readonly LanguageRegistry Languages = + HeadlessLanguages.Create(LanguageEngineServices.Create(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "frysharp-core-tests-data"))); + + public ToolchainFactAttribute(string languageId) + { + var toolchain = Languages.Get(languageId)?.Toolchain; + var found = toolchain?.ResolveAsync(new ToolchainQuery()).GetAwaiter().GetResult().Toolchain; + if (found == null) Skip = $"Needs {languageId} installed."; + } + + public static LanguageRegistry Registry => Languages; +} diff --git a/Tests.Sidecar/FrySharp.Sidecar.Tests.csproj b/Tests.Sidecar/FrySharp.Sidecar.Tests.csproj new file mode 100644 index 00000000..37a85909 --- /dev/null +++ b/Tests.Sidecar/FrySharp.Sidecar.Tests.csproj @@ -0,0 +1,18 @@ + + + enable + enable + false + true + + + + + + + + + + + + diff --git a/Tests.Sidecar/SidecarHarness.cs b/Tests.Sidecar/SidecarHarness.cs new file mode 100644 index 00000000..99909125 --- /dev/null +++ b/Tests.Sidecar/SidecarHarness.cs @@ -0,0 +1,102 @@ +using FrySharp.Core.Tests; +using FrySharp.Sidecar.Hosting; +using FrySharp.Sidecar.Protocol; +using FrySharp.Sidecar.Services; +using FrySharp.Sidecar.Sessions; +using Microsoft.Extensions.Logging.Abstractions; +using Nerdbank.Streams; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; +using StreamJsonRpc; + +namespace FrySharp.Sidecar.Tests; + +public sealed class SidecarHarness : IAsyncDisposable +{ + public TempFolder Workspace { get; } + public JsonRpc ClientRpc { get; } + public JsonRpc ServerRpc { get; } + public List<(string Method, object? Payload)> Notifications { get; } + + private SidecarHarness(TempFolder workspace, JsonRpc clientRpc, JsonRpc serverRpc, List<(string Method, object? Payload)> notifications) + { + Workspace = workspace; + ClientRpc = clientRpc; + ServerRpc = serverRpc; + Notifications = notifications; + } + + public static async Task CreateAsync() + { + ScriptHost.Configure(ScriptHostProfile.Headless); + var workspace = new TempFolder(); + var (clientStream, serverStream) = FullDuplexStream.CreatePair(); + + var serverHandler = new HeaderDelimitedMessageHandler(serverStream, serverStream, new JsonMessageFormatter()); + var serverRpc = new JsonRpc(serverHandler); + + var options = new SidecarOptions { WorkspaceRoot = workspace.Path }; + var engines = LanguageEngineServices.Create(options.DataDirectory); + var languages = HeadlessLanguages.Create(engines); + var compiler = new RoslynCompilerService(); + var runner = new FileRunner(languages, compiler, engines.Processes); + + var workspaceService = new WorkspaceService(options, NullLogger.Instance); + var languageFeatures = new LanguageFeatures(languages, compiler, NullLogger.Instance); + var documents = new DocumentService(options, workspaceService, languageFeatures); + var runs = new RunService(languages, workspaceService, runner, NullLogger.Instance); + var notebooks = new NotebookService(languages, workspaceService, NullLogger.Instance); + + var sidecarTarget = new SidecarRpc( + serverRpc, + options, + workspaceService, + documents, + languageFeatures, + languages, + runs, + notebooks, + NullLogger.Instance); + + serverRpc.AddLocalRpcTarget(sidecarTarget); + serverRpc.StartListening(); + + var harnessNotifications = new List<(string, object?)>(); + var clientHandler = new HeaderDelimitedMessageHandler(clientStream, clientStream, new JsonMessageFormatter()); + var clientRpc = new JsonRpc(clientHandler); + + clientRpc.AddLocalRpcTarget(new ClientReceiver(harnessNotifications)); + clientRpc.StartListening(); + + var harness = new SidecarHarness(workspace, clientRpc, serverRpc, harnessNotifications); + + // Perform initialize handshake + await clientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + return harness; + } + + public async ValueTask DisposeAsync() + { + ClientRpc.Dispose(); + ServerRpc.Dispose(); + Workspace.Dispose(); + } + + private sealed class ClientReceiver(List<(string, object?)> notifications) + { + [JsonRpcMethod(ClientMethods.NotebookOutput, UseSingleObjectParameterDeserialization = true)] + public void OnNotebookOutput(NotebookOutputNotification n) => notifications.Add((ClientMethods.NotebookOutput, n)); + + [JsonRpcMethod(ClientMethods.NotebookCellState, UseSingleObjectParameterDeserialization = true)] + public void OnNotebookCellState(CellStateNotification n) => notifications.Add((ClientMethods.NotebookCellState, n)); + + [JsonRpcMethod(ClientMethods.RunOutput, UseSingleObjectParameterDeserialization = true)] + public void OnRunOutput(RunOutputNotification n) => notifications.Add((ClientMethods.RunOutput, n)); + + [JsonRpcMethod(ClientMethods.RunExited, UseSingleObjectParameterDeserialization = true)] + public void OnRunExited(RunExitedNotification n) => notifications.Add((ClientMethods.RunExited, n)); + } +} diff --git a/Tests.Sidecar/SidecarTests.cs b/Tests.Sidecar/SidecarTests.cs new file mode 100644 index 00000000..6311df10 --- /dev/null +++ b/Tests.Sidecar/SidecarTests.cs @@ -0,0 +1,148 @@ +using FrySharp.Sidecar.Protocol; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; +using Xunit; + +namespace FrySharp.Sidecar.Tests; + +public sealed class SidecarTests : IAsyncLifetime +{ + private SidecarHarness _harness = null!; + + public async Task InitializeAsync() + { + _harness = await SidecarHarness.CreateAsync(); + } + + public async Task DisposeAsync() + { + await _harness.DisposeAsync(); + } + + [Fact] + public async Task Workspace_ListFolder_ReturnsEntries() + { + var testFile = Path.Combine(_harness.Workspace.Path, "hello.txt"); + await File.WriteAllTextAsync(testFile, "Hello world"); + + var listing = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/workspace/listFolder", new PathParams("")); + + Assert.NotNull(listing); + Assert.Contains(listing.Entries, e => e.Name == "hello.txt" && !e.IsFolder); + } + + [Fact] + public async Task Notebook_OpenExecuteAndSave_Succeeds() + { + var notebook = new NotebookDocumentItem { Title = "Test Notebook", Kernel = "csharp" }; + notebook.Cells.Add(new NotebookCellItem + { + Id = "cell-1", + Type = CellType.Code, + Source = "Console.WriteLine(\"Output from cell\");" + }); + + var nbPath = Path.Combine(_harness.Workspace.Path, "test.frynb"); + await File.WriteAllTextAsync(nbPath, NotebookFileFormat.WriteFrynb(notebook)); + + var content = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/notebook/open", new PathParams("test.frynb")); + + Assert.NotNull(content); + Assert.Single(content.Cells); + Assert.Equal("cell-1", content.Cells[0].Id); + + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/notebook/execute", new ExecuteCellParams("test.frynb", "cell-1", "Console.WriteLine(\"Output from cell\");")); + + Assert.True(result.Success); + Assert.Equal(1, result.ExecutionCount); + + var saveResult = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/notebook/save", new NotebookSaveParams("test.frynb")); + + Assert.NotNull(saveResult); + } + + [Fact] + public async Task Notebook_Execute_DisplayHtml_EmitsOutputNotification() + { + var notebook = new NotebookDocumentItem { Title = "Test Notebook", Kernel = "csharp" }; + notebook.Cells.Add(new NotebookCellItem + { + Id = "cell-disp", + Type = CellType.Code, + Source = "Display.Html(\"Hello HTML\");" + }); + + var nbPath = Path.Combine(_harness.Workspace.Path, "disp.frynb"); + await File.WriteAllTextAsync(nbPath, NotebookFileFormat.WriteFrynb(notebook)); + + var content = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/notebook/open", new PathParams("disp.frynb")); + Assert.NotNull(content); + + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/notebook/execute", new ExecuteCellParams("disp.frynb", "cell-disp", "Display.Html(\"Hello HTML\");")); + + Assert.True(result.Success); + + var outputNotif = _harness.Notifications + .FirstOrDefault(n => n.Method == ClientMethods.NotebookOutput && n.Payload is NotebookOutputNotification outNotif && outNotif.Output.Kind == "display") + .Payload as NotebookOutputNotification; + + Assert.NotNull(outputNotif); + Assert.Equal("display", outputNotif.Output.Kind); + Assert.NotNull(outputNotif.Output.Data); + } + + [Fact] + public async Task Run_CSharpScript_StreamsOutputAndExits() + { + var scriptPath = Path.Combine(_harness.Workspace.Path, "script.cs"); + await File.WriteAllTextAsync(scriptPath, "System.Console.WriteLine(\"Hello from script\");"); + + var startResult = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/run/start", new RunStartParams("run-1", Path: "script.cs")); + + Assert.Equal("run-1", startResult.RunId); + + // Wait a short bit for the run to complete + var tries = 0; + IReadOnlyList runs; + do + { + await Task.Delay(50); + runs = await _harness.ClientRpc.InvokeWithParameterObjectAsync>( + "fry/run/list", new EmptyParams()); + tries++; + } while (runs.Any(r => r.Running) && tries < 40); + + var run = Assert.Single(runs); + Assert.False(run.Running); + Assert.Equal(0, run.ExitCode); + Assert.Contains("Hello from script", run.Output); + } + + [Fact] + public async Task Document_Completions_ReturnCSharpSuggestions() + { + var filePath = Path.Combine(_harness.Workspace.Path, "test.cs"); + await File.WriteAllTextAsync(filePath, "class C { void M() { System.Con } }"); + + var doc = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/document/open", new PathParams("test.cs")); + Assert.NotNull(doc); + + await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/document/didOpen", new DidOpenParams("test.cs", 1, doc.Text, doc.DiskVersion)); + + var offset = doc.Text.IndexOf("System.Con", StringComparison.Ordinal) + "System.Con".Length; + var completions = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/language/completion", new PositionParams("test.cs", 1, offset)); + + Assert.NotNull(completions); + Assert.Contains(completions.Items, c => c.Label == "Console"); + } +} diff --git a/Tests/CodeStudio/TablePreviewScaleTests.cs b/Tests/CodeStudio/TablePreviewScaleTests.cs index 2e735999..7b6855fe 100644 --- a/Tests/CodeStudio/TablePreviewScaleTests.cs +++ b/Tests/CodeStudio/TablePreviewScaleTests.cs @@ -106,13 +106,13 @@ public void ImageSizes_ComeFromTheHeader_WithoutDecoding() new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13, (byte)'I', (byte)'H', (byte)'D', (byte)'R' }.CopyTo(png, 0); png[18] = 0x17; png[19] = 0x70; // 6000 png[22] = 0x0F; png[23] = 0xA0; // 4000 - Assert.True(ImageDecoder.TryReadDimensions(png, out int w, out int h)); + Assert.True(ImageHeader.TryReadDimensions(png, out int w, out int h)); Assert.Equal((6000, 4000), (w, h)); var gif = Encoding.ASCII.GetBytes("GIF89a").Concat(new byte[] { 0x40, 0x01, 0xF0, 0x00 }).ToArray(); - Assert.True(ImageDecoder.TryReadDimensions(gif, out w, out h)); + Assert.True(ImageHeader.TryReadDimensions(gif, out w, out h)); Assert.Equal((320, 240), (w, h)); - Assert.False(ImageDecoder.TryReadDimensions(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }, out _, out _)); + Assert.False(ImageHeader.TryReadDimensions(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }, out _, out _)); } } diff --git a/Tests/Media/Gif/AnimatedGifEncoderTests.cs b/Tests/Media/Gif/AnimatedGifEncoderTests.cs index 141e9c20..f60c0bfd 100644 --- a/Tests/Media/Gif/AnimatedGifEncoderTests.cs +++ b/Tests/Media/Gif/AnimatedGifEncoderTests.cs @@ -33,7 +33,7 @@ public void EncodeToBytes_SingleFrame_ProducesValidGif89aHeaderAndDimensions() Assert.Equal("GIF89a", header); // Verify ImageDecoder reads matching dimensions - Assert.True(ImageDecoder.TryReadDimensions(gifBytes, out int readW, out int readH)); + Assert.True(ImageHeader.TryReadDimensions(gifBytes, out int readW, out int readH)); Assert.Equal(width, readW); Assert.Equal(height, readH); @@ -66,7 +66,7 @@ public void EncodeToBytes_MultipleFrames_ContainsNetscapeExtensionAndLoop() string ascii = Encoding.ASCII.GetString(gifBytes); Assert.Contains("NETSCAPE2.0", ascii); - Assert.True(ImageDecoder.TryReadDimensions(gifBytes, out int w, out int h)); + Assert.True(ImageHeader.TryReadDimensions(gifBytes, out int w, out int h)); Assert.Equal(width, w); Assert.Equal(height, h); } diff --git a/Tests/RealJavaScript/JavaScriptKernelTests.cs b/Tests/RealJavaScript/JavaScriptKernelTests.cs index d6e6c0db..e4d3977a 100644 --- a/Tests/RealJavaScript/JavaScriptKernelTests.cs +++ b/Tests/RealJavaScript/JavaScriptKernelTests.cs @@ -41,7 +41,7 @@ private ProtocolKernel Kernel() var services = new StudioLanguageServices(Path.Combine(_baseDir, "services")); var js = (JavaScriptLanguage)services.Registry.Get(LanguageIds.JavaScript)!; js.JavaScriptToolchain.Select(TestJavaScript.Require().ExecutablePath); - var kernel = (ProtocolKernel)js.NotebookKernels.Create(new KernelCreationContext(() => _notebookFolder)); + var kernel = (ProtocolKernel)js.Engine.NotebookKernels.Create(new KernelCreationContext(() => _notebookFolder)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/RealJavaScript/JavaScriptScriptRunTests.cs b/Tests/RealJavaScript/JavaScriptScriptRunTests.cs index 9366e2b1..69c719a0 100644 --- a/Tests/RealJavaScript/JavaScriptScriptRunTests.cs +++ b/Tests/RealJavaScript/JavaScriptScriptRunTests.cs @@ -42,7 +42,7 @@ private string Write(string name, string code) var node = TestJavaScript.Require(); var services = TestJavaScript.Services(Path.Combine(_dir, ".studio")); var language = (JavaScriptLanguage)services.Registry.Get(LanguageIds.JavaScript)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, node)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, node)); var output = new ConcurrentQueue(); var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); return (session, output); diff --git a/Tests/RealJavaScript/JavaScriptVisualsConformanceTests.cs b/Tests/RealJavaScript/JavaScriptVisualsConformanceTests.cs index ffd52f9a..565bdcd9 100644 --- a/Tests/RealJavaScript/JavaScriptVisualsConformanceTests.cs +++ b/Tests/RealJavaScript/JavaScriptVisualsConformanceTests.cs @@ -104,7 +104,7 @@ class L { constructor(val, next = null) { this.val = val; this.next = next; } } var node = TestJavaScript.Require(); var services = TestJavaScript.Services(Path.Combine(_dir, ".studio")); var language = (JavaScriptLanguage)services.Registry.Get(LanguageIds.JavaScript)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, node)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, node)); var console = new List(); var outputs = new List(); var processor = new ExternalOutputProcessor(console.Add, outputs.Add, visuals: session?.Visuals); @@ -122,7 +122,7 @@ private ProtocolKernel Kernel() var services = new StudioLanguageServices(Path.Combine(_dir, "services")); var js = (JavaScriptLanguage)services.Registry.Get(LanguageIds.JavaScript)!; js.JavaScriptToolchain.Select(TestJavaScript.Require().ExecutablePath); - var kernel = (ProtocolKernel)js.NotebookKernels.Create(new KernelCreationContext(() => _dir)); + var kernel = (ProtocolKernel)js.Engine.NotebookKernels.Create(new KernelCreationContext(() => _dir)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/RealPython/PythonKernelTests.cs b/Tests/RealPython/PythonKernelTests.cs index 199971cc..e447848e 100644 --- a/Tests/RealPython/PythonKernelTests.cs +++ b/Tests/RealPython/PythonKernelTests.cs @@ -42,7 +42,7 @@ private ProtocolKernel Kernel() var services = new StudioLanguageServices(Path.Combine(_baseDir, "services")); var python = (PythonLanguage)services.Registry.Get(LanguageIds.Python)!; python.PythonToolchain.Select(TestPython.Require().ExecutablePath); - var kernel = (ProtocolKernel)python.NotebookKernels.Create(new KernelCreationContext(() => _notebookFolder)); + var kernel = (ProtocolKernel)python.Engine.NotebookKernels.Create(new KernelCreationContext(() => _notebookFolder)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/RealPython/PythonScriptRunTests.cs b/Tests/RealPython/PythonScriptRunTests.cs index 2140c310..4beed29a 100644 --- a/Tests/RealPython/PythonScriptRunTests.cs +++ b/Tests/RealPython/PythonScriptRunTests.cs @@ -42,7 +42,7 @@ private string Write(string name, string code) var python = TestPython.Require(); var services = TestPython.Services(Path.Combine(_dir, ".studio")); var language = (PythonLanguage)services.Registry.Get(LanguageIds.Python)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, python)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, python)); var output = new ConcurrentQueue(); var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); return (session, output); diff --git a/Tests/RealPython/PythonVisualsConformanceTests.cs b/Tests/RealPython/PythonVisualsConformanceTests.cs index 55c02c53..c52ad36d 100644 --- a/Tests/RealPython/PythonVisualsConformanceTests.cs +++ b/Tests/RealPython/PythonVisualsConformanceTests.cs @@ -106,7 +106,7 @@ from fry import Display as D var python = TestPython.Require(); var services = TestPython.Services(Path.Combine(_dir, ".studio")); var language = (PythonLanguage)services.Registry.Get(LanguageIds.Python)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, python)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, python)); var console = new List(); var outputs = new List(); var processor = new ExternalOutputProcessor(console.Add, outputs.Add, visuals: session?.Visuals); @@ -124,7 +124,7 @@ private ProtocolKernel Kernel() var services = new StudioLanguageServices(Path.Combine(_dir, "services")); var python = (PythonLanguage)services.Registry.Get(LanguageIds.Python)!; python.PythonToolchain.Select(TestPython.Require().ExecutablePath); - var kernel = (ProtocolKernel)python.NotebookKernels.Create(new KernelCreationContext(() => _dir)); + var kernel = (ProtocolKernel)python.Engine.NotebookKernels.Create(new KernelCreationContext(() => _dir)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/Services/Display/DisplayFacadeTests.cs b/Tests/Services/Display/DisplayFacadeTests.cs new file mode 100644 index 00000000..661838df --- /dev/null +++ b/Tests/Services/Display/DisplayFacadeTests.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; +using Xunit; + +namespace CSharpEditorPlugin.Tests; + +/// +/// One Display in a desktop script: Core's members (what the browser studio and the CLI have too) and the desktop's +/// own (live controls, Animate, Avalonia bitmaps), through the same name, so scripts written before the split still compile. +/// +public class DisplayFacadeTests +{ + [Fact] + public void ADesktopScript_CallsCoresAndTheDesktopsMembers_ThroughOneDisplay() + { + const string script = """ + Display.Html("hi"); + Display.Markdown("# Title"); + Display.Table(new[] { 1, 2, 3 }, "numbers"); + Display.Image(new byte[] { 1, 2, 3 }); + Display.Image("image.png"); + Display.ThrowIfCancellationRequested(); + var token = Display.CancellationToken; + Display.Image(new Avalonia.Media.Imaging.WriteableBitmap(new Avalonia.PixelSize(1, 1), new Avalonia.Vector(96, 96))); + Display.Control(new Avalonia.Controls.TextBlock { Text = "live" }); + var box = Display.Control(() => new Avalonia.Controls.TextBox()); + var animation = Display.Animate((context, elapsed) => context.DrawEllipse(Brushes.Red, null, new Avalonia.Point(50, 50), 10, 10)); + new[] { 1, 2 }.Dump("dumped"); + """; + + var errors = new RoslynCompilerService().CheckDiagnostics(script).Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + Assert.True(errors.Count == 0, string.Join("\n", errors.Select(e => $"{e.Id} at {e.Line}: {e.Message}"))); + } + + [Fact] + public void TheDesktopsDisplay_IsCoresDisplay_WithMore() + { + var desktop = typeof(PdfEditorApp.Plugins.CSharpEditor.Services.Display.Display); + var core = typeof(PdfEditorApp.Plugins.CSharpEditor.Scripting.Display); + + Assert.Same(core, desktop.BaseType); + // Nothing of Core's is hidden by a desktop member of the same name and parameters. + const BindingFlags statics = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + var coreSignatures = core.GetMethods(statics).Select(Signature).ToHashSet(); + Assert.DoesNotContain(desktop.GetMethods(statics).Select(Signature), coreSignatures.Contains); + } + + private static string Signature(MethodInfo method) => + $"{method.Name}({string.Join(",", method.GetParameters().Select(p => p.ParameterType.FullName ?? p.ParameterType.Name))})"; +} diff --git a/Tests/Services/Execution/ExternalOutputProcessorTests.cs b/Tests/Services/Execution/ExternalOutputProcessorTests.cs index c01fe30d..a3d04e82 100644 --- a/Tests/Services/Execution/ExternalOutputProcessorTests.cs +++ b/Tests/Services/Execution/ExternalOutputProcessorTests.cs @@ -52,7 +52,6 @@ public void ProcessChunk_FryDisplayMarkerWithTable_EmitsRichOutputAndSuppressesF [Theory] [InlineData("text/plain", "42\\n", "42\n")] [InlineData("text/plain", "no newline", "no newline\n")] - [InlineData("text/markdown", "**bold**", "**bold**\n")] public void ProcessChunk_ATextDisplay_IsShownAsText_NotAsTheProtocolLine(string mime, string value, string expected) { var consoleLines = new List(); @@ -66,6 +65,21 @@ public void ProcessChunk_ATextDisplay_IsShownAsText_NotAsTheProtocolLine(string Assert.Empty(richOutputs); } + [Fact] + public void ProcessChunk_AMarkdownDisplay_IsAMarkdownOutput_LikeCSharpsDisplayMarkdown() + { + var consoleLines = new List(); + var richOutputs = new List(); + var processor = new ExternalOutputProcessor(consoleLines.Add, richOutputs.Add); + + processor.ProcessChunk("__FRY_DISPLAY__ {\"type\":\"display\",\"data\":{\"text/markdown\":\"**bold**\",\"text/plain\":\"**bold**\"},\"metadata\":{}}\n"); + processor.Flush(); + + Assert.Empty(consoleLines); + var markdown = Assert.Single(richOutputs); + Assert.Equal((CellOutputKind.Html, "**bold**", "bold"), (markdown.Kind, markdown.MarkdownSource, markdown.HtmlContent)); + } + [Fact] public void ProcessChunk_AShareLine_IsHandedToTheShareCallback_AndNotShown() { diff --git a/Tests/Settings/ThemedSyntaxAndChartsTests.cs b/Tests/Settings/ThemedSyntaxAndChartsTests.cs index d2c52f59..5f7d0d82 100644 --- a/Tests/Settings/ThemedSyntaxAndChartsTests.cs +++ b/Tests/Settings/ThemedSyntaxAndChartsTests.cs @@ -114,7 +114,7 @@ public void Charts_UseTheThemesSeriesColours_AndTheDefaultPaletteOtherwise() { var engine = StudioAppContext.Instance.ThemeEngine; engine.ApplyTheme(BuiltInThemes.DarkPlus.Id); - Assert.Equal(ChartPaletteService.ExpressivePalette[2], ChartPaletteService.GetSeriesColor(2)); + Assert.Equal(ChartPalette.ExpressivePalette[2], ChartPaletteService.GetSeriesColor(2)); var theme = HarmonicColorGenerator.GenerateHarmonicTheme(new HarmonicConfiguration { BaseHue = 200 }, isDark: false); engine.RegisterTheme(theme); diff --git a/Tests/TestSupport/DesktopScriptHostSetup.cs b/Tests/TestSupport/DesktopScriptHostSetup.cs new file mode 100644 index 00000000..0fac753f --- /dev/null +++ b/Tests/TestSupport/DesktopScriptHostSetup.cs @@ -0,0 +1,14 @@ +using System.Runtime.CompilerServices; +using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; + +namespace CSharpEditorPlugin.Tests.TestSupport; + +/// +/// The tests exercise the desktop studio, so scripts compile against the desktop profile, as they do in the app. It is set +/// when the test assembly loads: before any test, whichever runs first. +/// +internal static class DesktopScriptHostSetup +{ + [ModuleInitializer] + internal static void Configure() => DesktopScriptHost.Configure(); +} diff --git a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Explorer.cs b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Explorer.cs index be5ecff7..14fadd90 100644 --- a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Explorer.cs +++ b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Explorer.cs @@ -29,9 +29,11 @@ public partial class CSharpCodeStudioViewModel public string ExplorerTruncationText => $"Showing the first {_storageService.WorkspaceFileLimit:N0} files. This folder has more: press Ctrl+P to open any file by name, or open a subfolder."; - // The storage's StructureVersion the tree was last built at (-1: never built). The tree is rebuilt from a full - // workspace scan only when this has moved, not on every tab switch. - private long _explorerStructureVersion = -1; + private ExplorerRefresher? _explorerRefresher; + + // When the tree is listed: never while the studio is built, but on the first visit, from the host once the engine + // is warm, and when the workspace changed (see ExplorerRefresher). + private ExplorerRefresher ExplorerRefresh => _explorerRefresher ??= new ExplorerRefresher(_storageService, _activities, ShowExplorerListing); private LazyExplorerTree? _lazyExplorer; @@ -60,36 +62,9 @@ private void ShowExplorerListing(WorkspaceListing listing) HighlightExplorerItem(Script?.Id, Script?.Title, Script?.SourceFilePath); } - public void PopulateExplorerTree() - { - try - { - var version = _storageService.StructureVersion; - ShowExplorerListing(_storageService.LoadExplorerListingAsync().GetAwaiter().GetResult()); - _explorerStructureVersion = version; - } - catch (Exception ex) - { - Debug.WriteLine($"[CSharpEditorPlugin] Failed to populate script explorer tree: {ex.Message}"); - ExplorerRootItems.Clear(); - } - } - + /// Lists the workspace and redraws the Explorer, whether or not anything changed (the Refresh button). [RelayCommand] - public async Task RefreshExplorerAsync() - { - try - { - // Taken before the scan: a change that lands while it runs leaves the tree stale, so the next check reloads. - var version = _storageService.StructureVersion; - ShowExplorerListing(await _storageService.LoadExplorerListingAsync()); - _explorerStructureVersion = version; - } - catch (Exception ex) - { - Debug.WriteLine($"[CSharpEditorPlugin] Failed to refresh script explorer tree: {ex.Message}"); - } - } + public Task RefreshExplorerAsync() => ExplorerRefresh.RefreshAsync(); /// /// Brings the Explorer up to date without rescanning the workspace when nothing in it has changed: switching a tab @@ -98,11 +73,7 @@ public async Task RefreshExplorerAsync() /// public async Task RefreshExplorerIfStaleAsync() { - if (_explorerStructureVersion != _storageService.StructureVersion) - { - await RefreshExplorerAsync(); - return; - } + if (await ExplorerRefresh.RefreshIfStaleAsync()) return; HighlightExplorerItem(Script?.Id, Script?.Title, Script?.SourceFilePath); } @@ -115,7 +86,7 @@ public async Task RefreshExplorerIfStaleAsync() public void OnActivated() { _isPageActive = true; - _ = RefreshExplorerIfStaleAsync(); + RefreshExplorerIfStaleAsync().FireAndForget(_activities, "Listing the workspace"); // Reading the file index starts (or refreshes) its background walk, so Go to File is ready when it is used. _ = _storageService.FileIndex; diff --git a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Images.cs b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Images.cs index 9b3bb52a..42d70cc4 100644 --- a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Images.cs +++ b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Images.cs @@ -226,7 +226,7 @@ private void UpdateImageStateForDocument(ScriptDocumentItem document) // The size comes from the header at once; the pixels are decoded off the UI thread (a camera photo took // ~6 s here before), at most ImageDecoder.MaxDisplayWidth across. - if (ImageDecoder.TryReadDimensions(path, out var w, out var h)) + if (ImageHeader.TryReadDimensions(path, out var w, out var h)) { ImageDimensionsText = $"{w} × {h} px"; } @@ -300,5 +300,5 @@ private async Task DecodeActiveImageAsync(string documentId, string path, Cancel } public static bool TryReadImageHeaderDimensions(string path, out int width, out int height) => - ImageDecoder.TryReadDimensions(path, out width, out height); + ImageHeader.TryReadDimensions(path, out width, out height); } diff --git a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Search.cs b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Search.cs index 86490b30..246505ff 100644 --- a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Search.cs +++ b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Search.cs @@ -6,6 +6,7 @@ using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; using PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; using PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common; +using PdfEditorApp.Plugins.CSharpEditor.Services.Common; namespace PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio; diff --git a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.cs b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.cs index fa8f0de7..5ce3b2d2 100644 --- a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.cs +++ b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.cs @@ -399,12 +399,12 @@ public CSharpCodeStudioViewModel( _languages.Registry.Changed += OnLanguagesRegistryChanged; TriggerDiagnosticsCheck(); - PopulateExplorerTree(); + // The Explorer is listed later, off the engine's start-up path: see ExplorerRefresh. _storageService.ActiveWorkspaceChanged += () => Dispatcher.UIThread.Post(() => { UpdateSideBarTitle(); - _ = RefreshExplorerAsync(); + RefreshExplorerAsync().FireAndForget(_activities, "Listing the workspace"); }); // What a workspace search listed belongs to the folder that was open: search the new one for the same text. _storageService.ActiveWorkspaceChanged += () => _postToUiThread(() => @@ -414,7 +414,7 @@ public CSharpCodeStudioViewModel( // Files changed outside the studio: catch up now if this page is on screen, else on the next visit (OnActivated). _storageService.ExternalChangeDetected += () => Dispatcher.UIThread.Post(() => { - if (_isPageActive) _ = RefreshExplorerIfStaleAsync(); + if (_isPageActive) RefreshExplorerIfStaleAsync().FireAndForget(_activities, "Listing the workspace"); }); OnActiveLanguageChanged(); InitializeNuGetPackages(); diff --git a/ViewModels/CodeStudio/Explorer/ExplorerRefresher.cs b/ViewModels/CodeStudio/Explorer/ExplorerRefresher.cs new file mode 100644 index 00000000..ddc2b25f --- /dev/null +++ b/ViewModels/CodeStudio/Explorer/ExplorerRefresher.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using PdfEditorApp.Plugins.CSharpEditor.Services.Activities; +using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; +using PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; + +namespace PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio.Explorer; + +/// +/// When an Explorer lists the workspace, the same for every studio. Never while the studio is being built: that ran on +/// the engine's start-up path and read every document while the Hub was reading them too. Instead: +/// +/// on the page's first visit; +/// when the host lists it in the background once the engine is warm; +/// when the workspace changed (); +/// when asked (: the Refresh button, another folder opened). +/// +/// The newest listing wins: one that finishes after a later one began is dropped, so a slow listing of the folder just +/// left never lands over the new one. A staleness check that comes while a listing runs waits for it and then lists only +/// if the workspace changed meanwhile, so the background listing and a first visit never read the workspace twice +/// (concurrent listings at the same versions also share one walk in the storage). +/// +/// The workspace. +/// Where a slow listing shows (a line in the Explorer) and a failed one is reported. +/// Draws a listing. Runs where the listing was asked for (the UI thread in the studio), one at a time. +public sealed class ExplorerRefresher(IScriptStorageService storage, IActivityService activities, Action show) +{ + private readonly Lock _gate = new(); + private long _generation; + private Task _running = Task.CompletedTask; + + // The storage's StructureVersion the tree was last drawn at (-1: never). + private long _listedVersion = -1; + + /// True when the tree shows the workspace as it is now (its files and folders). + public bool IsCurrent => Interlocked.Read(ref _listedVersion) == storage.StructureVersion; + + /// Lists the workspace now and draws it, whether or not it changed. + public Task RefreshAsync() + { + long generation; + lock (_gate) generation = ++_generation; + + var listing = ListAsync(generation); + lock (_gate) + { + if (generation == _generation) _running = listing; + } + return listing; + } + + /// Lists the workspace only if it changed since the tree was drawn, or never was. True when it listed. + public async Task RefreshIfStaleAsync() + { + Task running; + lock (_gate) running = _running; + await running; + + if (IsCurrent) return false; + await RefreshAsync(); + return true; + } + + private async Task ListAsync(long generation) + { + using var activity = activities.Start(new ActivityOptions("Listing the workspace", ActivityLocation.Explorer)); + try + { + // Taken before the walk: a change that lands while it runs leaves the tree stale, so the next check lists again. + var version = storage.StructureVersion; + var listing = await storage.LoadExplorerListingAsync(); + lock (_gate) + { + if (generation != _generation) return; + show(listing); + Interlocked.Exchange(ref _listedVersion, version); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[CSharpEditorPlugin] Listing the workspace for the Explorer failed: {ex}"); + activity.Fail(ex); + } + } +} diff --git a/ViewModels/CodeStudio/Explorer/ExplorerRowList.cs b/ViewModels/CodeStudio/Explorer/ExplorerRowList.cs index 0881b554..cd0f106f 100644 --- a/ViewModels/CodeStudio/Explorer/ExplorerRowList.cs +++ b/ViewModels/CodeStudio/Explorer/ExplorerRowList.cs @@ -2,6 +2,7 @@ using System.Collections.Specialized; using System.ComponentModel; using PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common; +using PdfEditorApp.Plugins.CSharpEditor.Services.Common; namespace PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio.Explorer; diff --git a/ViewModels/Common/StudioWorkspaceExplorer.cs b/ViewModels/Common/StudioWorkspaceExplorer.cs index 5e711ad8..5ea67fc4 100644 --- a/ViewModels/Common/StudioWorkspaceExplorer.cs +++ b/ViewModels/Common/StudioWorkspaceExplorer.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Activities; using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; using PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; using PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio.Explorer; @@ -22,19 +23,22 @@ public sealed partial class StudioWorkspaceExplorer : ObservableObject, IExplore private readonly IScriptStorageService _storage; private readonly Func _open; private readonly Func> _createDocument; + private readonly ExplorerRefresher _refresh; private LazyExplorerTree? _lazy; - private long _listedVersion = -1; private string? _highlightedId; /// The workspace. /// Opens a file the user picked (the studio decides where). /// Creates the studio's own kind of document in a folder (path from the root, null for the top) and returns its id. - public StudioWorkspaceExplorer(IScriptStorageService storage, Func open, Func> createDocument) + /// Where a slow listing shows and a failed one is reported. + public StudioWorkspaceExplorer(IScriptStorageService storage, Func open, Func> createDocument, + IActivityService? activities = null) { _storage = storage; _open = open; _createDocument = createDocument; ExplorerRows = new ExplorerRowList(ExplorerRootItems); + _refresh = new ExplorerRefresher(storage, activities ?? NullActivityService.Instance, Show); } public ObservableCollection ExplorerRootItems { get; } = new(); @@ -66,32 +70,15 @@ public StudioWorkspaceExplorer(IScriptStorageService storage, FuncLists the workspace and redraws the tree, whether or not anything changed (the Refresh button). [RelayCommand] - public async Task RefreshExplorer() - { - var generation = Interlocked.Increment(ref _refreshGeneration); - try - { - // Taken before the scan: a change that lands while it runs leaves the tree stale, so the next check reloads. - var version = _storage.StructureVersion; - var listing = await _storage.LoadExplorerListingAsync(); - - // A newer refresh is on its way: an older listing must not land after it (or beside it). - if (generation != Volatile.Read(ref _refreshGeneration)) return; - lock (_treeGate) Show(listing); - _listedVersion = version; - } - catch (Exception ex) - { - Debug.WriteLine($"[CSharpEditorPlugin] Explorer refresh failed: {ex.Message}"); - } - } + public Task RefreshExplorerAsync() => _refresh.RefreshAsync(); + + /// Backward-compatibility method for tests and callers. + public Task RefreshExplorer() => RefreshExplorerAsync(); /// Lists the workspace again only when it changed since it was listed (a page coming back, say). - public Task RefreshIfStaleAsync() => _listedVersion == _storage.StructureVersion ? Task.CompletedTask : RefreshExplorer(); + public Task RefreshIfStaleAsync() => _refresh.RefreshIfStaleAsync(); private void Show(WorkspaceListing listing) { @@ -267,7 +254,7 @@ private async Task NewFileAsync(ExplorerItemViewModel? target) var id = await _createDocument(folder?.FullPath); if (id == null) return; - await RefreshExplorer(); + await RefreshExplorerAsync(); if (folder != null) await ListedAsync(folder.FullPath); var created = Find(ExplorerRootItems, i => string.Equals(i.DocumentId, id, StringComparison.OrdinalIgnoreCase)); if (created == null) return; @@ -292,7 +279,7 @@ private async Task NewNotebookAsync(ExplorerItemViewModel? target) return; } - await RefreshExplorer(); + await RefreshExplorerAsync(); if (parent != null) await ListedAsync(parent.FullPath); var created = Find(ExplorerRootItems, i => string.Equals(i.DocumentId, doc.Id, StringComparison.OrdinalIgnoreCase)); if (created != null) @@ -316,7 +303,7 @@ private async Task NewFolderAsync(ExplorerItemViewModel? target) return; } - await RefreshExplorer(); + await RefreshExplorerAsync(); if (parent != null) await ListedAsync(parent.FullPath); Find(ExplorerRootItems, i => i.IsDirectory && string.Equals(i.FullPath, path, StringComparison.OrdinalIgnoreCase))?.StartRename(); } @@ -383,7 +370,7 @@ private async Task DuplicateAsync(ExplorerItemViewModel item) try { var copy = await _storage.DuplicateFileAsync(item.FullPath); - await RefreshExplorer(); + await RefreshExplorerAsync(); var parentPath = copy.Contains('/') ? copy[..copy.LastIndexOf('/')] : null; if (parentPath != null) await ListedAsync(parentPath); if (Find(ExplorerRootItems, i => !i.IsDirectory && string.Equals(i.FullPath, copy, StringComparison.OrdinalIgnoreCase)) is { } created) diff --git a/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Documents.cs b/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Documents.cs index a480007d..45a497df 100644 --- a/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Documents.cs +++ b/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Documents.cs @@ -221,7 +221,7 @@ public async Task OpenExternalProjectAsync(string? path) return; } - await RefreshExplorer(); + await RefreshExplorerAsync(); Services.Extensibility.WorkspaceCustomizationLoader.LoadInBackground(_storageService.ActiveWorkspaceRootPath, _activities); diff --git a/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Explorer.cs b/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Explorer.cs index 3bd7149f..6e6746ec 100644 --- a/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Explorer.cs +++ b/ViewModels/Notebooks/CSharpNotebookStudioViewModel.Explorer.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Input; using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Activities; using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; using PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; using PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio.Explorer; @@ -16,34 +16,21 @@ public partial class CSharpNotebookStudioViewModel { // ── Explorer refresh ────────────────────────────────────────────────────── - // The storage's StructureVersion the tree was last built at (-1: never built). The tree is rebuilt from a full - // workspace scan only when this has moved. - private long _explorerStructureVersion = -1; + private ExplorerRefresher? _explorerRefresher; + // When the tree is listed: never while the studio is built, but on the first visit, from the host once the engine + // is warm, and when the workspace changed (see ExplorerRefresher). + private ExplorerRefresher ExplorerRefresh => _explorerRefresher ??= new ExplorerRefresher(_storageService, _activities, ShowExplorerListing); + + /// Lists the workspace and redraws the Explorer, whether or not anything changed (the Refresh button). [RelayCommand] - public async Task RefreshExplorer() - { - try - { - // Taken before the scan: a change that lands while it runs leaves the tree stale, so the next check reloads. - var version = _storageService.StructureVersion; - ShowExplorerListing(await _storageService.LoadExplorerListingAsync()); - _explorerStructureVersion = version; - } - catch (Exception ex) - { - Debug.WriteLine($"[CSharpEditorPlugin] Failed to refresh explorer tree: {ex.Message}"); - } - } + public Task RefreshExplorerAsync() => ExplorerRefresh.RefreshAsync(); - /// Reloads the Explorer only if the workspace changed since it was built (a script created in the Hub, say). - public async Task RefreshExplorerIfStaleAsync() - { - if (_explorerStructureVersion != _storageService.StructureVersion) - { - await RefreshExplorer(); - } - } + /// Backward-compatibility method for tests and callers. + public Task RefreshExplorer() => RefreshExplorerAsync(); + + /// Reloads the Explorer only if the workspace changed since it was built (a script created in the Hub, say), or never was. + public Task RefreshExplorerIfStaleAsync() => ExplorerRefresh.RefreshIfStaleAsync(); // ── Page lifecycle ──────────────────────────────────────────────────────── @@ -56,7 +43,7 @@ public void OnActivated() { _isPageActive = true; UpdateExtensibilityDocumentResolver(); - _ = RefreshExplorerIfStaleAsync(); + RefreshExplorerIfStaleAsync().FireAndForget(_activities, "Listing the workspace"); // Reading the file index starts (or refreshes) its background walk, so Go to File is ready when it is used. _ = _storageService.FileIndex; @@ -128,20 +115,6 @@ private void ShowExplorerListing(WorkspaceListing listing) } } - public void PopulateExplorerTree() - { - try - { - var version = _storageService.StructureVersion; - ShowExplorerListing(_storageService.LoadExplorerListingAsync().GetAwaiter().GetResult()); - _explorerStructureVersion = version; - } - catch (Exception ex) - { - Debug.WriteLine($"[CSharpEditorPlugin] Failed to populate explorer tree: {ex.Message}"); - ExplorerRootItems.Clear(); - } - } private void RebuildExplorerTree(IReadOnlyList folderPaths, IReadOnlyList summaries, IReadOnlyList closedFolders) { diff --git a/ViewModels/Notebooks/CSharpNotebookStudioViewModel.cs b/ViewModels/Notebooks/CSharpNotebookStudioViewModel.cs index d85acfcb..e91a6c39 100644 --- a/ViewModels/Notebooks/CSharpNotebookStudioViewModel.cs +++ b/ViewModels/Notebooks/CSharpNotebookStudioViewModel.cs @@ -15,6 +15,7 @@ using PdfEditorApp.Plugins.CSharpEditor.Services.Workspace; using PdfEditorApp.Plugins.CSharpEditor.ViewModels.CodeStudio.Explorer; using PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common; +using PdfEditorApp.Plugins.CSharpEditor.Services.Common; namespace PdfEditorApp.Plugins.CSharpEditor.ViewModels.Notebooks; @@ -452,13 +453,13 @@ public CSharpNotebookStudioViewModel( // Go to File finds any file of the workspace, not just the open tabs. QuickOpen.FileSearch = new WorkspaceFileSearch(() => _storageService.FileIndex, () => _storageService.ActiveWorkspaceRootPath, OpenWorkspaceFileAsync).Search; - PopulateExplorerTree(); + // The Explorer is listed later, off the engine's start-up path: see ExplorerRefresh. - _storageService.ActiveWorkspaceChanged += () => Dispatcher.UIThread.Post(() => _ = RefreshExplorer()); + _storageService.ActiveWorkspaceChanged += () => Dispatcher.UIThread.Post(() => RefreshExplorerAsync().FireAndForget(_activities, "Listing the workspace")); // Files changed outside the studio: catch up now if this page is on screen, else on the next visit (OnActivated). _storageService.ExternalChangeDetected += () => Dispatcher.UIThread.Post(() => { - if (_isPageActive) _ = RefreshExplorerIfStaleAsync(); + if (_isPageActive) RefreshExplorerIfStaleAsync().FireAndForget(_activities, "Listing the workspace"); }); InitializeExtensibilityBridge(); diff --git a/ViewModels/Notebooks/NotebookCanvasRows.cs b/ViewModels/Notebooks/NotebookCanvasRows.cs index a8c7ea28..116813ea 100644 --- a/ViewModels/Notebooks/NotebookCanvasRows.cs +++ b/ViewModels/Notebooks/NotebookCanvasRows.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common; +using PdfEditorApp.Plugins.CSharpEditor.Services.Common; namespace PdfEditorApp.Plugins.CSharpEditor.ViewModels.Notebooks; diff --git a/ViewModels/Notebooks/NotebookCellViewModel.cs b/ViewModels/Notebooks/NotebookCellViewModel.cs index 1f01190c..2b44b644 100644 --- a/ViewModels/Notebooks/NotebookCellViewModel.cs +++ b/ViewModels/Notebooks/NotebookCellViewModel.cs @@ -572,10 +572,13 @@ public void DisposeLiveResources() HasInteractiveControl = false; } - public void SetHtmlContent(string html) + /// The HTML to show. + /// For a Markdown output, the Markdown it was rendered from, saved with it. + public void SetHtmlContent(string html, string? markdown = null) { HtmlContent = html; Model.HtmlContent = html; + Model.MarkdownContent = markdown; HasHtmlContent = !string.IsNullOrEmpty(html); HasOutput = true; SelectedOutputTab = CellOutputTab.Html; @@ -690,6 +693,7 @@ public void ClearOutput() HtmlContent = string.Empty; HasHtmlContent = false; Model.HtmlContent = null; + Model.MarkdownContent = null; TableResult = null; HasTableOutput = false; diff --git a/ViewModels/Notebooks/NotebookTabViewModel.Languages.cs b/ViewModels/Notebooks/NotebookTabViewModel.Languages.cs index 6d8edadd..b0198948 100644 --- a/ViewModels/Notebooks/NotebookTabViewModel.Languages.cs +++ b/ViewModels/Notebooks/NotebookTabViewModel.Languages.cs @@ -32,6 +32,7 @@ public partial class NotebookTabViewModel private readonly StudioLanguageServices _languages; private readonly Func? _workspaceRoot; private readonly NotebookKernelRouter _router; + private readonly NotebookDirectiveRunner _directives; [ObservableProperty] private IReadOnlyList _activeKernels = Array.Empty(); @@ -130,8 +131,7 @@ private void RefreshKernelName() .Select(l => _router.Find(l!.Id)?.DisplayName ?? l!.DisplayName)); } - private static bool IsNotebookLanguage(ILanguageDefinition language) => - language.NotebookKernels != null && language.Has(LanguageCapabilities.NotebookCells); + private static bool IsNotebookLanguage(ILanguageDefinition language) => NotebookDirectiveRunner.IsNotebookLanguage(language); // A new cell is written in the language of the cell it's added next to (or the notebook's default). private NotebookCellItem NewCellItem(NotebookCellViewModel? nextTo, CellType type) @@ -175,86 +175,8 @@ private string GetPrecedingCodeContext(NotebookCellViewModel cell) /// Runs a cell's directives before its code: package commands, then #!shares. False when one failed (the /// cell's output says why), so the code doesn't run. /// - private async Task RunDirectivesAsync(CellDirectives directives, ILanguageDefinition language, INotebookKernel kernel, CellConsole console, CancellationToken ct) - { - foreach (var command in directives.PackageCommands) - { - if (!await RunPackageCommandAsync(language, command, kernel, console, ct)) return false; - } - - foreach (var share in directives.Shares) - { - if (!await ShareAsync(share, language, kernel, console, ct)) return false; - } - - return true; - } - - private async Task ShareAsync(ShareDirective share, ILanguageDefinition language, INotebookKernel kernel, CellConsole console, CancellationToken ct) - { - var from = _languages.Registry.Get(share.FromLanguage); - string? problem = null; - if (from == null || !IsNotebookLanguage(from)) - { - problem = $"There's no notebook language called \"{share.FromLanguage}\". Use {string.Join(" or ", _languages.Registry.NotebookLanguages.Select(l => l.Id))}."; - } - else if (!from.Has(LanguageCapabilities.ValueSharing) || !language.Has(LanguageCapabilities.ValueSharing)) - { - problem = $"{(from.Has(LanguageCapabilities.ValueSharing) ? language.DisplayName : from.DisplayName)} cells can't share values."; - } - else if (ReferenceEquals(from, language)) - { - problem = $"'{share.Name}' is already a {language.DisplayName} value: #!share copies from another language's cells."; - } - else if (_router.Find(from.Id) is not { IsSessionActive: true } source) - { - problem = $"No {from.DisplayName} cell has run yet, so there's no '{share.Name}' to copy. Run the {from.DisplayName} cell that sets it first."; - } - else - { - try - { - var json = await source.GetValueJsonAsync(share.Name, ct); - await kernel.SetValueFromJsonAsync(share.TargetName, json, ct); - return true; - } - catch (KernelValueException ex) - { - problem = ex.Message; - } - } - - console.Write($"❌ #!share (line {share.LineNumber}): {problem}\n"); - return false; - } - - private async Task RunPackageCommandAsync(ILanguageDefinition language, PackageCommand command, INotebookKernel kernel, CellConsole console, CancellationToken ct) - { - if (language.Packages is not { } packages || language.Toolchain is not { } toolchains) - { - console.Write($"❌ {language.DisplayName} cells can't install packages.\n"); - return false; - } - - var resolution = await toolchains.ResolveAsync(ToolchainQuery(), ct); - if (resolution.Toolchain is not { } toolchain) - { - console.Write((resolution.Missing?.ToText() ?? $"{toolchains.ToolName} wasn't found.") + "\n"); - return false; - } - - console.Write($"▶ {command.Text}\n"); - var result = await packages.RunAsync(command, toolchain, console.Write, ct); - if (result.Message is { Length: > 0 } message) console.Write((result.Success ? "" : "❌ ") + message.TrimEnd() + "\n"); - - // A new environment only takes over when the kernel next starts; until then the running one looks there too. - if (result.Success && result.AddedSearchPath is { } searchPath && kernel is ISearchPathKernel searchable && kernel.IsSessionActive) - { - await searchable.AddSearchPathAsync(searchPath, ct); - } - - return result.Success; - } + private Task RunDirectivesAsync(CellDirectives directives, ILanguageDefinition language, INotebookKernel kernel, CellConsole console, CancellationToken ct) => + _directives.RunAsync(directives, language, kernel, ToolchainQuery(), console.Write, ct); /// Installs the package a cell's last run was missing, then runs the cell again. public async Task InstallMissingDependencyAsync(NotebookCellViewModel cell) @@ -275,7 +197,7 @@ public async Task InstallMissingDependencyAsync(NotebookCellViewModel cell) bool installed; try { - installed = await RunPackageCommandAsync(language, packages.InstallCommand(package), kernel, console, _executionCts.Token); + installed = await _directives.RunPackageCommandAsync(language, packages.InstallCommand(package), kernel, ToolchainQuery(), console.Write, _executionCts.Token); } catch (OperationCanceledException) { diff --git a/ViewModels/Notebooks/NotebookTabViewModel.cs b/ViewModels/Notebooks/NotebookTabViewModel.cs index 6cd6e8fc..6ff0767b 100644 --- a/ViewModels/Notebooks/NotebookTabViewModel.cs +++ b/ViewModels/Notebooks/NotebookTabViewModel.cs @@ -127,6 +127,7 @@ public NotebookTabViewModel( _workspaceRoot = workspaceRoot; Kernel = new NotebookExecutionKernel(); _router = new NotebookKernelRouter(_languages.Registry, new KernelCreationContext(() => WorkingFolder, workspaceRoot), Kernel); + _directives = new NotebookDirectiveRunner(_languages.Registry, _router); // skipInitialPopulate = true when the caller will await PopulateCellsAsync() // immediately after construction (avoids blocking the UI thread for large notebooks). @@ -453,7 +454,8 @@ private async Task RunInCSharpKernelAsync(NotebookCellVie ct: ct, sourceId: cell.Id, onLiveConsole: console.Write, - onRichOutput: console.Show)); + onRichOutput: console.Show, + stdin: new InputRequestReader(cell.AskAsync, ct))); if (await ExecutionAbandonment.WaitWithGraceAsync(executionTask, ct)) { @@ -507,15 +509,15 @@ private void ApplyRichOutput(NotebookCellViewModel cell, RichCellOutput rich, in } break; case CellOutputKind.Control: - if (rich.InteractiveControl != null) + if (rich.InteractiveControl is Avalonia.Controls.Control control) { - cell.SetInteractiveControl(rich.InteractiveControl); + cell.SetInteractiveControl(control); } break; case CellOutputKind.Html: if (!string.IsNullOrEmpty(rich.HtmlContent)) { - cell.SetHtmlContent(rich.HtmlContent); + cell.SetHtmlContent(rich.HtmlContent, rich.MarkdownSource); } break; case CellOutputKind.Table: diff --git a/ViewModels/Server/FryServerStudioViewModel.cs b/ViewModels/Server/FryServerStudioViewModel.cs index 0ab9016c..2a98734f 100644 --- a/ViewModels/Server/FryServerStudioViewModel.cs +++ b/ViewModels/Server/FryServerStudioViewModel.cs @@ -401,10 +401,10 @@ private StudioWorkspaceExplorer CreateExplorer(IScriptStorageService storage) if (string.Equals(id, Document?.Id, StringComparison.OrdinalIgnoreCase)) LoadDocument(CreateDefaultDocument()); }; - storage.ActiveWorkspaceChanged += () => Dispatcher.UIThread.Post(() => _ = explorer.RefreshExplorer()); + storage.ActiveWorkspaceChanged += () => Dispatcher.UIThread.Post(() => _ = explorer.RefreshExplorerAsync()); storage.ExternalChangeDetected += () => Dispatcher.UIThread.Post(() => _ = explorer.RefreshIfStaleAsync()); explorer.Highlight(Document?.Id); // marked when the listing arrives - _ = explorer.RefreshExplorer(); + _ = explorer.RefreshExplorerAsync(); return explorer; } diff --git a/ViewModels/Settings/CSharpSettingsViewModel.ThemingExport.cs b/ViewModels/Settings/CSharpSettingsViewModel.ThemingExport.cs index 536756ea..ff2f874d 100644 --- a/ViewModels/Settings/CSharpSettingsViewModel.ThemingExport.cs +++ b/ViewModels/Settings/CSharpSettingsViewModel.ThemingExport.cs @@ -10,6 +10,7 @@ using PdfEditorApp.Plugins.CSharpEditor.Services.Extensibility; using PdfEditorApp.Plugins.CSharpEditor.Services.Extensibility.Theming; using PdfEditorApp.Plugins.CSharpEditor.Services.Extensibility.Theming.ColorMath; +using PdfEditorApp.Plugins.CSharpEditor.Services.Common; namespace PdfEditorApp.Plugins.CSharpEditor.ViewModels.Settings; @@ -44,7 +45,7 @@ public partial class CSharpSettingsViewModel public ObservableCollection CategoryFilters { get; } = new(); public ObservableCollection ColorTokens { get; } = new(); - public PdfEditorApp.Plugins.CSharpEditor.ViewModels.Common.RangeObservableCollection FilteredColorTokens { get; } = new(); + public PdfEditorApp.Plugins.CSharpEditor.Services.Common.RangeObservableCollection FilteredColorTokens { get; } = new(); public int TotalTokensCount => ColorTokens.Count; diff --git a/Views/DumpTableView.Rendering.cs b/Views/DumpTableView.Rendering.cs index bd908174..a2116f5f 100644 --- a/Views/DumpTableView.Rendering.cs +++ b/Views/DumpTableView.Rendering.cs @@ -298,7 +298,7 @@ internal Control CreateCellContent(DumpTableCell? cell, DumpTableBrushes brushes FontStyle = cell?.IsNull == true ? FontStyle.Italic : FontStyle.Normal, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, - TextAlignment = cell?.Alignment ?? TextAlignment.Left, + TextAlignment = cell?.IsNumeric == true ? TextAlignment.Right : TextAlignment.Left, // Right-click reaches the row's cell menu (which has Copy Cell Value) instead of a bare Copy flyout. ContextFlyout = null, }; diff --git a/Views/DumpTableView.axaml b/Views/DumpTableView.axaml index 0d599ddd..61789f4e 100644 --- a/Views/DumpTableView.axaml +++ b/Views/DumpTableView.axaml @@ -104,25 +104,25 @@ + Click="OnCopyTsv"> + Click="OnCopyCsv"> + Click="OnCopyJson"> + Click="OnCopyMarkdown"> diff --git a/Views/DumpTableView.axaml.cs b/Views/DumpTableView.axaml.cs index bf91de12..3d5ecfce 100644 --- a/Views/DumpTableView.axaml.cs +++ b/Views/DumpTableView.axaml.cs @@ -154,6 +154,24 @@ private void OnDataContextChanged(object? sender, EventArgs e) /// Shows now (nested tables and tools call this directly). public void BuildTable(DumpTableResult table) => Attach(table, refreshNow: true); + // The Copy menu: what the table shows (filtered and sorted) goes to this view's own clipboard, the one of the window + // it is in, not the studio's main window. + private void OnCopyTsv(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _ = CopyAsync(t => t.ToTsv()); + + private void OnCopyCsv(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _ = CopyAsync(t => t.ToCsv()); + + private void OnCopyJson(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _ = CopyAsync(t => t.ToJson()); + + private void OnCopyMarkdown(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _ = CopyAsync(t => t.ToMarkdown()); + + private async Task CopyAsync(Func format) + { + var table = _currentTable ?? DataContext as DumpTableResult; + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (table == null || clipboard == null) return; + await clipboard.SetTextAsync(format(table)); + } + private void Attach(DumpTableResult table, bool refreshNow) { if (!ReferenceEquals(_currentTable, table)) diff --git a/Views/Server/FryServerStudioView.axaml b/Views/Server/FryServerStudioView.axaml index 0dc6e6f8..32f580a1 100644 --- a/Views/Server/FryServerStudioView.axaml +++ b/Views/Server/FryServerStudioView.axaml @@ -7,7 +7,7 @@ xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:vm="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.ViewModels.Server" xmlns:serverControls="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Controls.Server" - xmlns:models="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Models" + xmlns:models="using:PdfEditorApp.Plugins.CSharpEditor.Models" xmlns:server="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Services.Server" xmlns:server1="clr-namespace:PdfEditorApp.Plugins.CSharpEditor.Models.Server" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="900" diff --git a/Visualizers/Services/VisualizerRecorderGifExtensions.cs b/Visualizers/Services/VisualizerRecorderGifExtensions.cs new file mode 100644 index 00000000..f5f795b2 --- /dev/null +++ b/Visualizers/Services/VisualizerRecorderGifExtensions.cs @@ -0,0 +1,26 @@ +namespace PdfEditorApp.Plugins.CSharpEditor.Visualizers.Services; + +/// +/// GIF export of a recorded visualizer: the frames are drawn by the desktop's visualizer renderer, so it exists only in +/// the desktop studio. recorder.SaveGif(...) reads the same as before it moved out of . +/// +public static class VisualizerRecorderGifExtensions +{ + /// Exports the recorded visualizer steps into an animated GIF file. + public static Task SaveGifAsync(this VisualizerRecorder recorder, string path, VisualizerGifExportOptions? exportOptions = null, IProgress? progress = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recorder); + return VisualizerGifExportService.ExportToFileAsync(recorder.Options, path, exportOptions, progress, cancellationToken); + } + + /// Exports the recorded visualizer steps into an animated GIF file synchronously. + public static void SaveGif(this VisualizerRecorder recorder, string path, VisualizerGifExportOptions? exportOptions = null) => + recorder.SaveGifAsync(path, exportOptions).GetAwaiter().GetResult(); + + /// Exports the recorded visualizer steps into animated GIF bytes. + public static Task ToGifBytesAsync(this VisualizerRecorder recorder, VisualizerGifExportOptions? exportOptions = null, IProgress? progress = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recorder); + return VisualizerGifExportService.ExportToGifBytesAsync(recorder.Options, exportOptions, progress, cancellationToken); + } +} diff --git a/devlog/week-2-thumbnail.svg b/devlog/week-2-thumbnail.svg index 2f50b259..bf8902d7 100644 --- a/devlog/week-2-thumbnail.svg +++ b/devlog/week-2-thumbnail.svg @@ -319,6 +319,6 @@ - WEEK 2 + WEEK 3 diff --git a/docs/session-protocol.md b/docs/session-protocol.md new file mode 100644 index 00000000..5960cfd1 --- /dev/null +++ b/docs/session-protocol.md @@ -0,0 +1,161 @@ +# The FrySharp Sidecar Session Protocol + +The Visual Studio Code extension (`vscode-frysharp`) talks to the headless sidecar (`FrySharp.Sidecar`) with this protocol. It is JSON-RPC 2.0 framed with standard Content-Length headers over stdio (`stdin`/`stdout`). + +The definitions are: +- `FrySharp.Sidecar/Protocol/Protocol.cs` holds the messages, error codes, and notification names. +- `FrySharp.Sidecar/Sessions/SidecarRpc.cs` holds the JSON-RPC methods. +- `vscode-frysharp/src/sidecar/protocol.ts` mirrors them in TypeScript for the extension. + +The automated tests in `Tests.Sidecar/` verify that all methods, notifications, and execution lifecycles pass in-memory and over streams. + +## Transport & Framing + +- **Transport**: Standard I/O (`process.stdin` / `process.stdout`) between VS Code and the .NET sidecar process. +- **Framing**: Content-Length header delimited (standard Language Server Protocol / `StreamJsonRpc.HeaderDelimitedMessageHandler` and `vscode-jsonrpc`). +- **Logging**: Sidecar diagnostic logs are directed strictly to `stderr` (`Console.Error`), leaving `stdout` clean for JSON-RPC messages. +- **Initialize**: The client sends `fry/initialize` upon launching the sidecar. The sidecar responds with server capabilities, supported languages, and workspace info. + +## Sessions + +A session represents an active client connection (VS Code extension instance). The client provides a unique `clientId` during `fry/initialize`. + +- **Lifespan**: The sidecar lifecycle is tied to the parent VS Code process or editor workspace session. +- **Process Cleanup**: When the client disconnects or closes, the sidecar cleans up all active runs, language kernels, and child processes via `ProcessRegistry`. +- **Concurrency**: Notebook cell runs and script runs execute asynchronously with streaming output events back to the client. + +## Errors + +Errors are JSON-RPC errors with one of these codes: + +| Code | Name | When | +|---|---|---| +| -32001 | `NotFound` | The file, folder, document or cell doesn't exist. | +| -32002 | `AccessDenied` | The path is outside the workspace (absolute, `..`, or through a link that leads out), or names the workspace itself where that isn't allowed (delete, rename). | +| -32003 | `Conflict` | The file changed on disk since it was read, or the session was taken over. | +| -32004 | `Invalid` | Bad parameters, or a request before `fry/initialize`. | +| -32005 | `TooLarge` | The file is bigger than the editor opens (`maxOpenFileBytes`); download it instead. | +| -32006 | `OutOfSync` | The server's copy of a document is behind the editor's. Send the whole text again with `didOpen`. | +| -32007 | `NotSupported` | The file can't be opened as text, or run, or the client speaks another protocol version. | +| -32008 | `AlreadyExists` | A create, rename or run would replace something that exists. | + +## Paths + +Paths are relative to the workspace root and use `/`. `""` is the root. + +- An absolute path (`/etc/hosts`) is refused, never read as relative. +- So is a path that leaves the workspace with `..`, or through a symbolic link anywhere along it. +- The workspace root itself can't be deleted or renamed. + +## Requests (browser → server) + +### Session + +| Method | Params | Result | +|---|---|---| +| `fry/initialize` | `{ clientId, clientName?, protocolVersion }` | `{ protocolVersion, serverVersion, workspaceRoot, workspaceName, languages[], resumed, maxOpenFileBytes }`. `languages[]` says what each language can do: `canRun`, `canRunCells`, `hasCompletion`, its extensions and new-file template. A different `protocolVersion` answers `NotSupported`. | + +### Workspace + +| Method | Params | Result | +|---|---|---| +| `fry/workspace/listFolder` | `{ path }` | `{ path, entries[], truncated }`. Folders come first. As in VS Code, only `.git`, `.svn`, `.hg`, `CVS`, `.DS_Store` and `Thumbs.db` are hidden; links that lead outside the workspace are left out too. | +| `fry/workspace/findFiles` | `{ query, limit }` | `{ matches[], indexing }`. Fuzzy file-name search over the workspace index (Go to File). | +| `fry/workspace/search` | `{ searchId, query, isRegex, matchCase, wholeWord }` | `{ filesSearched, filesWithMatches, matches, hitLimit }`, once the search ends. Hits stream as `fry/workspace/searchHits` while it runs. Cancel it with `$/cancelRequest`. | +| `fry/workspace/createFile` | `{ path, content? }` | `{ path }`. Without `content`, the file gets its language's template. Never replaces a file (`AlreadyExists`). | +| `fry/workspace/createFolder` | `{ path }` | `{ path }` | +| `fry/workspace/delete` | `{ path }` | — Deleted for good (the server has no trash), so the studio asks first. | +| `fry/workspace/rename` | `{ from, to }` | `{ path }`. Never overwrites (`AlreadyExists`). A change of case only is allowed. A folder can't move into itself (`Invalid`). | + +### Documents + +The server holds the text of every document the tab has open, so language features take only a position. + +| Method | Params | Result | +|---|---|---| +| `fry/document/open` | `{ path }` | `{ path, text, languageId, diskVersion, isReadOnly }`. `diskVersion` fingerprints the file as read. | +| `fry/document/didOpen` | `{ path, version, text, diskVersion }` | Notification. The editor's text at `version`. It is also sent again, with the whole text, after `OutOfSync` or `fry/document/resync`. | +| `fry/document/didChange` | `{ path, version, changes: [{ offset, length, text }] }` | Notification. `version` is one more than the last one. Each change's offset is in the text *before* the batch. A skipped version gets `fry/document/resync`. | +| `fry/document/didClose` | `{ path }` | Notification. | +| `fry/document/save` | `{ path, version, force? }` | `{ diskVersion }`. Writes the server's text at `version`, atomically (temp file, then rename), keeping the encoding, its BOM and the file's permissions. The answer is `Conflict` if the file changed on disk since it was read (repeat with `force: true` to overwrite), or `OutOfSync` if `version` isn't what the server holds. | + +### Language features (C#) + +| Method | Params | Result | +|---|---|---| +| `fry/language/completion` | `{ path, version, offset }` | `{ items: [{ label, insertText, kind, detail?, documentation?, sortText }] }` | +| `fry/language/hover` | `{ path, version, offset }` | `{ markdown: [] }` or `null` | + +Diagnostics come by themselves as `fry/language/diagnostics`, about 400 ms after the last change. Statements are checked as the C# script they run as: a trailing expression is the result, not an error. + +### Runs + +| Method | Params | Result | +|---|---|---| +| `fry/run/start` | `{ runId, path?, code?, languageId?, arguments? }` | `{ runId, title }`. Runs the saved file at `path`, or `code` (C# only). | +| `fry/run/stop` | `{ runId }` | `true` if the run was going. C# that ignores Stop is abandoned after a grace period. Either way the run ends with exit code 130. | +| `fry/run/input` | `{ runId, text }` | — A line typed into the run (`Console.ReadLine`, `input()`). | +| `fry/run/list` | `{}` | `[{ runId, title, path?, output, running, exitCode? }]`: the tab's runs with what they printed (up to 1 MB each), for a tab that reconnected. | + +`runId` is chosen by the client: 1 to 64 letters, digits, `-` or `_`, unique in the session. A run's notifications can arrive before the answer to `fry/run/start`, and the client already knows which run they belong to. + +Each language's runner reads the run's errors from its output; they arrive as `problems` in `fry/run/exited`. Arguments are passed to the program one by one, never joined into a command line. + +Exit codes: +- 0 means success, 1 means a C# compile error or exception, 130 means stopped. +- Other languages report the program's own exit code. + +### Notebooks + +The server owns an open notebook: its cells, outputs and kernels. Every tab that opens the same file shares it. + +| Method | Params | Result | +|---|---|---| +| `fry/notebook/open` | `{ path }` | `{ path, format, title, defaultLanguage, diskVersion, cells: [{ id, type, language?, source, executionCount?, hasError, outputs[] }] }`. `format` is `frynb` or `ipynb`. | +| `fry/notebook/edit` | `{ path, edits: [{ op, cellId, index?, type?, language?, source? }] }` | — `op` is `insert`, `delete`, `move`, `source`, `language` or `type`. The client makes new cells' ids. | +| `fry/notebook/execute` | `{ path, cellId, source }` | `{ executionCount, success, durationMs }`, once the cell has run. Cells run one at a time, in the order asked. Output streams as `fry/notebook/output`. A dropped connection doesn't stop the cell. | +| `fry/notebook/interrupt` | `{ path }` | — Stops the running cell. | +| `fry/notebook/restart` | `{ path }` | — Stops it and forgets every kernel's state. | +| `fry/notebook/variables` | `{ path }` | `[{ name, type, value, kernel }]` | +| `fry/notebook/save` | `{ path, force? }` | `{ diskVersion }`. Saved in the notebook's own format. A `.ipynb` stays valid nbformat 4.5. Nothing is written if nothing changed. `Conflict` works as for documents. | +| `fry/notebook/close` | `{ path }` | — The kernels end when the last tab closes it. | + +A cell's first lines can pick its language (`#!python`) and copy a variable from another kernel (`#!share --from csharp nums [--as numbers]`). See [kernel-protocol.md](kernel-protocol.md) for how the other languages' kernels run. + +## Notifications (server → browser) + +| Method | Params | +|---|---| +| `fry/workspace/changed` | `{ structure }`: files changed on disk. `structure` means files or folders were added or removed, so the client lists open folders again. | +| `fry/workspace/searchHits` | `{ searchId, path, hits: [{ line, column, length, text }] }` | +| `fry/document/changedOnDisk` | `{ path }`: an open document's file changed outside the studio. | +| `fry/document/resync` | `{ path }`: the server missed an edit. Send the whole text with `didOpen`. | +| `fry/language/diagnostics` | `{ path, version, diagnostics: [{ severity, code, message, startLine, startColumn, endLine, endColumn }] }`. Lines and columns start at 1. | +| `fry/run/output` | `{ runId, text }`: batched (sent at 8 KB or 50 ms, whichever comes first), in order, with terminal line endings (`\r\n`) and ANSI colours kept. | +| `fry/run/display` | `{ runId, data, metadata }`: a display as a MIME bundle (see below). | +| `fry/run/inputRequested` | `{ runId }`: the program is waiting for a line. | +| `fry/run/exited` | `{ runId, exitCode, durationMs, problems: [{ path?, severity, code, message, line, column }] }`. Sent exactly once per run. | +| `fry/notebook/output` | `{ path, cellId, output: { kind, text?, data?, metadata? } }`. `kind` is `stream`, `display` or `error`. | +| `fry/notebook/cellState` | `{ path, cellId, running, executionCount?, success?, durationMs? }` | +| `fry/session/replaced` | `{}`: another connection took this session over. | + +### Requests the server makes + +| Method | Params | Answer | +|---|---|---| +| `fry/input/request` | `{ prompt, password }` | The line typed, or `null` (dismissed). A notebook cell asks with this: `Console.ReadLine()` in C#, `input()` in Python, `prompt()` in JavaScript. The server cancels the request (`$/cancelRequest`) if the cell is stopped. | + +## Displays + +Displays travel as MIME bundles, like Jupyter's. Each bundle also has `text/plain`, so any client can show something. + +| MIME type | From | Shape | +|---|---|---| +| `application/vnd.fry.table+json` | `Dump`, `Display.Table`, `fry.table` | `{ title, columns[], numeric[], rows[][], totalRows, totalColumns }`. At most 1,000 rows are sent; `totalRows` says how many there are. | +| `application/vnd.fry.inspector+json` | an object's `Dump` | `{ title, properties: [{ name, value, isNull, isString, child? }] }` | +| `application/vnd.fry.chart.v1+json`, `…plot3d.v1+json`, `…visualizer.v1+json` | charts, 3D plots, visualizers | the visual's spec ([visual-protocol.md](visual-protocol.md)) | +| `image/png`, `image/jpeg`, `image/gif`, `image/svg+xml` | `Display.Image`, plots | base64 (SVG as text). `metadata[mime]` may give `{ width, height }`. | +| `text/html` | `Display.Html`, ECharts | shown in the sandbox frame only | +| `text/markdown` | `Display.Markdown`, `fry.markdown`, Jupyter kernels | rendered and sanitized, with highlighted code blocks | + +A live desktop control (`Display.Control`, `Display.Animate`) has no bundle and isn't sent. diff --git a/futureplan/Idea.md b/futureplan/Idea.md index 849bd8e0..808c1ef0 100644 --- a/futureplan/Idea.md +++ b/futureplan/Idea.md @@ -1,44 +1,42 @@ -# FrySharp Web Mode — Plan +# FrySharp VS Code Extension & Sidecar — Architecture & Plan -> **Concept**: `frysharp serve` starts a local server, and the browser becomes the IDE, as with JupyterLab. The same engine that runs in the FryPDF desktop app (Roslyn scripting, polyglot notebooks, kernels, visuals) runs behind a React UI with the same 5-zone layout. +> **Concept**: A native Visual Studio Code extension (`vscode-frysharp`) backed by a lightweight, headless .NET 10 sidecar (`FrySharp.Sidecar`) communicating over stdio JSON-RPC 2.0. The extension gives developers first-class polyglot interactive notebooks (`.frynb` & `.ipynb`), Roslyn script execution, cross-language variable sharing (`#!share`), and rich visual output renderers natively inside VS Code. -*Last checked against the code: 2026-10-09 (branch `UiIssues`).* +*Updated: 2026-10-13 (Web SPA replaced by native VS Code extension).* > [!IMPORTANT] -> **Decision (2026-10-09): Web is the preferred surface.** The VS Code extension is deferred (§12). Because the web client talks to the engine through one protocol (§5), the extension can reuse that protocol later instead of needing its own backend. +> **Decision (2026-10-13): VS Code extension is the primary external surface; Web SPA retired.** +> Rebuilding VS Code's entire UI shell in React/Monaco was redundant and heavy. Developers already work inside VS Code, where Microsoft's deprecation of Polyglot Notebooks (.NET Interactive) left an enormous void for C# and polyglot notebook users. The headless engine (`FrySharp.Core`) now powers `FrySharp.Sidecar`, which talks directly to `vscode-frysharp` via stdio JSON-RPC. --- -## 1. Why web +## 1. Why VS Code Extension -- One UI that works on every OS, on a remote or headless machine (`ssh -L 5000:localhost:5000 box`), and on locked-down machines where the desktop app can't be installed. -- Notebooks and scripts are a natural fit for the browser, and JupyterLab proved the model. -- Timing: Microsoft deprecated **Polyglot Notebooks** (support ended 2026-03-27) and retired **Azure Data Studio**, leaving C# and SQL notebook users without a tool ([DevClass](https://www.devclass.com/databases/2026/02/14/microsoft-deprecates-polyglot-notebooks-developers-react/4091167), [The Register](https://forums.theregister.com/forum/all/2026/02/12/polyglot_notebooks_deprecation/)). FrySharp already has C#, Python, JavaScript, Java and SQL cells with `#!share --from `. +- **Native Experience**: Native VS Code tabs, editor chrome, keyboard shortcuts, split views, terminal, and settings without maintaining a custom web UI clone. +- **Timing & Market Void**: Microsoft deprecated **Polyglot Notebooks** (.NET Interactive in VS Code, support ended 2026-03-27) and retired **Azure Data Studio**. FrySharp offers a seamless, drop-in replacement for .NET, C#, Python, JavaScript, Java, and SQL notebook cells. +- **Zero Network Attack Surface**: Communication occurs strictly over `stdin`/`stdout` JSON-RPC pipes. No open ports, no authentication cookies, and no CORS/Origin vulnerabilities. --- ## 2. Architecture ``` -$ frysharp serve [--port 5000] [--workspace DIR] [--open] - → http://127.0.0.1:5000/?token=… (token swapped for an HttpOnly cookie) - -┌──────────────────────────────────────────────────────────────────────┐ -│ FrySharp.Web (ASP.NET Core, references FrySharp.Core only) │ -│ ├─ GET / the SPA, embedded in the assembly │ -│ ├─ GET /files/raw large downloads; POST /files/upload │ -│ ├─ WS /session JSON-RPC 2.0: everything interactive (§5) │ -│ └─ FrySharp.Core Roslyn, kernels, processes, workspace index, │ -│ storage, visuals render models, debugging │ -└──────────────────────────────────────────────────────────────────────┘ - ↕ one authenticated WebSocket per browser tab ┌──────────────────────────────────────────────────────────────────────┐ -│ SPA (Vite + React + TypeScript) │ -│ ├─ Monaco, coloured by the desktop's own TextMate grammars │ -│ ├─ 5 zones: Activity Bar · Side Bar · Editor · Bottom Deck · Status │ -│ ├─ Zustand state · TanStack Virtual lists, tree and tables │ -│ ├─ Renderers: tables, charts/3D/visualizers, ECharts, inspector │ -│ └─ xterm.js for program output (ANSI colours kept) │ +│ VS Code Extension (`vscode-frysharp` in TypeScript) │ +│ ├─ NotebookSerializer: native `.frynb` and `.ipynb` notebook support│ +│ ├─ NotebookController: executes cells (C#, Python, JS, SQL, etc.) │ +│ ├─ NotebookRenderer: webview output renderer for ECharts, charts & │ +│ │ rich tables (using FrySharp's existing visual JSON specs) │ +│ └─ Commands: Run Script, New Notebook, Restart Sidecar │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ JSON-RPC 2.0 over stdio (Content-Length framed) +┌──────────────────────────────────▼───────────────────────────────────┐ +│ FrySharp.Sidecar (.NET 10 console executable, references Core only) │ +│ ├─ Stdio JSON-RPC dispatch via StreamJsonRpc │ +│ ├─ Workspace & document services │ +│ ├─ Script runner (FileRunner + RoslynCompilerService) │ +│ ├─ Notebook kernel router & directive runner │ +│ └─ FrySharp.Core: Roslyn, kernels, processes, visuals render models │ └──────────────────────────────────────────────────────────────────────┘ ``` @@ -76,9 +74,9 @@ $ frysharp serve [--port 5000] [--workspace DIR] [--open] Do it in slices so web work can start after the first one: -- [ ] **0a — Minimal core (unblocks W1–W3):** `Execution`, `Processes`, `Storage`, `Workspace`, `Toolchains`, `Visuals`, plus the **C#** engine. Replace the `Dispatcher.UIThread` calls on these paths with events the UI marshals itself. -- [ ] **0b — Language split:** `ILanguageDefinition` becomes `ILanguageEngine` (Core) + `ILanguageEditor` (desktop). `LanguageRegistry` maps an id to both. Move one language per PR (Python, JavaScript, SQL, Java, F#, Go, Rust, C++). Each one appears in web mode as it moves. -- [ ] **0c — Kernels + Display:** untangle `NotebookExecutionKernel` and the four Display files. Core emits MIME bundles; the desktop turns them into bitmaps and controls. +- [x] **0a — Minimal core (unblocks W1–W3):** `Execution`, `Processes`, `Storage`, `Workspace`, `Toolchains`, `Visuals`, plus the **C#** engine. Replace the `Dispatcher.UIThread` calls on these paths with events the UI marshals itself. *(done 2026-10-13)* +- [ ] **0b — Language split:** `ILanguageDefinition` becomes `ILanguageEngine` (Core) + `ILanguageEditor` (desktop). `LanguageRegistry` maps an id to both. Move one language per PR (Python, JavaScript, SQL, Java, F#, Go, Rust, C++). Each one appears in web mode as it moves. *(C#, Python and JavaScript engines are in Core (2026-10-13); SQL, Java, F#, Go, Rust and C++ are still desktop-only)* +- [x] **0c — Kernels + Display:** untangle `NotebookExecutionKernel` and the four Display files. Core emits MIME bundles; the desktop turns them into bitmaps and controls. *(done 2026-10-13: Core `Display` with the desktop's as a subclass, kernels and MIME bundles in Core)* **Done when:** `dotnet list FrySharp.Core package --include-transitive` shows no Avalonia. A console test opens a sample `.frynb`, runs C#, Python and SQL cells with `#!share`, and gets the same MIME bundles as the desktop. The desktop keeps 0 warnings, all tests green and unchanged `UiSnapshots perf` numbers. @@ -114,32 +112,32 @@ Document it in `docs/session-protocol.md`, beside `kernel-protocol.md`. ## 6. Phases ### W1 — Host, CLI and security -- [ ] `FrySharp.Web` (ASP.NET Core minimal host) on `FrySharp.Core`. -- [ ] `frysharp` **console** entry with `serve`, using `System.CommandLine`. `Runner/` is a `WinExe` with no console on Windows, so it can't host the verb. -- [ ] Security as in §10: token → cookie, `Host`/`Origin` checks, loopback by default, path confinement. -- [ ] SPA served from embedded resources, so a single-file publish works. +- [x] `FrySharp.Web` (ASP.NET Core minimal host) on `FrySharp.Core`. *(done)* +- [x] `frysharp` **console** entry with `serve`, using `System.CommandLine`. `Runner/` is a `WinExe` with no console on Windows, so it can't host the verb. *(done: `serve`, `run`, `info`)* +- [x] Security as in §10: token → cookie, `Host`/`Origin` checks, loopback by default, path confinement. *(done, covered by Tests.Web)* +- [x] SPA served from embedded resources, so a single-file publish works. *(done in Release builds)* - **Done when:** `frysharp serve --open` opens the browser. A request without the token, or with a foreign `Origin`, is rejected (tests cover HTTP and the WebSocket upgrade). ### W2 — Shell: layout, Explorer, editor -- [ ] 5-zone layout. Single-sidebar rule. Keybindings `Ctrl+B`, `Ctrl+J`, `Ctrl+S`, `Ctrl+P`, `Ctrl+F`, `Ctrl+Shift+E/F`, `F5`/`Ctrl+F5`/`Shift+F5`. Browser-reserved keys (e.g. `Ctrl+W`, `Ctrl+N`) get documented alternatives. -- [ ] **Themes shared with the desktop:** `fry/initialize` sends the active theme's tokens (theme studio palettes) as CSS variables, so themes are built once. -- [ ] Explorer: a flat list of visible rows virtualized with TanStack Virtual (the desktop's `ExplorerRowList` model), folders listed lazily with `listFolder`, Go to File over `WorkspaceFileIndex`. -- [ ] Monaco **bundled locally**. `@monaco-editor/react` loads Monaco from a CDN by default, which breaks offline use and CSP, so configure `loader.config({ monaco })`. Tokenize with the **same TextMate grammar files** the desktop gets from `TextMateSharp.Grammars` (served by the backend, run with `vscode-textmate` + `vscode-oniguruma`), plus Roslyn semantic tokens for C#. Colours then match the desktop. -- [ ] Tabs with dirty markers, breadcrumbs, and save with conflict detection. External edits come through `watch`. +- [x] 5-zone layout. Single-sidebar rule. Keybindings `Ctrl+B`, `Ctrl+J`, `Ctrl+S`, `Ctrl+P`, `Ctrl+F`, `Ctrl+Shift+E/F`, `F5`/`Ctrl+F5`/`Shift+F5`. Browser-reserved keys (e.g. `Ctrl+W`, `Ctrl+N`) get documented alternatives. *(done; browser-reserved keys in web/README.md)* +- [ ] **Themes shared with the desktop:** `fry/initialize` sends the active theme's tokens (theme studio palettes) as CSS variables, so themes are built once. *(open)* +- [x] Explorer: a flat list of visible rows virtualized with TanStack Virtual (the desktop's `ExplorerRowList` model), folders listed lazily with `listFolder`, Go to File over `WorkspaceFileIndex`. *(done)* +- [ ] Monaco **bundled locally**. `@monaco-editor/react` loads Monaco from a CDN by default, which breaks offline use and CSP, so configure `loader.config({ monaco })`. Tokenize with the **same TextMate grammar files** the desktop gets from `TextMateSharp.Grammars` (served by the backend, run with `vscode-textmate` + `vscode-oniguruma`), plus Roslyn semantic tokens for C#. Colours then match the desktop. *(local Monaco done; TextMate grammars and Roslyn semantic tokens are open, Monaco's own tokenizer is used meanwhile)* +- [x] Tabs with dirty markers, breadcrumbs, and save with conflict detection. External edits come through `watch`. *(done)* - **Done when:** a 30,000-file workspace's Explorer shows its first level in under 200 ms and stays smooth while scrolling. ### W3 — Run, output, problems -- [ ] Run / stop for every language whose engine has moved to Core. -- [ ] Output in xterm.js with ANSI colours; stdin; output batched (the kernel protocol's rule: flush at newline, 8 KB or 50 ms). -- [ ] Problems panel from run diagnostics (`IDiagnosticParser`), with click-to-navigate. -- [ ] Status bar: language, Ln/Col, compiler status, execution timer. +- [x] Run / stop for every language whose engine has moved to Core. *(done for C#, Python, JavaScript)* +- [x] Output in xterm.js with ANSI colours; stdin; output batched (the kernel protocol's rule: flush at newline, 8 KB or 50 ms). *(done)* +- [x] Problems panel from run diagnostics (`IDiagnosticParser`), with click-to-navigate. *(done, plus live C# diagnostics)* +- [x] Status bar: language, Ln/Col, compiler status, execution timer. *(done; a run's time shows when it ends, there is no live timer yet)* - **Done when:** a script printing 100,000 lines doesn't freeze the tab, and memory stays capped. ### W4 — Notebooks -- [ ] `.frynb` open/save (byte-identical round trip when nothing changed), plus `.ipynb` import. -- [ ] **Virtualized cell list:** only cells near the viewport mount a Monaco editor. The desktop needed this too: 150 cells went from 15.7 s / 2.6 GB to 0.75 s. -- [ ] Run / add / delete / move, interrupt, `input()`, polyglot cells with `#!share`, Variables panel. -- [ ] Reconnect keeps kernels and outputs (§5). +- [x] `.frynb` open/save (byte-identical round trip when nothing changed), plus `.ipynb` import. *(done; `.ipynb` is also saved back as Jupyter)* +- [x] **Virtualized cell list:** only cells near the viewport mount a Monaco editor. The desktop needed this too: 150 cells went from 15.7 s / 2.6 GB to 0.75 s. *(done)* +- [x] Run / add / delete / move, interrupt, `input()`, polyglot cells with `#!share`, Variables panel. *(done; C# `Console.ReadLine()` in cells too)* +- [x] Reconnect keeps kernels and outputs (§5). *(done: a running cell or run survives a refresh)* - **Done when:** a 1,000-cell notebook opens in under 1 s, and the repo's sample notebooks produce the same text outputs as the desktop (an automated comparison). ### W5 — Rich outputs diff --git a/samples/demo.frynb b/samples/demo.frynb new file mode 100644 index 00000000..885f57cf --- /dev/null +++ b/samples/demo.frynb @@ -0,0 +1,2318 @@ +{ + "SchemaVersion": "1.0", + "Type": "notebook", + "Id": "frysharp-polyglot-showcase", + "Title": "FrySharp Polyglot Showcase", + "Description": "", + "Category": "Interactive", + "Kernel": "csharp", + "Cells": [ + { + "Id": "cell-intro", + "Type": 1, + "Source": "# \uD83E\uDE90 FrySharp Polyglot Showcase\nInteractive .NET 10 Roslyn scripting, polyglot kernels (C#, Python, Node.js), cross-language variable sharing, and rich interactive visual outputs.", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-table-md", + "Type": 1, + "Source": "### 1. Interactive Enterprise Telemetry Table", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-table-code", + "Type": 0, + "Language": "csharp", + "Source": "// Strongly-typed enterprise cluster telemetry record\npublic record ClusterNode(\n string Region,\n string NodeId,\n string Role,\n double CpuPercent,\n double MemoryGb,\n double LatencyMs,\n string Status);\n\n// Global production infrastructure nodes\nvar clusterNodes = new[]\n{\n new ClusterNode(\u0022us-east-1\u0022, \u0022node-core-01\u0022, \u0022Primary Router\u0022, 24.5, 64.0, 12.4, \u0022Optimal\u0022),\n new ClusterNode(\u0022us-west-2\u0022, \u0022node-worker-02\u0022, \u0022Compute Shard\u0022, 78.2, 128.0, 28.1, \u0022Under Load\u0022),\n new ClusterNode(\u0022eu-west-1\u0022, \u0022node-db-03\u0022, \u0022Postgres Leader\u0022, 45.0, 256.0, 18.7, \u0022Optimal\u0022),\n new ClusterNode(\u0022ap-southeast-1\u0022, \u0022node-edge-04\u0022, \u0022Ingress Proxy\u0022, 15.8, 32.0, 42.0, \u0022Optimal\u0022),\n new ClusterNode(\u0022sa-east-1\u0022, \u0022node-cache-05\u0022, \u0022Redis In-Memory\u0022, 88.9, 64.0, 68.5, \u0022Degraded\u0022),\n new ClusterNode(\u0022ap-northeast-1\u0022, \u0022node-ai-06\u0022, \u0022Tensor Pipeline\u0022, 92.1, 192.0, 31.2, \u0022Heavy Compute\u0022)\n};\n\n// Render rich interactive dark-mode table\nDisplay.Table(clusterNodes, \u0022Global Cluster Infrastructure \u0026 Edge Telemetry\u0022);\n\n// LINQ Aggregations\ndouble avgLatency = clusterNodes.Average(n =\u003E n.LatencyMs);\ndouble totalRam = clusterNodes.Sum(n =\u003E n.MemoryGb);\nint healthyCount = clusterNodes.Count(n =\u003E n.Status == \u0022Optimal\u0022);\n\nConsole.WriteLine($\u0022\u2713 Telemetry Snapshot: {clusterNodes.Length} clusters monitored\u0022);\nConsole.WriteLine($\u0022 - Average Edge Latency: {avgLatency:F1} ms\u0022);\nConsole.WriteLine($\u0022 - Total Provisioned RAM: {totalRam:N0} GB\u0022);\nConsole.WriteLine($\u0022 - Healthy SLA Quorum: {healthyCount}/{clusterNodes.Length} nodes\u0022);", + "OutputText": "\u2713 Telemetry Snapshot: 6 clusters monitored\n - Average Edge Latency: 33.5 ms\n - Total Provisioned RAM: 736 GB\n - Healthy SLA Quorum: 3/6 nodes\n", + "ExecutionCount": 1, + "IsExecuting": false, + "ExecutionTimeText": "995 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-tbl-container\u0022 id=\u0022fry_tbl_be473415\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;margin:10px 0;max-width:960px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow:hidden;\u0022\u003E\n \u003Cstyle\u003E\n #fry_tbl_be473415 .fry-tbl-header {\n display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 8px 12px; background: var(--vscode-sideBar-background, #161b22);\n border-bottom: 1px solid var(--vscode-widget-border, #30363d);\n }\n #fry_tbl_be473415 .fry-tbl-toggle-btn {\n display: flex; align-items: center; gap: 8px; background: transparent;\n border: none; color: var(--vscode-foreground, #c9d1d9); cursor: pointer;\n padding: 0; font-size: 13px; font-weight: 600; text-align: left;\n }\n #fry_tbl_be473415 .fry-tbl-pill {\n font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-badge-background, #21262d); border: 1px solid var(--vscode-widget-border, #30363d);\n padding: 2px 8px; border-radius: 12px;\n }\n #fry_tbl_be473415 .fry-tbl-actions {\n display: flex; align-items: center; gap: 8px;\n }\n #fry_tbl_be473415 .fry-tbl-search-wrap {\n position: relative; display: flex; align-items: center;\n }\n #fry_tbl_be473415 .fry-tbl-search-input {\n background: var(--vscode-input-background, #0d1117);\n border: 1px solid var(--vscode-input-border, #30363d);\n color: var(--vscode-input-foreground, #c9d1d9);\n padding: 3px 8px 3px 24px; border-radius: 4px; font-size: 11.5px;\n width: 140px; transition: border-color 0.2s, width 0.2s;\n }\n #fry_tbl_be473415 .fry-tbl-search-input:focus {\n outline: none; border-color: var(--vscode-focusBorder, #58a6ff); width: 180px;\n }\n #fry_tbl_be473415 .fry-tbl-search-icon {\n position: absolute; left: 7px; font-size: 11px; opacity: 0.6; pointer-events: none;\n }\n #fry_tbl_be473415 .fry-tbl-btn {\n background: var(--vscode-button-secondaryBackground, #21262d);\n color: var(--vscode-button-secondaryForeground, #c9d1d9);\n border: 1px solid var(--vscode-widget-border, #30363d);\n border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500;\n cursor: pointer; transition: background 0.15s;\n }\n #fry_tbl_be473415 .fry-tbl-btn:hover {\n background: var(--vscode-button-secondaryHoverBackground, #30363d);\n }\n #fry_tbl_be473415 .fry-tbl-scroll {\n max-height: 420px; overflow-y: auto; overflow-x: auto;\n }\n #fry_tbl_be473415 table.fry-tbl-grid {\n width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;\n }\n #fry_tbl_be473415 thead th {\n position: sticky; top: 0; z-index: 5; background: var(--vscode-sideBar-background, #161b22);\n padding: 8px 12px; border-bottom: 2px solid var(--vscode-widget-border, #30363d);\n font-weight: 600; color: var(--vscode-foreground, #c9d1d9); user-select: none;\n }\n #fry_tbl_be473415 thead th.sortable:hover {\n background: var(--vscode-list-hoverBackground, #21262d);\n }\n #fry_tbl_be473415 tbody tr.fry-data-row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.06);\n transition: background 0.12s;\n }\n #fry_tbl_be473415 tbody tr.fry-data-row:hover {\n background: var(--vscode-list-hoverBackground, rgba(56, 139, 253, 0.08)) !important;\n }\n #fry_tbl_be473415 td {\n padding: 7px 12px; vertical-align: middle;\n }\n #fry_tbl_be473415 .fry-num {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right; color: #79c0ff;\n }\n #fry_tbl_be473415 .fry-badge {\n display: inline-block; padding: 2px 7px; border-radius: 12px;\n font-size: 11px; font-weight: 500; letter-spacing: 0.2px;\n }\n #fry_tbl_be473415 .fry-badge-green {\n background: rgba(35, 134, 54, 0.2); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.4);\n }\n #fry_tbl_be473415 .fry-badge-amber {\n background: rgba(187, 128, 9, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4);\n }\n #fry_tbl_be473415 .fry-badge-red {\n background: rgba(218, 54, 51, 0.2); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.4);\n }\n #fry_tbl_be473415 .fry-badge-blue {\n background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.4);\n }\n #fry_tbl_be473415 .fry-expander-btn {\n background: transparent; border: none; cursor: pointer;\n color: var(--vscode-textLink-foreground, #58a6ff); font-size: 10px;\n padding: 3px 5px; border-radius: 4px; transition: transform 0.15s;\n }\n #fry_tbl_be473415 .fry-expander-btn:hover {\n background: rgba(255,255,255,0.1);\n }\n #fry_tbl_be473415 tr.fry-detail-row {\n background: rgba(0, 0, 0, 0.28);\n }\n #fry_tbl_be473415 .fry-prop-grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; margin-top: 6px;\n }\n #fry_tbl_be473415 .fry-prop-card {\n background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);\n border-radius: 4px; padding: 6px 10px;\n }\n #fry_tbl_be473415 .fry-prop-key {\n font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px;\n }\n #fry_tbl_be473415 .fry-prop-val {\n font-size: 12px; color: #c9d1d9; margin-top: 2px; word-break: break-word;\n }\n #fry_tbl_be473415 .fry-tbl-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px 12px; font-size: 11px; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-sideBar-background, #161b22);\n border-top: 1px solid var(--vscode-widget-border, #30363d);\n }\n \u003C/style\u003E\n\n \u003Cdiv class=\u0022fry-tbl-header\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-toggle-btn\u0022 onclick=\u0022fryTblToggle(\u0027fry_tbl_be473415\u0027)\u0022 title=\u0022Click to collapse/expand table\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-chevron\u0022 style=\u0022color:#58a6ff;\u0022\u003E\u25BC\u003C/span\u003E\n \u003Cspan\u003E\uD83D\uDCCA ClusterNode[6]\u003C/span\u003E\n \u003Cspan class=\u0022fry-tbl-pill\u0022\u003E6 rows \u00D7 7 cols\u003C/span\u003E\n \u003C/button\u003E\n \u003Cdiv class=\u0022fry-tbl-actions\u0022\u003E\n \u003Cdiv class=\u0022fry-tbl-search-wrap\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-search-icon\u0022\u003E\uD83D\uDD0D\u003C/span\u003E\n \u003Cinput type=\u0022text\u0022 class=\u0022fry-tbl-search-input\u0022 placeholder=\u0022Filter rows...\u0022 oninput=\u0022fryTblFilter(\u0027fry_tbl_be473415\u0027, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_be473415\u0027, \u0027csv\u0027)\u0022 title=\u0022Copy as CSV\u0022\u003ECSV\u003C/button\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_be473415\u0027, \u0027json\u0027)\u0022 title=\u0022Copy as JSON\u0022\u003EJSON\u003C/button\u003E\n \u003C/div\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-scroll\u0022\u003E\n \u003Ctable class=\u0022fry-tbl-grid\u0022\u003E\n \u003Cthead\u003E\n \u003Ctr\u003E\n \u003Cth style=\u0022width:36px;text-align:center;padding:8px 4px;\u0022\u003E\u003C/th\u003E\n \u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 0, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Region\u0022\u003E\n Region \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 1, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by NodeId\u0022\u003E\n NodeId \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 2, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Role\u0022\u003E\n Role \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 3, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by CpuPercent\u0022\u003E\n CpuPercent \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 4, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by MemoryGb\u0022\u003E\n MemoryGb \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 5, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by LatencyMs\u0022\u003E\n LatencyMs \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 6, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Status\u0022\u003E\n Status \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\n \u003C/tr\u003E\n \u003C/thead\u003E\n \u003Ctbody\u003E\n \u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00220\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 0)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eus-east-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-core-01\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EPrimary Router\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E24.5\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E64\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E12.4\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_0\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #1 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eus-east-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-core-01\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EPrimary Router\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E24.5\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E64\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E12.4\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00221\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 1)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eus-west-2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-worker-02\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ECompute Shard\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E78.2\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E128\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E28.1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_1\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #2 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eus-west-2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-worker-02\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ECompute Shard\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E78.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E128\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E28.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00222\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 2)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eeu-west-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-db-03\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EPostgres Leader\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E45\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E256\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E18.7\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_2\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #3 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eeu-west-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-db-03\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EPostgres Leader\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E45\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E256\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E18.7\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00223\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 3)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eap-southeast-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-edge-04\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EIngress Proxy\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E15.8\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E32\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E42\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_3\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #4 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eap-southeast-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-edge-04\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EIngress Proxy\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E15.8\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E32\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E42\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00224\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 4)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Esa-east-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-cache-05\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ERedis In-Memory\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E88.9\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E64\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E68.5\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_4\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #5 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Esa-east-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-cache-05\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ERedis In-Memory\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E88.9\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E64\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E68.5\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00225\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 5)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eap-northeast-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-ai-06\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ETensor Pipeline\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E92.1\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E192\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E31.2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EHeavy Compute\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_5\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #6 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eap-northeast-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-ai-06\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ETensor Pipeline\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E92.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E192\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E31.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EHeavy Compute\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\n \u003C/tbody\u003E\n \u003C/table\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-footer\u0022\u003E\n \u003Cspan id=\u0022fry_tbl_be473415_counter\u0022\u003EShowing 6 of 6 rows\u003C/span\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10.5px;\u0022\u003EFrySharp Polyglot Data\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Cscript\u003E\n (function() {\n if (window.__fryTblInit) return;\n window.__fryTblInit = true;\n\n window.fryTblToggle = function(id) {\n var el = document.getElementById(id);\n if (!el) return;\n var body = el.querySelector(\u0027.fry-tbl-scroll\u0027);\n var footer = el.querySelector(\u0027.fry-tbl-footer\u0027);\n var chevron = el.querySelector(\u0027.fry-tbl-chevron\u0027);\n var isHidden = body.style.display === \u0027none\u0027;\n body.style.display = isHidden ? \u0027block\u0027 : \u0027none\u0027;\n footer.style.display = isHidden ? \u0027flex\u0027 : \u0027none\u0027;\n if (chevron) chevron.textContent = isHidden ? \u0027\u25BC\u0027 : \u0027\u25B6\u0027;\n };\n\n window.fryTblToggleRow = function(id, rowIdx) {\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rowIdx);\n var el = document.getElementById(id);\n if (!drawer || !el) return;\n var btn = el.querySelector(\u0027tr[data-row=\u0022\u0027 \u002B rowIdx \u002B \u0027\u0022] .fry-expander-btn\u0027);\n var isOpen = drawer.style.display !== \u0027none\u0027;\n drawer.style.display = isOpen ? \u0027none\u0027 : \u0027table-row\u0027;\n if (btn) btn.textContent = isOpen ? \u0027\u25B6\u0027 : \u0027\u25BC\u0027;\n };\n\n window.fryTblFilter = function(id, query) {\n var el = document.getElementById(id);\n if (!el) return;\n var q = (query || \u0027\u0027).toLowerCase().trim();\n var rows = el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027);\n var count = 0;\n rows.forEach(function(row) {\n var rIdx = row.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n var match = !q || row.innerText.toLowerCase().indexOf(q) !== -1;\n row.style.display = match ? \u0027\u0027 : \u0027none\u0027;\n if (!match \u0026\u0026 drawer) drawer.style.display = \u0027none\u0027;\n if (match) count\u002B\u002B;\n });\n var cnt = document.getElementById(id \u002B \u0027_counter\u0027);\n if (cnt) {\n cnt.textContent = q ? (\u0027Showing \u0027 \u002B count \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows (filtered)\u0027) : (\u0027Showing \u0027 \u002B rows.length \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows\u0027);\n }\n };\n\n window.fryTblSort = function(id, colIdx, isNum) {\n var el = document.getElementById(id);\n if (!el) return;\n var tbody = el.querySelector(\u0027tbody\u0027);\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var th = el.querySelectorAll(\u0027thead th\u0027)[colIdx \u002B 1];\n var dir = th.getAttribute(\u0027data-sort-dir\u0027) === \u0027asc\u0027 ? \u0027desc\u0027 : \u0027asc\u0027;\n\n el.querySelectorAll(\u0027thead th\u0027).forEach(function(h) {\n h.removeAttribute(\u0027data-sort-dir\u0027);\n var s = h.querySelector(\u0027.fry-sort-ind\u0027);\n if (s) s.textContent = \u0027\u2195\u0027;\n });\n\n th.setAttribute(\u0027data-sort-dir\u0027, dir);\n var ind = th.querySelector(\u0027.fry-sort-ind\u0027);\n if (ind) ind.textContent = dir === \u0027asc\u0027 ? \u0027\u25B2\u0027 : \u0027\u25BC\u0027;\n\n rows.sort(function(a, b) {\n var vA = a.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n var vB = b.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n if (isNum) {\n var nA = parseFloat(vA.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n var nB = parseFloat(vB.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n return dir === \u0027asc\u0027 ? nA - nB : nB - nA;\n }\n return dir === \u0027asc\u0027 ? vA.localeCompare(vB) : vB.localeCompare(vA);\n });\n\n rows.forEach(function(r) {\n var rIdx = r.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n tbody.appendChild(r);\n if (drawer) tbody.appendChild(drawer);\n });\n };\n\n window.fryTblCopy = function(id, format) {\n var el = document.getElementById(id);\n if (!el) return;\n var ths = Array.from(el.querySelectorAll(\u0027thead th\u0027)).slice(1);\n var headers = ths.map(function(h) { return h.innerText.replace(/[\u2195\u25B2\u25BC]/g, \u0027\u0027).trim(); });\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var data = rows.map(function(r) {\n return Array.from(r.children).slice(1).map(function(c) { return c.innerText.trim(); });\n });\n\n var payload = \u0027\u0027;\n if (format === \u0027json\u0027) {\n var list = data.map(function(r) {\n var o = {};\n headers.forEach(function(h, idx) { o[h] = r[idx]; });\n return o;\n });\n payload = JSON.stringify(list, null, 2);\n } else {\n payload = headers.join(\u0027,\u0027) \u002B \u0027\\n\u0027 \u002B data.map(function(r) {\n return r.map(function(val) { return \u0027\u0022\u0027 \u002B val.replace(/\u0022/g, \u0027\u0022\u0022\u0027) \u002B \u0027\u0022\u0027; }).join(\u0027,\u0027);\n }).join(\u0027\\n\u0027);\n }\n\n navigator.clipboard.writeText(payload).then(function() {\n var btn = el.querySelector(\u0027.fry-tbl-btn[onclick*=\u0022\u0027 \u002B format \u002B \u0027\u0022]\u0027);\n if (btn) {\n var prev = btn.textContent;\n btn.textContent = \u0027\u2713 Copied!\u0027;\n setTimeout(function() { btn.textContent = prev; }, 1500);\n }\n });\n };\n })();\n \u003C/script\u003E\n\u003C/div\u003E", + "TableSnapshot": { + "Title": "ClusterNode[6]", + "Label": "Global Cluster Infrastructure \u0026 Edge Telemetry", + "Columns": [ + { + "Header": "Region", + "IsNumeric": false + }, + { + "Header": "NodeId", + "IsNumeric": false + }, + { + "Header": "Role", + "IsNumeric": false + }, + { + "Header": "CpuPercent", + "IsNumeric": true + }, + { + "Header": "MemoryGb", + "IsNumeric": true + }, + { + "Header": "LatencyMs", + "IsNumeric": true + }, + { + "Header": "Status", + "IsNumeric": false + } + ], + "Rows": [ + { + "Cells": [ + { + "DisplayText": "us-east-1", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-core-01", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Primary Router", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "24.5", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "64", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "12.4", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Optimal", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "us-west-2", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-worker-02", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Compute Shard", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "78.2", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "128", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "28.1", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Under Load", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "eu-west-1", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-db-03", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Postgres Leader", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "45", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "256", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "18.7", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Optimal", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "ap-southeast-1", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-edge-04", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Ingress Proxy", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "15.8", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "32", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "42", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Optimal", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "sa-east-1", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-cache-05", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Redis In-Memory", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "88.9", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "64", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "68.5", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Degraded", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "ap-northeast-1", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-ai-06", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Tensor Pipeline", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "92.1", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "192", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "31.2", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Heavy Compute", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + } + ] + }, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "cell-chart2d-md", + "Type": 1, + "Source": "### 2. Multi-Series 2D Line Chart (Fourier Harmonics)", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-chart2d-code", + "Type": 0, + "Language": "csharp", + "Source": "// Multi-series Fourier harmonic wave decomposition\nvar t = Enumerable.Range(0, 100).Select(i =\u003E i * 2.0 * Math.PI / 100.0).ToArray();\nvar fundamental = t.Select(Math.Sin).ToArray();\nvar harmonic3 = t.Select(x =\u003E (1.0 / 3.0) * Math.Sin(3 * x)).ToArray();\nvar composite = t.Select(x =\u003E Math.Sin(x) \u002B (1.0 / 3.0) * Math.Sin(3 * x) \u002B (1.0 / 5.0) * Math.Sin(5 * x)).ToArray();\n\nDisplay.LineChart(\n (\u0022Fundamental (sin t)\u0022, fundamental),\n (\u00223rd Harmonic (1/3 sin 3t)\u0022, harmonic3),\n (\u0022Composite Square Wave\u0022, composite));\n\nConsole.WriteLine(\u0022\u2713 2D Multi-series harmonic decomposition rendered.\u0022);", + "OutputText": "\u2713 2D Multi-series harmonic decomposition rendered.\n", + "ExecutionCount": 2, + "IsExecuting": false, + "ExecutionTimeText": "156 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-visual-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;margin:8px 0;max-width:840px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow-x:auto;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;\u0022\u003E\u003Cdiv\u003E\u003Cdiv style=\u0022font-weight:600;font-size:13.5px;color:#58a6ff;letter-spacing:0.2px;\u0022\u003E\uD83D\uDCCA Chart\u003C/div\u003E\u003C/div\u003E\u003Cdiv style=\u0022font-size:10.5px;font-weight:600;color:#8b949e;background:#161b22;padding:3px 9px;border-radius:12px;border:1px solid #30363d;letter-spacing:0.5px;\u0022\u003ELINE\u003C/div\u003E\u003C/div\u003E\u003Csvg viewBox=\u00220 0 760 360\u0022 width=\u0022100%\u0022 height=\u0022auto\u0022 style=\u0022display:block;border-radius:6px;background:#161b22;\u0022 xmlns=\u0022http://www.w3.org/2000/svg\u0022\u003E\u003Cdefs\u003E\u003ClinearGradient id=\u0022area-grad-0\u0022 x1=\u00220%\u0022 y1=\u00220%\u0022 x2=\u00220%\u0022 y2=\u0022100%\u0022\u003E\u003Cstop offset=\u00220%\u0022 stop-color=\u0022#58a6ff\u0022 stop-opacity=\u00220.35\u0022/\u003E\u003Cstop offset=\u0022100%\u0022 stop-color=\u0022#58a6ff\u0022 stop-opacity=\u00220.02\u0022/\u003E\u003C/linearGradient\u003E\u003ClinearGradient id=\u0022area-grad-1\u0022 x1=\u00220%\u0022 y1=\u00220%\u0022 x2=\u00220%\u0022 y2=\u0022100%\u0022\u003E\u003Cstop offset=\u00220%\u0022 stop-color=\u0022#3fb950\u0022 stop-opacity=\u00220.35\u0022/\u003E\u003Cstop offset=\u0022100%\u0022 stop-color=\u0022#3fb950\u0022 stop-opacity=\u00220.02\u0022/\u003E\u003C/linearGradient\u003E\u003ClinearGradient id=\u0022area-grad-2\u0022 x1=\u00220%\u0022 y1=\u00220%\u0022 x2=\u00220%\u0022 y2=\u0022100%\u0022\u003E\u003Cstop offset=\u00220%\u0022 stop-color=\u0022#d29922\u0022 stop-opacity=\u00220.35\u0022/\u003E\u003Cstop offset=\u0022100%\u0022 stop-color=\u0022#d29922\u0022 stop-opacity=\u00220.02\u0022/\u003E\u003C/linearGradient\u003E\u003C/defs\u003E\u003Cline x1=\u002260\u0022 y1=\u002230.0\u0022 x2=\u0022720\u0022 y2=\u002230.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u002234.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E1\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u002284.0\u0022 x2=\u0022720\u0022 y2=\u002284.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u002288.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E0.6\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022138.0\u0022 x2=\u0022720\u0022 y2=\u0022138.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022142.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E0.2\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022192.0\u0022 x2=\u0022720\u0022 y2=\u0022192.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022196.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E-0.2\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022246.0\u0022 x2=\u0022720\u0022 y2=\u0022246.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022250.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E-0.6\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022300.0\u0022 x2=\u0022720\u0022 y2=\u0022300.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022304.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E-1\u003C/text\u003E\u003Cpath d=\u0022M 60.0 165.0 L 66.7 156.5 L 73.3 148.1 L 80.0 139.7 L 86.7 131.4 L 93.3 123.3 L 100.0 115.3 L 106.7 107.5 L 113.3 100.0 L 120.0 92.7 L 126.7 85.6 L 133.3 78.9 L 140.0 72.6 L 146.7 66.6 L 153.3 61.0 L 160.0 55.8 L 166.7 51.0 L 173.3 46.7 L 180.0 42.8 L 186.7 39.5 L 193.3 36.6 L 200.0 34.2 L 206.7 32.4 L 213.3 31.1 L 220.0 30.3 L 226.7 30.0 L 233.3 30.3 L 240.0 31.1 L 246.7 32.4 L 253.3 34.2 L 260.0 36.6 L 266.7 39.5 L 273.3 42.8 L 280.0 46.7 L 286.7 51.0 L 293.3 55.8 L 300.0 61.0 L 306.7 66.6 L 313.3 72.6 L 320.0 78.9 L 326.7 85.6 L 333.3 92.7 L 340.0 100.0 L 346.7 107.5 L 353.3 115.3 L 360.0 123.3 L 366.7 131.4 L 373.3 139.7 L 380.0 148.1 L 386.7 156.5 L 393.3 165.0 L 400.0 173.5 L 406.7 181.9 L 413.3 190.3 L 420.0 198.6 L 426.7 206.7 L 433.3 214.7 L 440.0 222.5 L 446.7 230.0 L 453.3 237.3 L 460.0 244.4 L 466.7 251.1 L 473.3 257.4 L 480.0 263.4 L 486.7 269.0 L 493.3 274.2 L 500.0 279.0 L 506.7 283.3 L 513.3 287.2 L 520.0 290.5 L 526.7 293.4 L 533.3 295.8 L 540.0 297.6 L 546.7 298.9 L 553.3 299.7 L 560.0 300.0 L 566.7 299.7 L 573.3 298.9 L 580.0 297.6 L 586.7 295.8 L 593.3 293.4 L 600.0 290.5 L 606.7 287.2 L 613.3 283.3 L 620.0 279.0 L 626.7 274.2 L 633.3 269.0 L 640.0 263.4 L 646.7 257.4 L 653.3 251.1 L 660.0 244.4 L 666.7 237.3 L 673.3 230.0 L 680.0 222.5 L 686.7 214.7 L 693.3 206.7 L 700.0 198.6 L 706.7 190.3 L 713.3 181.9 L 720.0 173.5 \u0022 fill=\u0022none\u0022 stroke=\u0022#58a6ff\u0022 stroke-width=\u00222.5\u0022 stroke-linejoin=\u0022round\u0022 stroke-linecap=\u0022round\u0022/\u003E\u003Ccircle cx=\u002260.0\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.00 (#1)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002266.7\u0022 cy=\u0022156.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.06 (#2)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002273.3\u0022 cy=\u0022148.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.13 (#3)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002280.0\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.19 (#4)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002286.7\u0022 cy=\u0022131.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.25 (#5)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002293.3\u0022 cy=\u0022123.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.31 (#6)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022100.0\u0022 cy=\u0022115.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.37 (#7)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022106.7\u0022 cy=\u0022107.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.43 (#8)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022113.3\u0022 cy=\u0022100.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.48 (#9)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022120.0\u0022 cy=\u002292.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.54 (#10)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022126.7\u0022 cy=\u002285.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.59 (#11)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022133.3\u0022 cy=\u002278.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.64 (#12)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022140.0\u0022 cy=\u002272.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.68 (#13)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022146.7\u0022 cy=\u002266.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.73 (#14)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022153.3\u0022 cy=\u002261.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.77 (#15)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022160.0\u0022 cy=\u002255.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.81 (#16)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022166.7\u0022 cy=\u002251.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.84 (#17)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022173.3\u0022 cy=\u002246.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.88 (#18)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022180.0\u0022 cy=\u002242.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.90 (#19)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022186.7\u0022 cy=\u002239.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.93 (#20)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022193.3\u0022 cy=\u002236.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.95 (#21)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022200.0\u0022 cy=\u002234.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.97 (#22)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022206.7\u0022 cy=\u002232.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.98 (#23)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022213.3\u0022 cy=\u002231.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.99 (#24)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022220.0\u0022 cy=\u002230.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 1.00 (#25)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022226.7\u0022 cy=\u002230.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 1.00 (#26)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022233.3\u0022 cy=\u002230.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 1.00 (#27)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022240.0\u0022 cy=\u002231.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.99 (#28)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022246.7\u0022 cy=\u002232.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.98 (#29)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022253.3\u0022 cy=\u002234.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.97 (#30)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022260.0\u0022 cy=\u002236.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.95 (#31)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022266.7\u0022 cy=\u002239.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.93 (#32)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022273.3\u0022 cy=\u002242.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.90 (#33)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022280.0\u0022 cy=\u002246.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.88 (#34)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022286.7\u0022 cy=\u002251.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.84 (#35)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022293.3\u0022 cy=\u002255.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.81 (#36)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022300.0\u0022 cy=\u002261.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.77 (#37)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022306.7\u0022 cy=\u002266.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.73 (#38)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022313.3\u0022 cy=\u002272.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.68 (#39)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022320.0\u0022 cy=\u002278.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.64 (#40)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022326.7\u0022 cy=\u002285.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.59 (#41)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022333.3\u0022 cy=\u002292.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.54 (#42)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022340.0\u0022 cy=\u0022100.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.48 (#43)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022346.7\u0022 cy=\u0022107.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.43 (#44)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022353.3\u0022 cy=\u0022115.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.37 (#45)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022360.0\u0022 cy=\u0022123.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.31 (#46)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022366.7\u0022 cy=\u0022131.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.25 (#47)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022373.3\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.19 (#48)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022380.0\u0022 cy=\u0022148.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.13 (#49)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022386.7\u0022 cy=\u0022156.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.06 (#50)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022393.3\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.00 (#51)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022400.0\u0022 cy=\u0022173.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.06 (#52)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022406.7\u0022 cy=\u0022181.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.13 (#53)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022413.3\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.19 (#54)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022420.0\u0022 cy=\u0022198.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.25 (#55)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022426.7\u0022 cy=\u0022206.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.31 (#56)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022433.3\u0022 cy=\u0022214.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.37 (#57)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022440.0\u0022 cy=\u0022222.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.43 (#58)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022446.7\u0022 cy=\u0022230.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.48 (#59)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022453.3\u0022 cy=\u0022237.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.54 (#60)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022460.0\u0022 cy=\u0022244.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.59 (#61)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022466.7\u0022 cy=\u0022251.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.64 (#62)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022473.3\u0022 cy=\u0022257.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.68 (#63)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022480.0\u0022 cy=\u0022263.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.73 (#64)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022486.7\u0022 cy=\u0022269.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.77 (#65)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022493.3\u0022 cy=\u0022274.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.81 (#66)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022500.0\u0022 cy=\u0022279.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.84 (#67)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022506.7\u0022 cy=\u0022283.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.88 (#68)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022513.3\u0022 cy=\u0022287.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.90 (#69)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022520.0\u0022 cy=\u0022290.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.93 (#70)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022526.7\u0022 cy=\u0022293.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.95 (#71)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022533.3\u0022 cy=\u0022295.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.97 (#72)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022540.0\u0022 cy=\u0022297.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.98 (#73)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022546.7\u0022 cy=\u0022298.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.99 (#74)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022553.3\u0022 cy=\u0022299.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -1.00 (#75)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022560.0\u0022 cy=\u0022300.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -1.00 (#76)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022566.7\u0022 cy=\u0022299.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -1.00 (#77)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022573.3\u0022 cy=\u0022298.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.99 (#78)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022580.0\u0022 cy=\u0022297.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.98 (#79)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022586.7\u0022 cy=\u0022295.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.97 (#80)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022593.3\u0022 cy=\u0022293.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.95 (#81)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022600.0\u0022 cy=\u0022290.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.93 (#82)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022606.7\u0022 cy=\u0022287.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.90 (#83)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022613.3\u0022 cy=\u0022283.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.88 (#84)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022620.0\u0022 cy=\u0022279.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.84 (#85)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022626.7\u0022 cy=\u0022274.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.81 (#86)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022633.3\u0022 cy=\u0022269.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.77 (#87)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022640.0\u0022 cy=\u0022263.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.73 (#88)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022646.7\u0022 cy=\u0022257.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.68 (#89)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022653.3\u0022 cy=\u0022251.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.64 (#90)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022660.0\u0022 cy=\u0022244.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.59 (#91)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022666.7\u0022 cy=\u0022237.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.54 (#92)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022673.3\u0022 cy=\u0022230.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.48 (#93)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022680.0\u0022 cy=\u0022222.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.43 (#94)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022686.7\u0022 cy=\u0022214.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.37 (#95)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022693.3\u0022 cy=\u0022206.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.31 (#96)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022700.0\u0022 cy=\u0022198.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.25 (#97)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022706.7\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.19 (#98)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022713.3\u0022 cy=\u0022181.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.13 (#99)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022720.0\u0022 cy=\u0022173.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.06 (#100)\u003C/title\u003E\u003C/circle\u003E\u003Cpath d=\u0022M 60.0 165.0 L 66.7 156.6 L 73.3 148.4 L 80.0 140.9 L 86.7 134.2 L 93.3 128.6 L 100.0 124.3 L 106.7 121.4 L 113.3 120.1 L 120.0 120.4 L 126.7 122.2 L 133.3 125.6 L 140.0 130.3 L 146.7 136.3 L 153.3 143.3 L 160.0 151.1 L 166.7 159.4 L 173.3 167.8 L 180.0 176.2 L 186.7 184.2 L 193.3 191.5 L 200.0 197.8 L 206.7 203.0 L 213.3 206.8 L 220.0 209.2 L 226.7 210.0 L 233.3 209.2 L 240.0 206.8 L 246.7 203.0 L 253.3 197.8 L 260.0 191.5 L 266.7 184.2 L 273.3 176.2 L 280.0 167.8 L 286.7 159.4 L 293.3 151.1 L 300.0 143.3 L 306.7 136.3 L 313.3 130.3 L 320.0 125.6 L 326.7 122.2 L 333.3 120.4 L 340.0 120.1 L 346.7 121.4 L 353.3 124.3 L 360.0 128.6 L 366.7 134.2 L 373.3 140.9 L 380.0 148.4 L 386.7 156.6 L 393.3 165.0 L 400.0 173.4 L 406.7 181.6 L 413.3 189.1 L 420.0 195.8 L 426.7 201.4 L 433.3 205.7 L 440.0 208.6 L 446.7 209.9 L 453.3 209.6 L 460.0 207.8 L 466.7 204.4 L 473.3 199.7 L 480.0 193.7 L 486.7 186.7 L 493.3 178.9 L 500.0 170.6 L 506.7 162.2 L 513.3 153.8 L 520.0 145.8 L 526.7 138.5 L 533.3 132.2 L 540.0 127.0 L 546.7 123.2 L 553.3 120.8 L 560.0 120.0 L 566.7 120.8 L 573.3 123.2 L 580.0 127.0 L 586.7 132.2 L 593.3 138.5 L 600.0 145.8 L 606.7 153.8 L 613.3 162.2 L 620.0 170.6 L 626.7 178.9 L 633.3 186.7 L 640.0 193.7 L 646.7 199.7 L 653.3 204.4 L 660.0 207.8 L 666.7 209.6 L 673.3 209.9 L 680.0 208.6 L 686.7 205.7 L 693.3 201.4 L 700.0 195.8 L 706.7 189.1 L 713.3 181.6 L 720.0 173.4 \u0022 fill=\u0022none\u0022 stroke=\u0022#3fb950\u0022 stroke-width=\u00222.5\u0022 stroke-linejoin=\u0022round\u0022 stroke-linecap=\u0022round\u0022/\u003E\u003Ccircle cx=\u002260.0\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.00 (#1)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002266.7\u0022 cy=\u0022156.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.06 (#2)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002273.3\u0022 cy=\u0022148.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.12 (#3)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002280.0\u0022 cy=\u0022140.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.18 (#4)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002286.7\u0022 cy=\u0022134.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.23 (#5)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002293.3\u0022 cy=\u0022128.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.27 (#6)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022100.0\u0022 cy=\u0022124.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.30 (#7)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022106.7\u0022 cy=\u0022121.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#8)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022113.3\u0022 cy=\u0022120.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#9)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022120.0\u0022 cy=\u0022120.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#10)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022126.7\u0022 cy=\u0022122.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#11)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022133.3\u0022 cy=\u0022125.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.29 (#12)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022140.0\u0022 cy=\u0022130.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.26 (#13)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022146.7\u0022 cy=\u0022136.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.21 (#14)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022153.3\u0022 cy=\u0022143.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.16 (#15)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022160.0\u0022 cy=\u0022151.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.10 (#16)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022166.7\u0022 cy=\u0022159.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.04 (#17)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022173.3\u0022 cy=\u0022167.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.02 (#18)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022180.0\u0022 cy=\u0022176.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.08 (#19)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022186.7\u0022 cy=\u0022184.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.14 (#20)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022193.3\u0022 cy=\u0022191.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.20 (#21)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022200.0\u0022 cy=\u0022197.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.24 (#22)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022206.7\u0022 cy=\u0022203.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.28 (#23)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022213.3\u0022 cy=\u0022206.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.31 (#24)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022220.0\u0022 cy=\u0022209.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#25)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022226.7\u0022 cy=\u0022210.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#26)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022233.3\u0022 cy=\u0022209.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#27)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022240.0\u0022 cy=\u0022206.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.31 (#28)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022246.7\u0022 cy=\u0022203.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.28 (#29)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022253.3\u0022 cy=\u0022197.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.24 (#30)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022260.0\u0022 cy=\u0022191.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.20 (#31)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022266.7\u0022 cy=\u0022184.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.14 (#32)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022273.3\u0022 cy=\u0022176.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.08 (#33)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022280.0\u0022 cy=\u0022167.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.02 (#34)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022286.7\u0022 cy=\u0022159.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.04 (#35)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022293.3\u0022 cy=\u0022151.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.10 (#36)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022300.0\u0022 cy=\u0022143.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.16 (#37)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022306.7\u0022 cy=\u0022136.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.21 (#38)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022313.3\u0022 cy=\u0022130.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.26 (#39)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022320.0\u0022 cy=\u0022125.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.29 (#40)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022326.7\u0022 cy=\u0022122.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#41)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022333.3\u0022 cy=\u0022120.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#42)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022340.0\u0022 cy=\u0022120.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#43)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022346.7\u0022 cy=\u0022121.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#44)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022353.3\u0022 cy=\u0022124.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.30 (#45)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022360.0\u0022 cy=\u0022128.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.27 (#46)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022366.7\u0022 cy=\u0022134.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.23 (#47)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022373.3\u0022 cy=\u0022140.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.18 (#48)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022380.0\u0022 cy=\u0022148.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.12 (#49)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022386.7\u0022 cy=\u0022156.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.06 (#50)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022393.3\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.00 (#51)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022400.0\u0022 cy=\u0022173.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.06 (#52)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022406.7\u0022 cy=\u0022181.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.12 (#53)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022413.3\u0022 cy=\u0022189.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.18 (#54)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022420.0\u0022 cy=\u0022195.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.23 (#55)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022426.7\u0022 cy=\u0022201.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.27 (#56)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022433.3\u0022 cy=\u0022205.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.30 (#57)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022440.0\u0022 cy=\u0022208.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#58)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022446.7\u0022 cy=\u0022209.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#59)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022453.3\u0022 cy=\u0022209.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#60)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022460.0\u0022 cy=\u0022207.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#61)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022466.7\u0022 cy=\u0022204.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.29 (#62)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022473.3\u0022 cy=\u0022199.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.26 (#63)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022480.0\u0022 cy=\u0022193.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.21 (#64)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022486.7\u0022 cy=\u0022186.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.16 (#65)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022493.3\u0022 cy=\u0022178.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.10 (#66)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022500.0\u0022 cy=\u0022170.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.04 (#67)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022506.7\u0022 cy=\u0022162.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.02 (#68)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022513.3\u0022 cy=\u0022153.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.08 (#69)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022520.0\u0022 cy=\u0022145.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.14 (#70)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022526.7\u0022 cy=\u0022138.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.20 (#71)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022533.3\u0022 cy=\u0022132.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.24 (#72)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022540.0\u0022 cy=\u0022127.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.28 (#73)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022546.7\u0022 cy=\u0022123.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.31 (#74)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022553.3\u0022 cy=\u0022120.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#75)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022560.0\u0022 cy=\u0022120.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#76)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022566.7\u0022 cy=\u0022120.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#77)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022573.3\u0022 cy=\u0022123.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.31 (#78)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022580.0\u0022 cy=\u0022127.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.28 (#79)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022586.7\u0022 cy=\u0022132.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.24 (#80)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022593.3\u0022 cy=\u0022138.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.20 (#81)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022600.0\u0022 cy=\u0022145.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.14 (#82)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022606.7\u0022 cy=\u0022153.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.08 (#83)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022613.3\u0022 cy=\u0022162.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.02 (#84)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022620.0\u0022 cy=\u0022170.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.04 (#85)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022626.7\u0022 cy=\u0022178.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.10 (#86)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022633.3\u0022 cy=\u0022186.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.16 (#87)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022640.0\u0022 cy=\u0022193.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.21 (#88)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022646.7\u0022 cy=\u0022199.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.26 (#89)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022653.3\u0022 cy=\u0022204.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.29 (#90)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022660.0\u0022 cy=\u0022207.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#91)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022666.7\u0022 cy=\u0022209.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#92)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022673.3\u0022 cy=\u0022209.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#93)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022680.0\u0022 cy=\u0022208.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#94)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022686.7\u0022 cy=\u0022205.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.30 (#95)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022693.3\u0022 cy=\u0022201.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.27 (#96)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022700.0\u0022 cy=\u0022195.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.23 (#97)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022706.7\u0022 cy=\u0022189.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.18 (#98)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022713.3\u0022 cy=\u0022181.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.12 (#99)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022720.0\u0022 cy=\u0022173.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.06 (#100)\u003C/title\u003E\u003C/circle\u003E\u003Cpath d=\u0022M 60.0 165.0 L 66.7 139.7 L 73.3 115.6 L 80.0 93.7 L 86.7 74.9 L 93.3 59.9 L 100.0 48.9 L 106.7 42.1 L 113.3 39.2 L 120.0 39.7 L 126.7 42.9 L 133.3 47.9 L 140.0 53.8 L 146.7 59.7 L 153.3 65.0 L 160.0 68.9 L 166.7 71.1 L 173.3 71.4 L 180.0 69.9 L 186.7 67.0 L 193.3 63.1 L 200.0 58.7 L 206.7 54.5 L 213.3 51.1 L 220.0 48.8 L 226.7 48.0 L 233.3 48.8 L 240.0 51.1 L 246.7 54.5 L 253.3 58.7 L 260.0 63.1 L 266.7 67.0 L 273.3 69.9 L 280.0 71.4 L 286.7 71.1 L 293.3 68.9 L 300.0 65.0 L 306.7 59.7 L 313.3 53.8 L 320.0 47.9 L 326.7 42.9 L 333.3 39.7 L 340.0 39.2 L 346.7 42.1 L 353.3 48.9 L 360.0 59.9 L 366.7 74.9 L 373.3 93.7 L 380.0 115.6 L 386.7 139.7 L 393.3 165.0 L 400.0 190.3 L 406.7 214.4 L 413.3 236.3 L 420.0 255.1 L 426.7 270.1 L 433.3 281.1 L 440.0 287.9 L 446.7 290.8 L 453.3 290.3 L 460.0 287.1 L 466.7 282.1 L 473.3 276.2 L 480.0 270.3 L 486.7 265.0 L 493.3 261.1 L 500.0 258.9 L 506.7 258.6 L 513.3 260.1 L 520.0 263.0 L 526.7 266.9 L 533.3 271.3 L 540.0 275.5 L 546.7 278.9 L 553.3 281.2 L 560.0 282.0 L 566.7 281.2 L 573.3 278.9 L 580.0 275.5 L 586.7 271.3 L 593.3 266.9 L 600.0 263.0 L 606.7 260.1 L 613.3 258.6 L 620.0 258.9 L 626.7 261.1 L 633.3 265.0 L 640.0 270.3 L 646.7 276.2 L 653.3 282.1 L 660.0 287.1 L 666.7 290.3 L 673.3 290.8 L 680.0 287.9 L 686.7 281.1 L 693.3 270.1 L 700.0 255.1 L 706.7 236.3 L 713.3 214.4 L 720.0 190.3 \u0022 fill=\u0022none\u0022 stroke=\u0022#d29922\u0022 stroke-width=\u00222.5\u0022 stroke-linejoin=\u0022round\u0022 stroke-linecap=\u0022round\u0022/\u003E\u003Ccircle cx=\u002260.0\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.00 (#1)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002266.7\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.19 (#2)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002273.3\u0022 cy=\u0022115.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.37 (#3)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002280.0\u0022 cy=\u002293.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.53 (#4)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002286.7\u0022 cy=\u002274.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.67 (#5)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002293.3\u0022 cy=\u002259.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#6)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022100.0\u0022 cy=\u002248.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#7)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022106.7\u0022 cy=\u002242.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.91 (#8)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022113.3\u0022 cy=\u002239.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#9)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022120.0\u0022 cy=\u002239.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#10)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022126.7\u0022 cy=\u002242.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.90 (#11)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022133.3\u0022 cy=\u002247.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.87 (#12)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022140.0\u0022 cy=\u002253.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#13)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022146.7\u0022 cy=\u002259.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#14)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022153.3\u0022 cy=\u002265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.74 (#15)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022160.0\u0022 cy=\u002268.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.71 (#16)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022166.7\u0022 cy=\u002271.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#17)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022173.3\u0022 cy=\u002271.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.69 (#18)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022180.0\u0022 cy=\u002269.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#19)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022186.7\u0022 cy=\u002267.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.73 (#20)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022193.3\u0022 cy=\u002263.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.76 (#21)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022200.0\u0022 cy=\u002258.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.79 (#22)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022206.7\u0022 cy=\u002254.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#23)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022213.3\u0022 cy=\u002251.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.84 (#24)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022220.0\u0022 cy=\u002248.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#25)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022226.7\u0022 cy=\u002248.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.87 (#26)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022233.3\u0022 cy=\u002248.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#27)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022240.0\u0022 cy=\u002251.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.84 (#28)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022246.7\u0022 cy=\u002254.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#29)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022253.3\u0022 cy=\u002258.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.79 (#30)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022260.0\u0022 cy=\u002263.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.76 (#31)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022266.7\u0022 cy=\u002267.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.73 (#32)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022273.3\u0022 cy=\u002269.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#33)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022280.0\u0022 cy=\u002271.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.69 (#34)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022286.7\u0022 cy=\u002271.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#35)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022293.3\u0022 cy=\u002268.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.71 (#36)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022300.0\u0022 cy=\u002265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.74 (#37)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022306.7\u0022 cy=\u002259.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#38)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022313.3\u0022 cy=\u002253.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#39)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022320.0\u0022 cy=\u002247.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.87 (#40)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022326.7\u0022 cy=\u002242.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.90 (#41)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022333.3\u0022 cy=\u002239.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#42)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022340.0\u0022 cy=\u002239.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#43)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022346.7\u0022 cy=\u002242.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.91 (#44)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022353.3\u0022 cy=\u002248.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#45)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022360.0\u0022 cy=\u002259.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#46)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022366.7\u0022 cy=\u002274.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.67 (#47)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022373.3\u0022 cy=\u002293.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.53 (#48)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022380.0\u0022 cy=\u0022115.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.37 (#49)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022386.7\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.19 (#50)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022393.3\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.00 (#51)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022400.0\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.19 (#52)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022406.7\u0022 cy=\u0022214.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.37 (#53)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022413.3\u0022 cy=\u0022236.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.53 (#54)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022420.0\u0022 cy=\u0022255.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.67 (#55)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022426.7\u0022 cy=\u0022270.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#56)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022433.3\u0022 cy=\u0022281.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#57)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022440.0\u0022 cy=\u0022287.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.91 (#58)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022446.7\u0022 cy=\u0022290.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#59)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022453.3\u0022 cy=\u0022290.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#60)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022460.0\u0022 cy=\u0022287.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.90 (#61)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022466.7\u0022 cy=\u0022282.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.87 (#62)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022473.3\u0022 cy=\u0022276.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#63)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022480.0\u0022 cy=\u0022270.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#64)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022486.7\u0022 cy=\u0022265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.74 (#65)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022493.3\u0022 cy=\u0022261.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.71 (#66)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022500.0\u0022 cy=\u0022258.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#67)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022506.7\u0022 cy=\u0022258.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.69 (#68)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022513.3\u0022 cy=\u0022260.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#69)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022520.0\u0022 cy=\u0022263.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.73 (#70)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022526.7\u0022 cy=\u0022266.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.76 (#71)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022533.3\u0022 cy=\u0022271.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.79 (#72)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022540.0\u0022 cy=\u0022275.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#73)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022546.7\u0022 cy=\u0022278.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.84 (#74)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022553.3\u0022 cy=\u0022281.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#75)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022560.0\u0022 cy=\u0022282.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.87 (#76)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022566.7\u0022 cy=\u0022281.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#77)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022573.3\u0022 cy=\u0022278.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.84 (#78)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022580.0\u0022 cy=\u0022275.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#79)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022586.7\u0022 cy=\u0022271.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.79 (#80)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022593.3\u0022 cy=\u0022266.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.76 (#81)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022600.0\u0022 cy=\u0022263.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.73 (#82)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022606.7\u0022 cy=\u0022260.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#83)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022613.3\u0022 cy=\u0022258.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.69 (#84)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022620.0\u0022 cy=\u0022258.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#85)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022626.7\u0022 cy=\u0022261.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.71 (#86)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022633.3\u0022 cy=\u0022265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.74 (#87)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022640.0\u0022 cy=\u0022270.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#88)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022646.7\u0022 cy=\u0022276.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#89)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022653.3\u0022 cy=\u0022282.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.87 (#90)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022660.0\u0022 cy=\u0022287.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.90 (#91)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022666.7\u0022 cy=\u0022290.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#92)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022673.3\u0022 cy=\u0022290.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#93)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022680.0\u0022 cy=\u0022287.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.91 (#94)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022686.7\u0022 cy=\u0022281.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#95)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022693.3\u0022 cy=\u0022270.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#96)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022700.0\u0022 cy=\u0022255.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.67 (#97)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022706.7\u0022 cy=\u0022236.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.53 (#98)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022713.3\u0022 cy=\u0022214.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.37 (#99)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022720.0\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.19 (#100)\u003C/title\u003E\u003C/circle\u003E\u003Cline x1=\u002260\u0022 y1=\u0022300\u0022 x2=\u0022720\u0022 y2=\u0022300\u0022 stroke=\u0022#30363d\u0022 stroke-width=\u00221.5\u0022/\u003E\u003Ccircle cx=\u002265.0\u0022 cy=\u0022335\u0022 r=\u00224.5\u0022 fill=\u0022#58a6ff\u0022/\u003E\u003Ctext x=\u002274.0\u0022 y=\u0022338.5\u0022 fill=\u0022#8b949e\u0022 font-size=\u002211\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EFundamental (sin t)\u003C/text\u003E\u003Ccircle cx=\u0022233.0\u0022 cy=\u0022335\u0022 r=\u00224.5\u0022 fill=\u0022#3fb950\u0022/\u003E\u003Ctext x=\u0022242.0\u0022 y=\u0022338.5\u0022 fill=\u0022#8b949e\u0022 font-size=\u002211\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003E3rd Harmonic (1/3 sin 3t)\u003C/text\u003E\u003Ccircle cx=\u0022443.0\u0022 cy=\u0022335\u0022 r=\u00224.5\u0022 fill=\u0022#d29922\u0022/\u003E\u003Ctext x=\u0022452.0\u0022 y=\u0022338.5\u0022 fill=\u0022#8b949e\u0022 font-size=\u002211\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EComposite Square Wave\u003C/text\u003E\u003C/svg\u003E\u003C/div\u003E", + "TableSnapshot": null, + "InspectorSnapshot": null, + "Visuals": [ + { + "MimeType": "application/vnd.fry.chart.v1\u002Bjson", + "Data": { + "kind": "line", + "series": [ + { + "name": "Fundamental (sin t)", + "y": [ + 0, + 0.06279051952931337, + 0.12533323356430426, + 0.1873813145857246, + 0.2486898871648548, + 0.3090169943749474, + 0.3681245526846779, + 0.42577929156507266, + 0.4817536741017153, + 0.5358267949789967, + 0.5877852522924731, + 0.6374239897486896, + 0.6845471059286886, + 0.7289686274214116, + 0.7705132427757893, + 0.8090169943749473, + 0.8443279255020151, + 0.8763066800438637, + 0.9048270524660196, + 0.9297764858882513, + 0.9510565162951535, + 0.9685831611286311, + 0.9822872507286886, + 0.9921147013144778, + 0.9980267284282716, + 1, + 0.9980267284282716, + 0.9921147013144779, + 0.9822872507286887, + 0.9685831611286311, + 0.9510565162951536, + 0.9297764858882515, + 0.9048270524660195, + 0.8763066800438635, + 0.844327925502015, + 0.8090169943749475, + 0.7705132427757893, + 0.7289686274214114, + 0.6845471059286888, + 0.6374239897486899, + 0.5877852522924732, + 0.535826794978997, + 0.4817536741017152, + 0.4257792915650729, + 0.36812455268467814, + 0.3090169943749475, + 0.24868988716485524, + 0.18738131458572457, + 0.12533323356430454, + 0.06279051952931358, + 1.2246467991473532E-16, + -0.06279051952931335, + -0.12533323356430429, + -0.18738131458572477, + -0.24868988716485457, + -0.3090169943749473, + -0.3681245526846779, + -0.42577929156507227, + -0.4817536741017154, + -0.5358267949789964, + -0.5877852522924727, + -0.6374239897486896, + -0.6845471059286884, + -0.7289686274214117, + -0.7705132427757894, + -0.8090169943749473, + -0.8443279255020153, + -0.8763066800438636, + -0.9048270524660198, + -0.9297764858882515, + -0.9510565162951535, + -0.9685831611286312, + -0.9822872507286887, + -0.9921147013144778, + -0.9980267284282716, + -1, + -0.9980267284282716, + -0.9921147013144779, + -0.9822872507286887, + -0.9685831611286311, + -0.9510565162951536, + -0.9297764858882516, + -0.9048270524660199, + -0.8763066800438634, + -0.844327925502015, + -0.8090169943749476, + -0.7705132427757896, + -0.7289686274214122, + -0.684547105928689, + -0.6374239897486896, + -0.5877852522924734, + -0.5358267949789971, + -0.4817536741017161, + -0.425779291565073, + -0.36812455268467786, + -0.3090169943749477, + -0.24868988716485535, + -0.18738131458572468, + -0.12533323356430465, + -0.06279051952931326 + ] + }, + { + "name": "3rd Harmonic (1/3 sin 3t)", + "y": [ + 0, + 0.06246043819524154, + 0.12270818422822599, + 0.17860893165966552, + 0.22818236864289623, + 0.2696723314583158, + 0.30160901748867314, + 0.32286105370954366, + 0.33267557614275717, + 0.33070490043815925, + 0.3170188387650512, + 0.2921022266812879, + 0.2568377475919298, + 0.21247466324956316, + 0.16058455803390506, + 0.10300566479164931, + 0.04177774452143469, + -0.02093017317643793, + -0.08289662905495167, + -0.14192643052169074, + -0.19592841743082434, + -0.2429895424738039, + -0.2814426418340049, + -0.3099254952960837, + -0.32742908357622946, + -0.3333333333333333, + -0.3274290835762295, + -0.3099254952960838, + -0.28144264183400497, + -0.24298954247380383, + -0.19592841743082468, + -0.141926430521691, + -0.08289662905495149, + -0.020930173176437753, + 0.04177774452143502, + 0.10300566479164905, + 0.1605845580339052, + 0.21247466324956327, + 0.25683774759192957, + 0.2921022266812877, + 0.31701883876505116, + 0.3307049004381592, + 0.33267557614275717, + 0.32286105370954366, + 0.3016090174886733, + 0.2696723314583159, + 0.22818236864289657, + 0.17860893165966574, + 0.12270818422822652, + 0.06246043819524189, + 1.224646799147353E-16, + -0.06246043819524165, + -0.1227081842282263, + -0.17860893165966552, + -0.22818236864289593, + -0.2696723314583157, + -0.3016090174886732, + -0.32286105370954366, + -0.33267557614275717, + -0.3307049004381593, + -0.31701883876505144, + -0.2921022266812878, + -0.2568377475919299, + -0.21247466324956324, + -0.16058455803390492, + -0.1030056647916493, + -0.041777744521434665, + 0.020930173176437507, + 0.08289662905495213, + 0.14192643052169104, + 0.19592841743082423, + 0.24298954247380428, + 0.28144264183400514, + 0.30992549529608376, + 0.3274290835762296, + 0.3333333333333333, + 0.32742908357622963, + 0.30992549529608393, + 0.28144264183400536, + 0.24298954247380372, + 0.1959284174308245, + 0.14192643052169138, + 0.08289662905495247, + 0.020930173176437285, + -0.0417777445214349, + -0.10300566479164837, + -0.16058455803390512, + -0.21247466324956205, + -0.2568377475919293, + -0.29210222668128794, + -0.3170188387650511, + -0.3307049004381592, + -0.3326755761427572, + -0.3228610537095437, + -0.30160901748867336, + -0.2696723314583156, + -0.2281823686428971, + -0.17860893165966585, + -0.12270818422822666, + -0.06246043819524143 + ] + }, + { + "name": "Composite Square Wave", + "y": [ + 0, + 0.1870543565995444, + 0.3655984682510249, + 0.5277936451203796, + 0.6670835590667817, + 0.7786893258332632, + 0.8599448734323818, + 0.9104437441496058, + 0.9319863007029672, + 0.9283350942921454, + 0.9048040910575244, + 0.8677228175549881, + 0.8238278030621238, + 0.7796398917959851, + 0.7408864975506636, + 0.7120226591665966, + 0.6958943667644191, + 0.6935731079924363, + 0.7043733729525732, + 0.7260466564915711, + 0.7551280988643292, + 0.7873970175298166, + 0.8184016593531783, + 0.8439926048933835, + 0.8608089481110728, + 0.8666666666666667, + 0.8608089481110728, + 0.8439926048933836, + 0.8184016593531784, + 0.7873970175298168, + 0.7551280988643294, + 0.7260466564915711, + 0.7043733729525734, + 0.6935731079924361, + 0.6958943667644192, + 0.7120226591665966, + 0.7408864975506636, + 0.7796398917959853, + 0.8238278030621237, + 0.867722817554988, + 0.9048040910575242, + 0.9283350942921453, + 0.9319863007029668, + 0.9104437441496058, + 0.8599448734323821, + 0.7786893258332634, + 0.6670835590667827, + 0.5277936451203797, + 0.36559846825102577, + 0.18705435659954506, + 3.6739403974420594E-16, + -0.1870543565995447, + -0.3655984682510251, + -0.52779364512038, + -0.6670835590667812, + -0.778689325833263, + -0.8599448734323819, + -0.9104437441496058, + -0.9319863007029673, + -0.9283350942921458, + -0.904804091057525, + -0.8677228175549878, + -0.8238278030621238, + -0.7796398917959853, + -0.7408864975506636, + -0.7120226591665966, + -0.6958943667644194, + -0.6935731079924367, + -0.7043733729525735, + -0.7260466564915711, + -0.7551280988643291, + -0.7873970175298165, + -0.8184016593531781, + -0.8439926048933832, + -0.8608089481110729, + -0.8666666666666667, + -0.8608089481110727, + -0.8439926048933833, + -0.8184016593531782, + -0.7873970175298166, + -0.7551280988643294, + -0.7260466564915712, + -0.7043733729525736, + -0.6935731079924361, + -0.6958943667644192, + -0.7120226591665959, + -0.7408864975506637, + -0.7796398917959844, + -0.8238278030621233, + -0.8677228175549883, + -0.9048040910575242, + -0.9283350942921452, + -0.9319863007029672, + -0.9104437441496058, + -0.8599448734323821, + -0.7786893258332632, + -0.6670835590667832, + -0.52779364512038, + -0.36559846825102615, + -0.18705435659954406 + ] + } + ] + }, + "Title": null + } + ], + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "cell-barchart-md", + "Type": 1, + "Source": "### 3. Categorical Donut \u0026 Bar Charts", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-barchart-code", + "Type": 0, + "Language": "csharp", + "Source": "// 1. Regional CPU load bar chart\nvar loads = clusterNodes.ToDictionary(n =\u003E n.Region, n =\u003E n.CpuPercent);\nDisplay.BarChart(loads, \u0022Regional Compute Load (%)\u0022);\n\n// 2. Resource memory allocation donut chart\nvar ramAllocation = clusterNodes.ToDictionary(n =\u003E $\u0022{n.Role} ({n.Region})\u0022, n =\u003E n.MemoryGb);\nDisplay.Chart(ramAllocation, \u0022Cluster Memory Distribution (GB)\u0022, chartType: \u0022donut\u0022);\n\nConsole.WriteLine(\u0022\u2713 Categorical bar \u0026 donut charts rendered.\u0022);", + "OutputText": "\u2713 Categorical bar \u0026 donut charts rendered.\n", + "ExecutionCount": 3, + "IsExecuting": false, + "ExecutionTimeText": "41 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-visual-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;margin:8px 0;max-width:840px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow-x:auto;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;\u0022\u003E\u003Cdiv\u003E\u003Cdiv style=\u0022font-weight:600;font-size:13.5px;color:#58a6ff;letter-spacing:0.2px;\u0022\u003E\uD83D\uDCCA Cluster Memory Distribution (GB)\u003C/div\u003E\u003C/div\u003E\u003Cdiv style=\u0022font-size:10.5px;font-weight:600;color:#8b949e;background:#161b22;padding:3px 9px;border-radius:12px;border:1px solid #30363d;letter-spacing:0.5px;\u0022\u003EDONUT\u003C/div\u003E\u003C/div\u003E\u003Cdiv id=\u0022fry_pie_ed845486\u0022 style=\u0022position:relative;\u0022\u003E\n \u003Cstyle\u003E\n #fry_pie_ed845486 path.fry-pie-slice {\n transform-origin: 280px 180px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s;\n cursor: pointer;\n }\n #fry_pie_ed845486 path.fry-pie-slice:hover {\n transform: scale(1.05);\n filter: drop-shadow(0 2px 8px rgba(0,0,0,0.5));\n }\n #fry_pie_ed845486 .fry-pie-legend-item {\n cursor: pointer; transition: opacity 0.15s;\n }\n #fry_pie_ed845486 .fry-pie-legend-item:hover {\n opacity: 0.75;\n }\n \u003C/style\u003E\n \u003Csvg viewBox=\u00220 0 760 360\u0022 width=\u0022100%\u0022 height=\u0022auto\u0022 style=\u0022display:block;border-radius:6px;background:#161b22;\u0022 xmlns=\u0022http://www.w3.org/2000/svg\u0022\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00220\u0022 d=\u0022M 280.0 50.0 A 130 130 0 0 1 347.5 68.9 L 313.8 124.5 A 65 65 0 0 0 280.0 115.0 Z\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003EPrimary Router (us-east-1): 64.0 (8.7%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00221\u0022 d=\u0022M 347.5 68.9 A 130 130 0 0 1 409.7 188.9 L 344.8 184.4 A 65 65 0 0 0 313.8 124.5 Z\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003ECompute Shard (us-west-2): 128.0 (17.4%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00222\u0022 d=\u0022M 409.7 188.9 A 130 130 0 0 1 198.0 280.8 L 239.0 230.4 A 65 65 0 0 0 344.8 184.4 Z\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003EPostgres Leader (eu-west-1): 256.0 (34.8%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00223\u0022 d=\u0022M 198.0 280.8 A 130 130 0 0 1 173.8 255.0 L 226.9 217.5 A 65 65 0 0 0 239.0 230.4 Z\u0022 fill=\u0022#f85149\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003EIngress Proxy (ap-southeast-1): 32.0 (4.3%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00224\u0022 d=\u0022M 173.8 255.0 A 130 130 0 0 1 150.3 188.9 L 215.2 184.4 A 65 65 0 0 0 226.9 217.5 Z\u0022 fill=\u0022#bc8cff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003ERedis In-Memory (sa-east-1): 64.0 (8.7%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00225\u0022 d=\u0022M 150.3 188.9 A 130 130 0 0 1 280.0 50.0 L 280.0 115.0 A 65 65 0 0 0 215.2 184.4 Z\u0022 fill=\u0022#2dd4bf\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003ETensor Pipeline (ap-northeast-1): 192.0 (26.1%)\u003C/title\u003E\n\u003C/path\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00220\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u002260\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#58a6ff\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u002270\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EPrimary Router (us-east-1): 8.7%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00221\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u002284\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#3fb950\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u002294\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003ECompute Shard (us-west-2): 17.4%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00222\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022108\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#d29922\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022118\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EPostgres Leader (eu-west-1): 34.8%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00223\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022132\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#f85149\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022142\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EIngress Proxy (ap-southeast-1): 4.3%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00224\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022156\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#bc8cff\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022166\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003ERedis In-Memory (sa-east-1): 8.7%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00225\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022180\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#2dd4bf\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022190\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003ETensor Pipeline (ap-northeast-1): 26.1%\u003C/text\u003E\n\u003C/g\u003E\u003C/svg\u003E\u003C/div\u003E\u003C/div\u003E", + "TableSnapshot": null, + "InspectorSnapshot": null, + "Visuals": [ + { + "MimeType": "application/vnd.fry.chart.v1\u002Bjson", + "Data": { + "kind": "bar", + "series": [ + { + "y": [ + 24.5, + 78.2, + 45, + 15.8, + 88.9, + 92.1 + ], + "labels": [ + "us-east-1", + "us-west-2", + "eu-west-1", + "ap-southeast-1", + "sa-east-1", + "ap-northeast-1" + ] + } + ], + "title": "Regional Compute Load (%)" + }, + "Title": "Regional Compute Load (%)" + }, + { + "MimeType": "application/vnd.fry.chart.v1\u002Bjson", + "Data": { + "kind": "donut", + "series": [ + { + "y": [ + 64, + 128, + 256, + 32, + 64, + 192 + ], + "labels": [ + "Primary Router (us-east-1)", + "Compute Shard (us-west-2)", + "Postgres Leader (eu-west-1)", + "Ingress Proxy (ap-southeast-1)", + "Redis In-Memory (sa-east-1)", + "Tensor Pipeline (ap-northeast-1)" + ] + } + ], + "title": "Cluster Memory Distribution (GB)" + }, + "Title": "Cluster Memory Distribution (GB)" + } + ], + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "cell-plot3d-md", + "Type": 1, + "Source": "### 4. Interactive 3D Surface Mesh (Sombrero Function)", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-plot3d-code", + "Type": 0, + "Language": "csharp", + "Source": "// 3D Sombrero surface wave: z = sin(r) / r\nDisplay.Plot3D((x, y) =\u003E {\n double r = Math.Sqrt(x * x \u002B y * y);\n return r == 0 ? 1.0 : Math.Sin(r) / r;\n}, (-6.0, 6.0), (-6.0, 6.0), resolution: 28, title: \u0022Sombrero Wave Surface (z = sin(r) / r)\u0022);\n\nConsole.WriteLine(\u0022\u2713 3D Parametric surface mesh rendered.\u0022);", + "OutputText": "\u2713 3D Parametric surface mesh rendered.\n", + "ExecutionCount": 4, + "IsExecuting": false, + "ExecutionTimeText": "74 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-visual-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;margin:8px 0;max-width:840px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow-x:auto;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;\u0022\u003E\u003Cdiv\u003E\u003Cdiv style=\u0022font-weight:600;font-size:13.5px;color:#58a6ff;letter-spacing:0.2px;\u0022\u003E\uD83D\uDCCA Sombrero Wave Surface (z = sin(r) / r)\u003C/div\u003E\u003C/div\u003E\u003Cdiv style=\u0022font-size:10.5px;font-weight:600;color:#8b949e;background:#161b22;padding:3px 9px;border-radius:12px;border:1px solid #30363d;letter-spacing:0.5px;\u0022\u003ESURFACE\u003C/div\u003E\u003C/div\u003E\u003Cdiv id=\u0022fry_3d_968ce822\u0022 style=\u0022position:relative;user-select:none;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-bottom:8px;font-size:11px;color:#8b949e;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;gap:6px;\u0022\u003E\n \u003Cspan\u003E\uD83D\uDD04 Rotate\u003C/span\u003E\n \u003Cinput type=\u0022range\u0022 class=\u0022fry-3d-az-slider\u0022 min=\u00220\u0022 max=\u0022360\u0022 value=\u002230\u0022 style=\u0022width:90px;height:4px;accent-color:#58a6ff;cursor:pointer;\u0022 oninput=\u0022window.fryRotate3D \u0026\u0026 window.fryRotate3D(\u0027fry_3d_968ce822\u0027, this.value, null)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;gap:6px;\u0022\u003E\n \u003Cspan\u003E\uD83D\uDCD0 Tilt\u003C/span\u003E\n \u003Cinput type=\u0022range\u0022 class=\u0022fry-3d-el-slider\u0022 min=\u002215\u0022 max=\u002280\u0022 value=\u002235\u0022 style=\u0022width:70px;height:4px;accent-color:#58a6ff;cursor:pointer;\u0022 oninput=\u0022window.fryRotate3D \u0026\u0026 window.fryRotate3D(\u0027fry_3d_968ce822\u0027, null, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10px;\u0022\u003E(Drag on canvas to rotate)\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Csvg class=\u0022fry-3d-canvas\u0022 viewBox=\u00220 0 760 380\u0022 width=\u0022100%\u0022 height=\u0022auto\u0022 style=\u0022display:block;border-radius:6px;background:#161b22;cursor:grab;\u0022 xmlns=\u0022http://www.w3.org/2000/svg\u0022\u003E\n \u003Cg class=\u0022fry-3d-mesh\u0022\u003E\u003C/g\u003E\n \u003C/svg\u003E\u003Cscript\u003E\n(function() {\n const root = document.getElementById(\u0027fry_3d_968ce822\u0027);\n if (!root) return;\n const grid = [[[-1.000,-1.000,0.260],[-0.926,-1.000,0.277],[-0.852,-1.000,0.287],[-0.778,-1.000,0.287],[-0.704,-1.000,0.280],[-0.630,-1.000,0.266],[-0.556,-1.000,0.247],[-0.481,-1.000,0.227],[-0.407,-1.000,0.206],[-0.333,-1.000,0.186],[-0.259,-1.000,0.170],[-0.185,-1.000,0.156],[-0.111,-1.000,0.147],[-0.037,-1.000,0.143],[0.037,-1.000,0.143],[0.111,-1.000,0.147],[0.185,-1.000,0.156],[0.259,-1.000,0.170],[0.333,-1.000,0.186],[0.407,-1.000,0.206],[0.481,-1.000,0.227],[0.556,-1.000,0.247],[0.630,-1.000,0.266],[0.704,-1.000,0.280],[0.778,-1.000,0.287],[0.852,-1.000,0.287],[0.926,-1.000,0.277],[1.000,-1.000,0.260],],[[-1.000,-0.926,0.277],[-0.926,-0.926,0.287],[-0.852,-0.926,0.286],[-0.778,-0.926,0.276],[-0.704,-0.926,0.257],[-0.630,-0.926,0.233],[-0.556,-0.926,0.206],[-0.481,-0.926,0.178],[-0.407,-0.926,0.152],[-0.333,-0.926,0.129],[-0.259,-0.926,0.110],[-0.185,-0.926,0.096],[-0.111,-0.926,0.086],[-0.037,-0.926,0.082],[0.037,-0.926,0.082],[0.111,-0.926,0.086],[0.185,-0.926,0.096],[0.259,-0.926,0.110],[0.333,-0.926,0.129],[0.407,-0.926,0.152],[0.481,-0.926,0.178],[0.556,-0.926,0.206],[0.630,-0.926,0.233],[0.704,-0.926,0.257],[0.778,-0.926,0.276],[0.852,-0.926,0.286],[0.926,-0.926,0.287],[1.000,-0.926,0.277],],[[-1.000,-0.852,0.287],[-0.926,-0.852,0.286],[-0.852,-0.852,0.274],[-0.778,-0.852,0.253],[-0.704,-0.852,0.224],[-0.630,-0.852,0.190],[-0.556,-0.852,0.156],[-0.481,-0.852,0.124],[-0.407,-0.852,0.096],[-0.333,-0.852,0.073],[-0.259,-0.852,0.055],[-0.185,-0.852,0.043],[-0.111,-0.852,0.035],[-0.037,-0.852,0.031],[0.037,-0.852,0.031],[0.111,-0.852,0.035],[0.185,-0.852,0.043],[0.259,-0.852,0.055],[0.333,-0.852,0.073],[0.407,-0.852,0.096],[0.481,-0.852,0.124],[0.556,-0.852,0.156],[0.630,-0.852,0.190],[0.704,-0.852,0.224],[0.778,-0.852,0.253],[0.852,-0.852,0.274],[0.926,-0.852,0.286],[1.000,-0.852,0.287],],[[-1.000,-0.778,0.287],[-0.926,-0.778,0.276],[-0.852,-0.778,0.253],[-0.778,-0.778,0.220],[-0.704,-0.778,0.182],[-0.630,-0.778,0.143],[-0.556,-0.778,0.105],[-0.481,-0.778,0.073],[-0.407,-0.778,0.047],[-0.333,-0.778,0.028],[-0.259,-0.778,0.015],[-0.185,-0.778,0.008],[-0.111,-0.778,0.004],[-0.037,-0.778,0.003],[0.037,-0.778,0.003],[0.111,-0.778,0.004],[0.185,-0.778,0.008],[0.259,-0.778,0.015],[0.333,-0.778,0.028],[0.407,-0.778,0.047],[0.481,-0.778,0.073],[0.556,-0.778,0.105],[0.630,-0.778,0.143],[0.704,-0.778,0.182],[0.778,-0.778,0.220],[0.852,-0.778,0.253],[0.926,-0.778,0.276],[1.000,-0.778,0.287],],[[-1.000,-0.704,0.280],[-0.926,-0.704,0.257],[-0.852,-0.704,0.224],[-0.778,-0.704,0.182],[-0.704,-0.704,0.138],[-0.630,-0.704,0.096],[-0.556,-0.704,0.059],[-0.481,-0.704,0.031],[-0.407,-0.704,0.013],[-0.333,-0.704,0.003],[-0.259,-0.704,0.000],[-0.185,-0.704,0.001],[-0.111,-0.704,0.004],[-0.037,-0.704,0.007],[0.037,-0.704,0.007],[0.111,-0.704,0.004],[0.185,-0.704,0.001],[0.259,-0.704,0.000],[0.333,-0.704,0.003],[0.407,-0.704,0.013],[0.481,-0.704,0.031],[0.556,-0.704,0.059],[0.630,-0.704,0.096],[0.704,-0.704,0.138],[0.778,-0.704,0.182],[0.852,-0.704,0.224],[0.926,-0.704,0.257],[1.000,-0.704,0.280],],[[-1.000,-0.630,0.266],[-0.926,-0.630,0.233],[-0.852,-0.630,0.190],[-0.778,-0.630,0.143],[-0.704,-0.630,0.096],[-0.630,-0.630,0.055],[-0.556,-0.630,0.024],[-0.481,-0.630,0.006],[-0.407,-0.630,0.000],[-0.333,-0.630,0.004],[-0.259,-0.630,0.016],[-0.185,-0.630,0.030],[-0.111,-0.630,0.042],[-0.037,-0.630,0.049],[0.037,-0.630,0.049],[0.111,-0.630,0.042],[0.185,-0.630,0.030],[0.259,-0.630,0.016],[0.333,-0.630,0.004],[0.407,-0.630,0.000],[0.481,-0.630,0.006],[0.556,-0.630,0.024],[0.630,-0.630,0.055],[0.704,-0.630,0.096],[0.778,-0.630,0.143],[0.852,-0.630,0.190],[0.926,-0.630,0.233],[1.000,-0.630,0.266],],[[-1.000,-0.556,0.247],[-0.926,-0.556,0.206],[-0.852,-0.556,0.156],[-0.778,-0.556,0.105],[-0.704,-0.556,0.059],[-0.630,-0.556,0.024],[-0.556,-0.556,0.004],[-0.481,-0.556,0.001],[-0.407,-0.556,0.012],[-0.333,-0.556,0.036],[-0.259,-0.556,0.065],[-0.185,-0.556,0.095],[-0.111,-0.556,0.118],[-0.037,-0.556,0.132],[0.037,-0.556,0.132],[0.111,-0.556,0.118],[0.185,-0.556,0.095],[0.259,-0.556,0.065],[0.333,-0.556,0.036],[0.407,-0.556,0.012],[0.481,-0.556,0.001],[0.556,-0.556,0.004],[0.630,-0.556,0.024],[0.704,-0.556,0.059],[0.778,-0.556,0.105],[0.852,-0.556,0.156],[0.926,-0.556,0.206],[1.000,-0.556,0.247],],[[-1.000,-0.481,0.227],[-0.926,-0.481,0.178],[-0.852,-0.481,0.124],[-0.778,-0.481,0.073],[-0.704,-0.481,0.031],[-0.630,-0.481,0.006],[-0.556,-0.481,0.001],[-0.481,-0.481,0.016],[-0.407,-0.481,0.049],[-0.333,-0.481,0.095],[-0.259,-0.481,0.146],[-0.185,-0.481,0.193],[-0.111,-0.481,0.230],[-0.037,-0.481,0.250],[0.037,-0.481,0.250],[0.111,-0.481,0.230],[0.185,-0.481,0.193],[0.259,-0.481,0.146],[0.333,-0.481,0.095],[0.407,-0.481,0.049],[0.481,-0.481,0.016],[0.556,-0.481,0.001],[0.630,-0.481,0.006],[0.704,-0.481,0.031],[0.778,-0.481,0.073],[0.852,-0.481,0.124],[0.926,-0.481,0.178],[1.000,-0.481,0.227],],[[-1.000,-0.407,0.206],[-0.926,-0.407,0.152],[-0.852,-0.407,0.096],[-0.778,-0.407,0.047],[-0.704,-0.407,0.013],[-0.630,-0.407,0.000],[-0.556,-0.407,0.012],[-0.481,-0.407,0.049],[-0.407,-0.407,0.106],[-0.333,-0.407,0.176],[-0.259,-0.407,0.250],[-0.185,-0.407,0.318],[-0.111,-0.407,0.369],[-0.037,-0.407,0.396],[0.037,-0.407,0.396],[0.111,-0.407,0.369],[0.185,-0.407,0.318],[0.259,-0.407,0.250],[0.333,-0.407,0.176],[0.407,-0.407,0.106],[0.481,-0.407,0.049],[0.556,-0.407,0.012],[0.630,-0.407,0.000],[0.704,-0.407,0.013],[0.778,-0.407,0.047],[0.852,-0.407,0.096],[0.926,-0.407,0.152],[1.000,-0.407,0.206],],[[-1.000,-0.333,0.186],[-0.926,-0.333,0.129],[-0.852,-0.333,0.073],[-0.778,-0.333,0.028],[-0.704,-0.333,0.003],[-0.630,-0.333,0.004],[-0.556,-0.333,0.036],[-0.481,-0.333,0.095],[-0.407,-0.333,0.176],[-0.333,-0.333,0.272],[-0.259,-0.333,0.369],[-0.185,-0.333,0.455],[-0.111,-0.333,0.520],[-0.037,-0.333,0.555],[0.037,-0.333,0.555],[0.111,-0.333,0.520],[0.185,-0.333,0.455],[0.259,-0.333,0.369],[0.333,-0.333,0.272],[0.407,-0.333,0.176],[0.481,-0.333,0.095],[0.556,-0.333,0.036],[0.630,-0.333,0.004],[0.704,-0.333,0.003],[0.778,-0.333,0.028],[0.852,-0.333,0.073],[0.926,-0.333,0.129],[1.000,-0.333,0.186],],[[-1.000,-0.259,0.170],[-0.926,-0.259,0.110],[-0.852,-0.259,0.055],[-0.778,-0.259,0.015],[-0.704,-0.259,0.000],[-0.630,-0.259,0.016],[-0.556,-0.259,0.065],[-0.481,-0.259,0.146],[-0.407,-0.259,0.250],[-0.333,-0.259,0.369],[-0.259,-0.259,0.487],[-0.185,-0.259,0.591],[-0.111,-0.259,0.669],[-0.037,-0.259,0.711],[0.037,-0.259,0.711],[0.111,-0.259,0.669],[0.185,-0.259,0.591],[0.259,-0.259,0.487],[0.333,-0.259,0.369],[0.407,-0.259,0.250],[0.481,-0.259,0.146],[0.556,-0.259,0.065],[0.630,-0.259,0.016],[0.704,-0.259,0.000],[0.778,-0.259,0.015],[0.852,-0.259,0.055],[0.926,-0.259,0.110],[1.000,-0.259,0.170],],[[-1.000,-0.185,0.156],[-0.926,-0.185,0.096],[-0.852,-0.185,0.043],[-0.778,-0.185,0.008],[-0.704,-0.185,0.001],[-0.630,-0.185,0.030],[-0.556,-0.185,0.095],[-0.481,-0.185,0.193],[-0.407,-0.185,0.318],[-0.333,-0.185,0.455],[-0.259,-0.185,0.591],[-0.185,-0.185,0.711],[-0.111,-0.185,0.799],[-0.037,-0.185,0.847],[0.037,-0.185,0.847],[0.111,-0.185,0.799],[0.185,-0.185,0.711],[0.259,-0.185,0.591],[0.333,-0.185,0.455],[0.407,-0.185,0.318],[0.481,-0.185,0.193],[0.556,-0.185,0.095],[0.630,-0.185,0.030],[0.704,-0.185,0.001],[0.778,-0.185,0.008],[0.852,-0.185,0.043],[0.926,-0.185,0.096],[1.000,-0.185,0.156],],[[-1.000,-0.111,0.147],[-0.926,-0.111,0.086],[-0.852,-0.111,0.035],[-0.778,-0.111,0.004],[-0.704,-0.111,0.004],[-0.630,-0.111,0.042],[-0.556,-0.111,0.118],[-0.481,-0.111,0.230],[-0.407,-0.111,0.369],[-0.333,-0.111,0.520],[-0.259,-0.111,0.669],[-0.185,-0.111,0.799],[-0.111,-0.111,0.896],[-0.037,-0.111,0.947],[0.037,-0.111,0.947],[0.111,-0.111,0.896],[0.185,-0.111,0.799],[0.259,-0.111,0.669],[0.333,-0.111,0.520],[0.407,-0.111,0.369],[0.481,-0.111,0.230],[0.556,-0.111,0.118],[0.630,-0.111,0.042],[0.704,-0.111,0.004],[0.778,-0.111,0.004],[0.852,-0.111,0.035],[0.926,-0.111,0.086],[1.000,-0.111,0.147],],[[-1.000,-0.037,0.143],[-0.926,-0.037,0.082],[-0.852,-0.037,0.031],[-0.778,-0.037,0.003],[-0.704,-0.037,0.007],[-0.630,-0.037,0.049],[-0.556,-0.037,0.132],[-0.481,-0.037,0.250],[-0.407,-0.037,0.396],[-0.333,-0.037,0.555],[-0.259,-0.037,0.711],[-0.185,-0.037,0.847],[-0.111,-0.037,0.947],[-0.037,-0.037,1.000],[0.037,-0.037,1.000],[0.111,-0.037,0.947],[0.185,-0.037,0.847],[0.259,-0.037,0.711],[0.333,-0.037,0.555],[0.407,-0.037,0.396],[0.481,-0.037,0.250],[0.556,-0.037,0.132],[0.630,-0.037,0.049],[0.704,-0.037,0.007],[0.778,-0.037,0.003],[0.852,-0.037,0.031],[0.926,-0.037,0.082],[1.000,-0.037,0.143],],[[-1.000,0.037,0.143],[-0.926,0.037,0.082],[-0.852,0.037,0.031],[-0.778,0.037,0.003],[-0.704,0.037,0.007],[-0.630,0.037,0.049],[-0.556,0.037,0.132],[-0.481,0.037,0.250],[-0.407,0.037,0.396],[-0.333,0.037,0.555],[-0.259,0.037,0.711],[-0.185,0.037,0.847],[-0.111,0.037,0.947],[-0.037,0.037,1.000],[0.037,0.037,1.000],[0.111,0.037,0.947],[0.185,0.037,0.847],[0.259,0.037,0.711],[0.333,0.037,0.555],[0.407,0.037,0.396],[0.481,0.037,0.250],[0.556,0.037,0.132],[0.630,0.037,0.049],[0.704,0.037,0.007],[0.778,0.037,0.003],[0.852,0.037,0.031],[0.926,0.037,0.082],[1.000,0.037,0.143],],[[-1.000,0.111,0.147],[-0.926,0.111,0.086],[-0.852,0.111,0.035],[-0.778,0.111,0.004],[-0.704,0.111,0.004],[-0.630,0.111,0.042],[-0.556,0.111,0.118],[-0.481,0.111,0.230],[-0.407,0.111,0.369],[-0.333,0.111,0.520],[-0.259,0.111,0.669],[-0.185,0.111,0.799],[-0.111,0.111,0.896],[-0.037,0.111,0.947],[0.037,0.111,0.947],[0.111,0.111,0.896],[0.185,0.111,0.799],[0.259,0.111,0.669],[0.333,0.111,0.520],[0.407,0.111,0.369],[0.481,0.111,0.230],[0.556,0.111,0.118],[0.630,0.111,0.042],[0.704,0.111,0.004],[0.778,0.111,0.004],[0.852,0.111,0.035],[0.926,0.111,0.086],[1.000,0.111,0.147],],[[-1.000,0.185,0.156],[-0.926,0.185,0.096],[-0.852,0.185,0.043],[-0.778,0.185,0.008],[-0.704,0.185,0.001],[-0.630,0.185,0.030],[-0.556,0.185,0.095],[-0.481,0.185,0.193],[-0.407,0.185,0.318],[-0.333,0.185,0.455],[-0.259,0.185,0.591],[-0.185,0.185,0.711],[-0.111,0.185,0.799],[-0.037,0.185,0.847],[0.037,0.185,0.847],[0.111,0.185,0.799],[0.185,0.185,0.711],[0.259,0.185,0.591],[0.333,0.185,0.455],[0.407,0.185,0.318],[0.481,0.185,0.193],[0.556,0.185,0.095],[0.630,0.185,0.030],[0.704,0.185,0.001],[0.778,0.185,0.008],[0.852,0.185,0.043],[0.926,0.185,0.096],[1.000,0.185,0.156],],[[-1.000,0.259,0.170],[-0.926,0.259,0.110],[-0.852,0.259,0.055],[-0.778,0.259,0.015],[-0.704,0.259,0.000],[-0.630,0.259,0.016],[-0.556,0.259,0.065],[-0.481,0.259,0.146],[-0.407,0.259,0.250],[-0.333,0.259,0.369],[-0.259,0.259,0.487],[-0.185,0.259,0.591],[-0.111,0.259,0.669],[-0.037,0.259,0.711],[0.037,0.259,0.711],[0.111,0.259,0.669],[0.185,0.259,0.591],[0.259,0.259,0.487],[0.333,0.259,0.369],[0.407,0.259,0.250],[0.481,0.259,0.146],[0.556,0.259,0.065],[0.630,0.259,0.016],[0.704,0.259,0.000],[0.778,0.259,0.015],[0.852,0.259,0.055],[0.926,0.259,0.110],[1.000,0.259,0.170],],[[-1.000,0.333,0.186],[-0.926,0.333,0.129],[-0.852,0.333,0.073],[-0.778,0.333,0.028],[-0.704,0.333,0.003],[-0.630,0.333,0.004],[-0.556,0.333,0.036],[-0.481,0.333,0.095],[-0.407,0.333,0.176],[-0.333,0.333,0.272],[-0.259,0.333,0.369],[-0.185,0.333,0.455],[-0.111,0.333,0.520],[-0.037,0.333,0.555],[0.037,0.333,0.555],[0.111,0.333,0.520],[0.185,0.333,0.455],[0.259,0.333,0.369],[0.333,0.333,0.272],[0.407,0.333,0.176],[0.481,0.333,0.095],[0.556,0.333,0.036],[0.630,0.333,0.004],[0.704,0.333,0.003],[0.778,0.333,0.028],[0.852,0.333,0.073],[0.926,0.333,0.129],[1.000,0.333,0.186],],[[-1.000,0.407,0.206],[-0.926,0.407,0.152],[-0.852,0.407,0.096],[-0.778,0.407,0.047],[-0.704,0.407,0.013],[-0.630,0.407,0.000],[-0.556,0.407,0.012],[-0.481,0.407,0.049],[-0.407,0.407,0.106],[-0.333,0.407,0.176],[-0.259,0.407,0.250],[-0.185,0.407,0.318],[-0.111,0.407,0.369],[-0.037,0.407,0.396],[0.037,0.407,0.396],[0.111,0.407,0.369],[0.185,0.407,0.318],[0.259,0.407,0.250],[0.333,0.407,0.176],[0.407,0.407,0.106],[0.481,0.407,0.049],[0.556,0.407,0.012],[0.630,0.407,0.000],[0.704,0.407,0.013],[0.778,0.407,0.047],[0.852,0.407,0.096],[0.926,0.407,0.152],[1.000,0.407,0.206],],[[-1.000,0.481,0.227],[-0.926,0.481,0.178],[-0.852,0.481,0.124],[-0.778,0.481,0.073],[-0.704,0.481,0.031],[-0.630,0.481,0.006],[-0.556,0.481,0.001],[-0.481,0.481,0.016],[-0.407,0.481,0.049],[-0.333,0.481,0.095],[-0.259,0.481,0.146],[-0.185,0.481,0.193],[-0.111,0.481,0.230],[-0.037,0.481,0.250],[0.037,0.481,0.250],[0.111,0.481,0.230],[0.185,0.481,0.193],[0.259,0.481,0.146],[0.333,0.481,0.095],[0.407,0.481,0.049],[0.481,0.481,0.016],[0.556,0.481,0.001],[0.630,0.481,0.006],[0.704,0.481,0.031],[0.778,0.481,0.073],[0.852,0.481,0.124],[0.926,0.481,0.178],[1.000,0.481,0.227],],[[-1.000,0.556,0.247],[-0.926,0.556,0.206],[-0.852,0.556,0.156],[-0.778,0.556,0.105],[-0.704,0.556,0.059],[-0.630,0.556,0.024],[-0.556,0.556,0.004],[-0.481,0.556,0.001],[-0.407,0.556,0.012],[-0.333,0.556,0.036],[-0.259,0.556,0.065],[-0.185,0.556,0.095],[-0.111,0.556,0.118],[-0.037,0.556,0.132],[0.037,0.556,0.132],[0.111,0.556,0.118],[0.185,0.556,0.095],[0.259,0.556,0.065],[0.333,0.556,0.036],[0.407,0.556,0.012],[0.481,0.556,0.001],[0.556,0.556,0.004],[0.630,0.556,0.024],[0.704,0.556,0.059],[0.778,0.556,0.105],[0.852,0.556,0.156],[0.926,0.556,0.206],[1.000,0.556,0.247],],[[-1.000,0.630,0.266],[-0.926,0.630,0.233],[-0.852,0.630,0.190],[-0.778,0.630,0.143],[-0.704,0.630,0.096],[-0.630,0.630,0.055],[-0.556,0.630,0.024],[-0.481,0.630,0.006],[-0.407,0.630,0.000],[-0.333,0.630,0.004],[-0.259,0.630,0.016],[-0.185,0.630,0.030],[-0.111,0.630,0.042],[-0.037,0.630,0.049],[0.037,0.630,0.049],[0.111,0.630,0.042],[0.185,0.630,0.030],[0.259,0.630,0.016],[0.333,0.630,0.004],[0.407,0.630,0.000],[0.481,0.630,0.006],[0.556,0.630,0.024],[0.630,0.630,0.055],[0.704,0.630,0.096],[0.778,0.630,0.143],[0.852,0.630,0.190],[0.926,0.630,0.233],[1.000,0.630,0.266],],[[-1.000,0.704,0.280],[-0.926,0.704,0.257],[-0.852,0.704,0.224],[-0.778,0.704,0.182],[-0.704,0.704,0.138],[-0.630,0.704,0.096],[-0.556,0.704,0.059],[-0.481,0.704,0.031],[-0.407,0.704,0.013],[-0.333,0.704,0.003],[-0.259,0.704,0.000],[-0.185,0.704,0.001],[-0.111,0.704,0.004],[-0.037,0.704,0.007],[0.037,0.704,0.007],[0.111,0.704,0.004],[0.185,0.704,0.001],[0.259,0.704,0.000],[0.333,0.704,0.003],[0.407,0.704,0.013],[0.481,0.704,0.031],[0.556,0.704,0.059],[0.630,0.704,0.096],[0.704,0.704,0.138],[0.778,0.704,0.182],[0.852,0.704,0.224],[0.926,0.704,0.257],[1.000,0.704,0.280],],[[-1.000,0.778,0.287],[-0.926,0.778,0.276],[-0.852,0.778,0.253],[-0.778,0.778,0.220],[-0.704,0.778,0.182],[-0.630,0.778,0.143],[-0.556,0.778,0.105],[-0.481,0.778,0.073],[-0.407,0.778,0.047],[-0.333,0.778,0.028],[-0.259,0.778,0.015],[-0.185,0.778,0.008],[-0.111,0.778,0.004],[-0.037,0.778,0.003],[0.037,0.778,0.003],[0.111,0.778,0.004],[0.185,0.778,0.008],[0.259,0.778,0.015],[0.333,0.778,0.028],[0.407,0.778,0.047],[0.481,0.778,0.073],[0.556,0.778,0.105],[0.630,0.778,0.143],[0.704,0.778,0.182],[0.778,0.778,0.220],[0.852,0.778,0.253],[0.926,0.778,0.276],[1.000,0.778,0.287],],[[-1.000,0.852,0.287],[-0.926,0.852,0.286],[-0.852,0.852,0.274],[-0.778,0.852,0.253],[-0.704,0.852,0.224],[-0.630,0.852,0.190],[-0.556,0.852,0.156],[-0.481,0.852,0.124],[-0.407,0.852,0.096],[-0.333,0.852,0.073],[-0.259,0.852,0.055],[-0.185,0.852,0.043],[-0.111,0.852,0.035],[-0.037,0.852,0.031],[0.037,0.852,0.031],[0.111,0.852,0.035],[0.185,0.852,0.043],[0.259,0.852,0.055],[0.333,0.852,0.073],[0.407,0.852,0.096],[0.481,0.852,0.124],[0.556,0.852,0.156],[0.630,0.852,0.190],[0.704,0.852,0.224],[0.778,0.852,0.253],[0.852,0.852,0.274],[0.926,0.852,0.286],[1.000,0.852,0.287],],[[-1.000,0.926,0.277],[-0.926,0.926,0.287],[-0.852,0.926,0.286],[-0.778,0.926,0.276],[-0.704,0.926,0.257],[-0.630,0.926,0.233],[-0.556,0.926,0.206],[-0.481,0.926,0.178],[-0.407,0.926,0.152],[-0.333,0.926,0.129],[-0.259,0.926,0.110],[-0.185,0.926,0.096],[-0.111,0.926,0.086],[-0.037,0.926,0.082],[0.037,0.926,0.082],[0.111,0.926,0.086],[0.185,0.926,0.096],[0.259,0.926,0.110],[0.333,0.926,0.129],[0.407,0.926,0.152],[0.481,0.926,0.178],[0.556,0.926,0.206],[0.630,0.926,0.233],[0.704,0.926,0.257],[0.778,0.926,0.276],[0.852,0.926,0.286],[0.926,0.926,0.287],[1.000,0.926,0.277],],[[-1.000,1.000,0.260],[-0.926,1.000,0.277],[-0.852,1.000,0.287],[-0.778,1.000,0.287],[-0.704,1.000,0.280],[-0.630,1.000,0.266],[-0.556,1.000,0.247],[-0.481,1.000,0.227],[-0.407,1.000,0.206],[-0.333,1.000,0.186],[-0.259,1.000,0.170],[-0.185,1.000,0.156],[-0.111,1.000,0.147],[-0.037,1.000,0.143],[0.037,1.000,0.143],[0.111,1.000,0.147],[0.185,1.000,0.156],[0.259,1.000,0.170],[0.333,1.000,0.186],[0.407,1.000,0.206],[0.481,1.000,0.227],[0.556,1.000,0.247],[0.630,1.000,0.266],[0.704,1.000,0.280],[0.778,1.000,0.287],[0.852,1.000,0.287],[0.926,1.000,0.277],[1.000,1.000,0.260],],];\n const rows = grid.length;\n const cols = grid[0].length;\n const svgMesh = root.querySelector(\u0027.fry-3d-mesh\u0027);\n const canvas = root.querySelector(\u0027.fry-3d-canvas\u0027);\n\n let azDeg = 30;\n let elDeg = 35;\n const cx = 380;\n const cy = 190;\n const scaleX = 180.0 * 0.7;\n const scaleY = 90.0 * 0.7;\n const scaleZ = 120.0 * 0.6;\n\n function colorMap(t) {\n const hue = 240.0 - (Math.max(0, Math.min(1, t)) * 195.0);\n return \u0027hsl(\u0027 \u002B Math.round(hue) \u002B \u0027, 85%, 55%)\u0027;\n }\n\n function render(az, el) {\n const azRad = az * Math.PI / 180.0;\n const elRad = el * Math.PI / 180.0;\n const cosAz = Math.cos(azRad);\n const sinAz = Math.sin(azRad);\n const sinEl = Math.sin(elRad);\n\n function project(u, v, z) {\n const zScaled = (z * 1.8) - 0.9;\n const xRot = (u * cosAz - v * sinAz);\n const yRot = (u * sinAz \u002B v * cosAz);\n const px = cx \u002B xRot * scaleX;\n const py = cy \u002B (yRot * sinEl * scaleY) - (zScaled * scaleZ);\n return [px, py];\n }\n\n let svgContent = \u0027\u0027;\n\n // Row curves\n for (let r = 0; r \u003C rows; r\u002B\u002B) {\n let path = \u0027\u0027;\n let rowZSum = 0;\n for (let c = 0; c \u003C cols; c\u002B\u002B) {\n const pt = grid[r][c];\n rowZSum \u002B= pt[2];\n const p = project(pt[0], pt[1], pt[2]);\n path \u002B= (c === 0 ? \u0027M \u0027 : \u0027L \u0027) \u002B p[0].toFixed(1) \u002B \u0027 \u0027 \u002B p[1].toFixed(1) \u002B \u0027 \u0027;\n }\n const col = colorMap(rowZSum / cols);\n svgContent \u002B= \u0027\u003Cpath d=\u0022\u0027 \u002B path \u002B \u0027\u0022 fill=\u0022none\u0022 stroke=\u0022\u0027 \u002B col \u002B \u0027\u0022 stroke-width=\u00221.4\u0022 opacity=\u00220.85\u0022 /\u003E\u0027;\n }\n\n // Col curves (mesh cross lines)\n const colStep = Math.max(1, Math.floor(cols / 25));\n for (let c = 0; c \u003C cols; c \u002B= colStep) {\n let path = \u0027\u0027;\n for (let r = 0; r \u003C rows; r\u002B\u002B) {\n const pt = grid[r][c];\n const p = project(pt[0], pt[1], pt[2]);\n path \u002B= (r === 0 ? \u0027M \u0027 : \u0027L \u0027) \u002B p[0].toFixed(1) \u002B \u0027 \u0027 \u002B p[1].toFixed(1) \u002B \u0027 \u0027;\n }\n svgContent \u002B= \u0027\u003Cpath d=\u0022\u0027 \u002B path \u002B \u0027\u0022 fill=\u0022none\u0022 stroke=\u0022#58a6ff\u0022 stroke-width=\u00220.8\u0022 opacity=\u00220.35\u0022 /\u003E\u0027;\n }\n\n svgMesh.innerHTML = svgContent;\n }\n\n render(azDeg, elDeg);\n\n window.fryRotate3D = function(id, az, el) {\n if (id !== \u0027fry_3d_968ce822\u0027) return;\n if (az !== null) azDeg = parseFloat(az);\n if (el !== null) elDeg = parseFloat(el);\n render(azDeg, elDeg);\n };\n\n // Drag to rotate interaction\n let isDragging = false;\n let startX = 0, startY = 0;\n\n canvas.addEventListener(\u0027mousedown\u0027, function(e) {\n isDragging = true;\n startX = e.clientX;\n startY = e.clientY;\n canvas.style.cursor = \u0027grabbing\u0027;\n });\n\n window.addEventListener(\u0027mousemove\u0027, function(e) {\n if (!isDragging) return;\n const dx = e.clientX - startX;\n const dy = e.clientY - startY;\n startX = e.clientX;\n startY = e.clientY;\n azDeg = (azDeg \u002B dx * 0.6 \u002B 360) % 360;\n elDeg = Math.max(15, Math.min(80, elDeg - dy * 0.4));\n const azSlider = root.querySelector(\u0027.fry-3d-az-slider\u0027);\n if (azSlider) azSlider.value = Math.round(azDeg);\n const elSlider = root.querySelector(\u0027.fry-3d-el-slider\u0027);\n if (elSlider) elSlider.value = Math.round(elDeg);\n render(azDeg, elDeg);\n });\n\n window.addEventListener(\u0027mouseup\u0027, function() {\n if (isDragging) {\n isDragging = false;\n canvas.style.cursor = \u0027grab\u0027;\n }\n });\n})();\n\u003C/script\u003E\u003C/div\u003E\u003C/div\u003E", + "TableSnapshot": null, + "InspectorSnapshot": null, + "Visuals": [ + { + "MimeType": "application/vnd.fry.plot3d.v1\u002Bjson", + "Data": { + "kind": "surface", + "surface": { + "x": { + "min": -6, + "max": 6 + }, + "y": { + "min": -6, + "max": 6 + }, + "z": [ + [ + 0.09513663192183475, + 0.11596656479351104, + 0.12682462991954654, + 0.12737682780328718, + 0.11846831815380296, + 0.10186643610630686, + 0.0799124073170399, + 0.05515148446540677, + 0.030006056773055224, + 0.006539307734329662, + -0.013667579789292917, + -0.029528350954929623, + -0.040381550792932826, + -0.04587907383305402, + -0.04587907383305402, + -0.040381550792932826, + -0.029528350954929623, + -0.013667579789292917, + 0.006539307734329662, + 0.030006056773055224, + 0.05515148446540677, + 0.0799124073170398, + 0.10186643610630679, + 0.11846831815380293, + 0.12737682780328718, + 0.12682462991954654, + 0.11596656479351104, + 0.09513663192183475 + ], + [ + 0.11596656479351104, + 0.12727873569956913, + 0.12635519745351942, + 0.11386982985895412, + 0.09174384146839097, + 0.06274354564982379, + 0.030006056773055224, + -0.003418301489264026, + -0.03492980639601228, + -0.06259780962122471, + -0.08521600094933486, + -0.1022114315231641, + -0.11345326509316117, + -0.11902474787051427, + -0.11902474787051427, + -0.11345326509316131, + -0.1022114315231641, + -0.08521600094933486, + -0.06259780962122471, + -0.03492980639601228, + -0.003418301489264026, + 0.030006056773055095, + 0.06274354564982379, + 0.09174384146839097, + 0.11386982985895407, + 0.12635519745351942, + 0.12727873569956913, + 0.11596656479351104 + ], + [ + 0.12682462991954654, + 0.12635519745351942, + 0.11213120776405484, + 0.08604142186417321, + 0.051203995619134676, + 0.011401097914993538, + -0.029528350954929776, + -0.06822876507074507, + -0.10221143152316423, + -0.1300275977953364, + -0.15122123991517392, + -0.1660955742179871, + -0.17536272731412633, + -0.17976608439012923, + -0.17976608439012923, + -0.17536272731412633, + -0.1660955742179871, + -0.15122123991517392, + -0.1300275977953364, + -0.10221143152316423, + -0.06822876507074507, + -0.02952835095492992, + 0.011401097914993538, + 0.05120399561913456, + 0.08604142186417321, + 0.11213120776405484, + 0.12635519745351942, + 0.12682462991954654 + ], + [ + 0.12737682780328718, + 0.11386982985895412, + 0.08604142186417321, + 0.04715716246995318, + 0.0015985347206329088, + -0.045879073833053875, + -0.09088915480098324, + -0.13002759779533626, + -0.16125584316588004, + -0.1839992311665768, + -0.1989615614861598, + -0.20771065130836514, + -0.21213174340744032, + -0.2138682230867493, + -0.2138682230867493, + -0.21213174340744037, + -0.20771065130836514, + -0.1989615614861598, + -0.1839992311665768, + -0.16125584316588004, + -0.13002759779533626, + -0.09088915480098324, + -0.04587907383305402, + 0.0015985347206329088, + 0.04715716246995306, + 0.08604142186417321, + 0.11386982985895412, + 0.12737682780328718 + ], + [ + 0.11846831815380296, + 0.09174384146839097, + 0.051203995619134676, + 0.0015985347206329088, + -0.05141764082976182, + -0.1022114315231641, + -0.1460480754887461, + -0.17976608439012912, + -0.20213862295031954, + -0.2138682230867493, + -0.2172293925717256, + -0.21543782918861734, + -0.21187393452997094, + -0.20931329174357388, + -0.20931329174357388, + -0.21187393452997091, + -0.21543782918861734, + -0.2172293925717256, + -0.2138682230867493, + -0.20213862295031954, + -0.17976608439012912, + -0.1460480754887461, + -0.10221143152316423, + -0.05141764082976182, + 0.0015985347206329088, + 0.051203995619134676, + 0.09174384146839097, + 0.11846831815380296 + ], + [ + 0.10186643610630686, + 0.06274354564982379, + 0.011401097914993538, + -0.045879073833053875, + -0.1022114315231641, + -0.15122123991517378, + -0.18804908974216286, + -0.21007342776120977, + -0.2172293925717256, + -0.21187393452997091, + -0.1982275625850077, + -0.18149785600410848, + -0.16684582088550717, + -0.1583833179392697, + -0.1583833179392697, + -0.16684582088550717, + -0.18149785600410842, + -0.1982275625850077, + -0.21187393452997091, + -0.2172293925717256, + -0.21007342776120977, + -0.18804908974216297, + -0.15122123991517392, + -0.10221143152316423, + -0.04587907383305402, + 0.011401097914993538, + 0.06274354564982379, + 0.10186643610630686 + ], + [ + 0.0799124073170399, + 0.030006056773055224, + -0.029528350954929776, + -0.09088915480098324, + -0.1460480754887461, + -0.18804908974216286, + -0.21213174340744037, + -0.21648694256897097, + -0.20251503596944523, + -0.1745407635343178, + -0.13903280673798818, + -0.10346108234773095, + -0.07498767904568035, + -0.05921643708196729, + -0.05921643708196729, + -0.07498767904568035, + -0.10346108234773083, + -0.13903280673798818, + -0.1745407635343178, + -0.20251503596944523, + -0.21648694256897097, + -0.2121317434074404, + -0.18804908974216297, + -0.1460480754887461, + -0.09088915480098324, + -0.029528350954929776, + 0.030006056773055224, + 0.0799124073170399 + ], + [ + 0.05515148446540677, + -0.003418301489264026, + -0.06822876507074507, + -0.13002759779533626, + -0.17976608439012912, + -0.21007342776120977, + -0.21648694256897097, + -0.1982275625850077, + -0.1583833179392698, + -0.10346108234773095, + -0.042372535037570866, + 0.014984057583321607, + 0.059315391064978046, + 0.0834363447647132, + 0.0834363447647132, + 0.05931539106497819, + 0.014984057583321898, + -0.042372535037570866, + -0.10346108234773095, + -0.1583833179392698, + -0.1982275625850077, + -0.21648694256897097, + -0.21007342776120985, + -0.17976608439012923, + -0.1300275977953364, + -0.06822876507074507, + -0.003418301489264026, + 0.05515148446540677 + ], + [ + 0.030006056773055224, + -0.03492980639601228, + -0.10221143152316423, + -0.16125584316588004, + -0.20213862295031954, + -0.2172293925717256, + -0.20251503596944523, + -0.1583833179392698, + -0.08972371851374739, + -0.005312155821950362, + 0.08343634476471336, + 0.16415774887470502, + 0.22540737588752824, + 0.25840976490336276, + 0.25840976490336276, + 0.22540737588752824, + 0.16415774887470502, + 0.08343634476471336, + -0.005312155821950362, + -0.08972371851374739, + -0.1583833179392698, + -0.20251503596944517, + -0.2172293925717256, + -0.2021386229503196, + -0.16125584316588015, + -0.10221143152316423, + -0.03492980639601228, + 0.030006056773055224 + ], + [ + 0.006539307734329662, + -0.06259780962122471, + -0.1300275977953364, + -0.1839992311665768, + -0.2138682230867493, + -0.21187393452997091, + -0.1745407635343178, + -0.10346108234773095, + -0.005312155821950362, + 0.10891980905843199, + 0.22540737588752824, + 0.32942645897590556, + 0.4074823767322103, + 0.4492885365111389, + 0.4492885365111389, + 0.4074823767322105, + 0.3294264589759057, + 0.22540737588752824, + 0.10891980905843199, + -0.005312155821950362, + -0.10346108234773095, + -0.1745407635343176, + -0.21187393452997086, + -0.21386822308674935, + -0.1839992311665769, + -0.1300275977953364, + -0.06259780962122471, + 0.006539307734329662 + ], + [ + -0.013667579789292917, + -0.08521600094933486, + -0.15122123991517392, + -0.1989615614861598, + -0.2172293925717256, + -0.1982275625850077, + -0.13903280673798818, + -0.042372535037570866, + 0.08343634476471336, + 0.22540737588752824, + 0.3675471372972722, + 0.493023287997632, + 0.5865159597566532, + 0.6363960060850318, + 0.6363960060850319, + 0.5865159597566533, + 0.4930232879976322, + 0.3675471372972722, + 0.22540737588752824, + 0.08343634476471336, + -0.042372535037570866, + -0.13903280673798799, + -0.1982275625850076, + -0.2172293925717256, + -0.19896156148615987, + -0.15122123991517392, + -0.08521600094933486, + -0.013667579789292917 + ], + [ + -0.029528350954929623, + -0.1022114315231641, + -0.1660955742179871, + -0.20771065130836514, + -0.21543782918861734, + -0.18149785600410848, + -0.10346108234773095, + 0.014984057583321607, + 0.16415774887470502, + 0.32942645897590556, + 0.493023287997632, + 0.6363960060850314, + 0.7427393908561389, + 0.7993334098103074, + 0.7993334098103075, + 0.742739390856139, + 0.6363960060850318, + 0.493023287997632, + 0.32942645897590556, + 0.16415774887470502, + 0.014984057583321607, + -0.10346108234773063, + -0.18149785600410837, + -0.21543782918861734, + -0.2077106513083652, + -0.1660955742179871, + -0.1022114315231641, + -0.029528350954929623 + ], + [ + -0.040381550792932826, + -0.11345326509316117, + -0.17536272731412633, + -0.21213174340744032, + -0.21187393452997094, + -0.16684582088550717, + -0.07498767904568035, + 0.059315391064978046, + 0.22540737588752824, + 0.4074823767322103, + 0.5865159597566532, + 0.7427393908561389, + 0.8582985689787012, + 0.9197039526349604, + 0.9197039526349604, + 0.8582985689787013, + 0.7427393908561392, + 0.5865159597566532, + 0.4074823767322103, + 0.22540737588752824, + 0.059315391064978046, + -0.07498767904568, + -0.16684582088550703, + -0.21187393452997091, + -0.2121317434074404, + -0.17536272731412633, + -0.11345326509316117, + -0.040381550792932826 + ], + [ + -0.04587907383305402, + -0.11902474787051427, + -0.17976608439012923, + -0.2138682230867493, + -0.20931329174357388, + -0.1583833179392697, + -0.05921643708196729, + 0.0834363447647132, + 0.25840976490336276, + 0.4492885365111389, + 0.6363960060850318, + 0.7993334098103074, + 0.9197039526349604, + 0.9836201921792352, + 0.9836201921792354, + 0.9197039526349605, + 0.7993334098103078, + 0.6363960060850318, + 0.4492885365111389, + 0.25840976490336276, + 0.0834363447647132, + -0.05921643708196692, + -0.15838331793926957, + -0.20931329174357385, + -0.21386822308674935, + -0.17976608439012923, + -0.11902474787051427, + -0.04587907383305402 + ], + [ + -0.04587907383305402, + -0.11902474787051427, + -0.17976608439012923, + -0.2138682230867493, + -0.20931329174357388, + -0.1583833179392697, + -0.05921643708196729, + 0.0834363447647132, + 0.25840976490336276, + 0.4492885365111389, + 0.6363960060850319, + 0.7993334098103075, + 0.9197039526349604, + 0.9836201921792354, + 0.9836201921792354, + 0.9197039526349606, + 0.7993334098103079, + 0.6363960060850319, + 0.4492885365111389, + 0.25840976490336276, + 0.0834363447647132, + -0.05921643708196692, + -0.15838331793926957, + -0.20931329174357385, + -0.21386822308674935, + -0.17976608439012923, + -0.11902474787051427, + -0.04587907383305402 + ], + [ + -0.040381550792932826, + -0.11345326509316131, + -0.17536272731412633, + -0.21213174340744037, + -0.21187393452997091, + -0.16684582088550717, + -0.07498767904568035, + 0.05931539106497819, + 0.22540737588752824, + 0.4074823767322105, + 0.5865159597566533, + 0.742739390856139, + 0.8582985689787013, + 0.9197039526349605, + 0.9197039526349606, + 0.8582985689787015, + 0.7427393908561394, + 0.5865159597566533, + 0.4074823767322105, + 0.22540737588752824, + 0.05931539106497819, + -0.07498767904568, + -0.16684582088550695, + -0.21187393452997086, + -0.2121317434074404, + -0.17536272731412633, + -0.11345326509316131, + -0.040381550792932826 + ], + [ + -0.029528350954929623, + -0.1022114315231641, + -0.1660955742179871, + -0.20771065130836514, + -0.21543782918861734, + -0.18149785600410842, + -0.10346108234773083, + 0.014984057583321898, + 0.16415774887470502, + 0.3294264589759057, + 0.4930232879976322, + 0.6363960060850318, + 0.7427393908561392, + 0.7993334098103078, + 0.7993334098103079, + 0.7427393908561394, + 0.636396006085032, + 0.4930232879976322, + 0.3294264589759057, + 0.16415774887470502, + 0.014984057583321898, + -0.10346108234773052, + -0.1814978560041083, + -0.2154378291886173, + -0.2077106513083652, + -0.1660955742179871, + -0.1022114315231641, + -0.029528350954929623 + ], + [ + -0.013667579789292917, + -0.08521600094933486, + -0.15122123991517392, + -0.1989615614861598, + -0.2172293925717256, + -0.1982275625850077, + -0.13903280673798818, + -0.042372535037570866, + 0.08343634476471336, + 0.22540737588752824, + 0.3675471372972722, + 0.493023287997632, + 0.5865159597566532, + 0.6363960060850318, + 0.6363960060850319, + 0.5865159597566533, + 0.4930232879976322, + 0.3675471372972722, + 0.22540737588752824, + 0.08343634476471336, + -0.042372535037570866, + -0.13903280673798799, + -0.1982275625850076, + -0.2172293925717256, + -0.19896156148615987, + -0.15122123991517392, + -0.08521600094933486, + -0.013667579789292917 + ], + [ + 0.006539307734329662, + -0.06259780962122471, + -0.1300275977953364, + -0.1839992311665768, + -0.2138682230867493, + -0.21187393452997091, + -0.1745407635343178, + -0.10346108234773095, + -0.005312155821950362, + 0.10891980905843199, + 0.22540737588752824, + 0.32942645897590556, + 0.4074823767322103, + 0.4492885365111389, + 0.4492885365111389, + 0.4074823767322105, + 0.3294264589759057, + 0.22540737588752824, + 0.10891980905843199, + -0.005312155821950362, + -0.10346108234773095, + -0.1745407635343176, + -0.21187393452997086, + -0.21386822308674935, + -0.1839992311665769, + -0.1300275977953364, + -0.06259780962122471, + 0.006539307734329662 + ], + [ + 0.030006056773055224, + -0.03492980639601228, + -0.10221143152316423, + -0.16125584316588004, + -0.20213862295031954, + -0.2172293925717256, + -0.20251503596944523, + -0.1583833179392698, + -0.08972371851374739, + -0.005312155821950362, + 0.08343634476471336, + 0.16415774887470502, + 0.22540737588752824, + 0.25840976490336276, + 0.25840976490336276, + 0.22540737588752824, + 0.16415774887470502, + 0.08343634476471336, + -0.005312155821950362, + -0.08972371851374739, + -0.1583833179392698, + -0.20251503596944517, + -0.2172293925717256, + -0.2021386229503196, + -0.16125584316588015, + -0.10221143152316423, + -0.03492980639601228, + 0.030006056773055224 + ], + [ + 0.05515148446540677, + -0.003418301489264026, + -0.06822876507074507, + -0.13002759779533626, + -0.17976608439012912, + -0.21007342776120977, + -0.21648694256897097, + -0.1982275625850077, + -0.1583833179392698, + -0.10346108234773095, + -0.042372535037570866, + 0.014984057583321607, + 0.059315391064978046, + 0.0834363447647132, + 0.0834363447647132, + 0.05931539106497819, + 0.014984057583321898, + -0.042372535037570866, + -0.10346108234773095, + -0.1583833179392698, + -0.1982275625850077, + -0.21648694256897097, + -0.21007342776120985, + -0.17976608439012923, + -0.1300275977953364, + -0.06822876507074507, + -0.003418301489264026, + 0.05515148446540677 + ], + [ + 0.0799124073170398, + 0.030006056773055095, + -0.02952835095492992, + -0.09088915480098324, + -0.1460480754887461, + -0.18804908974216297, + -0.2121317434074404, + -0.21648694256897097, + -0.20251503596944517, + -0.1745407635343176, + -0.13903280673798799, + -0.10346108234773063, + -0.07498767904568, + -0.05921643708196692, + -0.05921643708196692, + -0.07498767904568, + -0.10346108234773052, + -0.13903280673798799, + -0.1745407635343176, + -0.20251503596944517, + -0.21648694256897097, + -0.21213174340744045, + -0.18804908974216306, + -0.14604807548874624, + -0.09088915480098353, + -0.02952835095492992, + 0.030006056773055095, + 0.0799124073170398 + ], + [ + 0.10186643610630679, + 0.06274354564982379, + 0.011401097914993538, + -0.04587907383305402, + -0.10221143152316423, + -0.15122123991517392, + -0.18804908974216297, + -0.21007342776120985, + -0.2172293925717256, + -0.21187393452997086, + -0.1982275625850076, + -0.18149785600410837, + -0.16684582088550703, + -0.15838331793926957, + -0.15838331793926957, + -0.16684582088550695, + -0.1814978560041083, + -0.1982275625850076, + -0.21187393452997086, + -0.2172293925717256, + -0.21007342776120985, + -0.18804908974216306, + -0.15122123991517405, + -0.10221143152316438, + -0.045879073833054174, + 0.011401097914993538, + 0.06274354564982379, + 0.10186643610630679 + ], + [ + 0.11846831815380293, + 0.09174384146839097, + 0.05120399561913456, + 0.0015985347206329088, + -0.05141764082976182, + -0.10221143152316423, + -0.1460480754887461, + -0.17976608439012923, + -0.2021386229503196, + -0.21386822308674935, + -0.2172293925717256, + -0.21543782918861734, + -0.21187393452997091, + -0.20931329174357385, + -0.20931329174357385, + -0.21187393452997086, + -0.2154378291886173, + -0.2172293925717256, + -0.21386822308674935, + -0.2021386229503196, + -0.17976608439012923, + -0.14604807548874624, + -0.10221143152316438, + -0.051417640829761975, + 0.0015985347206327678, + 0.05120399561913456, + 0.09174384146839097, + 0.11846831815380293 + ], + [ + 0.12737682780328718, + 0.11386982985895407, + 0.08604142186417321, + 0.04715716246995306, + 0.0015985347206329088, + -0.04587907383305402, + -0.09088915480098324, + -0.1300275977953364, + -0.16125584316588015, + -0.1839992311665769, + -0.19896156148615987, + -0.2077106513083652, + -0.2121317434074404, + -0.21386822308674935, + -0.21386822308674935, + -0.2121317434074404, + -0.2077106513083652, + -0.19896156148615987, + -0.1839992311665769, + -0.16125584316588015, + -0.1300275977953364, + -0.09088915480098353, + -0.045879073833054174, + 0.0015985347206327678, + 0.04715716246995294, + 0.08604142186417321, + 0.11386982985895407, + 0.12737682780328718 + ], + [ + 0.12682462991954654, + 0.12635519745351942, + 0.11213120776405484, + 0.08604142186417321, + 0.051203995619134676, + 0.011401097914993538, + -0.029528350954929776, + -0.06822876507074507, + -0.10221143152316423, + -0.1300275977953364, + -0.15122123991517392, + -0.1660955742179871, + -0.17536272731412633, + -0.17976608439012923, + -0.17976608439012923, + -0.17536272731412633, + -0.1660955742179871, + -0.15122123991517392, + -0.1300275977953364, + -0.10221143152316423, + -0.06822876507074507, + -0.02952835095492992, + 0.011401097914993538, + 0.05120399561913456, + 0.08604142186417321, + 0.11213120776405484, + 0.12635519745351942, + 0.12682462991954654 + ], + [ + 0.11596656479351104, + 0.12727873569956913, + 0.12635519745351942, + 0.11386982985895412, + 0.09174384146839097, + 0.06274354564982379, + 0.030006056773055224, + -0.003418301489264026, + -0.03492980639601228, + -0.06259780962122471, + -0.08521600094933486, + -0.1022114315231641, + -0.11345326509316117, + -0.11902474787051427, + -0.11902474787051427, + -0.11345326509316131, + -0.1022114315231641, + -0.08521600094933486, + -0.06259780962122471, + -0.03492980639601228, + -0.003418301489264026, + 0.030006056773055095, + 0.06274354564982379, + 0.09174384146839097, + 0.11386982985895407, + 0.12635519745351942, + 0.12727873569956913, + 0.11596656479351104 + ], + [ + 0.09513663192183475, + 0.11596656479351104, + 0.12682462991954654, + 0.12737682780328718, + 0.11846831815380296, + 0.10186643610630686, + 0.0799124073170399, + 0.05515148446540677, + 0.030006056773055224, + 0.006539307734329662, + -0.013667579789292917, + -0.029528350954929623, + -0.040381550792932826, + -0.04587907383305402, + -0.04587907383305402, + -0.040381550792932826, + -0.029528350954929623, + -0.013667579789292917, + 0.006539307734329662, + 0.030006056773055224, + 0.05515148446540677, + 0.0799124073170398, + 0.10186643610630679, + 0.11846831815380293, + 0.12737682780328718, + 0.12682462991954654, + 0.11596656479351104, + 0.09513663192183475 + ] + ] + }, + "title": "Sombrero Wave Surface (z = sin(r) / r)" + }, + "Title": "Sombrero Wave Surface (z = sin(r) / r)" + } + ], + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "cell-img-md", + "Type": 1, + "Source": "### 5. Programmatic Mandelbrot Image Synthesis", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-img-code", + "Type": 0, + "Language": "csharp", + "Source": "using System.IO;\nusing System.IO.Compression;\n\nbyte[] GenerateMandelbrot(int w, int h)\n{\n var raw = new byte[h * (1 \u002B w * 4)];\n for (int y = 0; y \u003C h; y\u002B\u002B)\n {\n int rowOff = y * (1 \u002B w * 4);\n raw[rowOff] = 0;\n double cy = (y - h / 2.0) * 2.8 / h;\n for (int x = 0; x \u003C w; x\u002B\u002B)\n {\n double cx = (x - w * 0.72) * 2.8 / w;\n double zx = 0, zy = 0;\n int iter = 0;\n while (zx * zx \u002B zy * zy \u003C 4.0 \u0026\u0026 iter \u003C 64)\n {\n double tmp = zx * zx - zy * zy \u002B cx;\n zy = 2.0 * zx * zy \u002B cy;\n zx = tmp;\n iter\u002B\u002B;\n }\n\n int pxOff = rowOff \u002B 1 \u002B x * 4;\n if (iter == 64)\n {\n raw[pxOff] = 13; raw[pxOff \u002B 1] = 17; raw[pxOff \u002B 2] = 23; raw[pxOff \u002B 3] = 255;\n }\n else\n {\n double t = (double)iter / 64.0;\n raw[pxOff] = (byte)(Math.Sin(t * 6.28) * 110 \u002B 130);\n raw[pxOff \u002B 1] = (byte)(Math.Sin(t * 6.28 \u002B 2.0) * 110 \u002B 130);\n raw[pxOff \u002B 2] = (byte)(Math.Sin(t * 6.28 \u002B 4.0) * 110 \u002B 130);\n raw[pxOff \u002B 3] = 255;\n }\n }\n }\n\n using var ms = new MemoryStream();\n using var bw = new BinaryWriter(ms);\n bw.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });\n\n void Chunk(string name, byte[] data)\n {\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(data.Length));\n var tag = System.Text.Encoding.ASCII.GetBytes(name);\n bw.Write(tag);\n bw.Write(data);\n uint crc = 0xFFFFFFFF;\n foreach (var b in tag) crc = Crc32(crc, b);\n foreach (var b in data) crc = Crc32(crc, b);\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(~crc));\n }\n\n uint Crc32(uint c, byte b)\n {\n c ^= b;\n for (int k = 0; k \u003C 8; k\u002B\u002B) c = (c \u0026 1) != 0 ? (0xEDB88320 ^ (c \u003E\u003E 1)) : (c \u003E\u003E 1);\n return c;\n }\n\n using (var ihdr = new MemoryStream())\n using (var iw = new BinaryWriter(ihdr))\n {\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(w));\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(h));\n iw.Write((byte)8); iw.Write((byte)6); iw.Write((byte)0); iw.Write((byte)0); iw.Write((byte)0);\n Chunk(\u0022IHDR\u0022, ihdr.ToArray());\n }\n\n using (var idat = new MemoryStream())\n {\n using (var deflate = new ZLibStream(idat, CompressionLevel.Optimal, true))\n {\n deflate.Write(raw);\n }\n Chunk(\u0022IDAT\u0022, idat.ToArray());\n }\n\n Chunk(\u0022IEND\u0022, Array.Empty\u003Cbyte\u003E());\n return ms.ToArray();\n}\n\nbyte[] pngBytes = GenerateMandelbrot(540, 270);\nDisplay.Image(pngBytes, \u0022PNG\u0022);\nConsole.WriteLine($\u0022\u2713 Mandelbrot fractal synthesized ({pngBytes.Length:N0} bytes PNG).\u0022);", + "OutputText": "\u2713 Mandelbrot fractal synthesized (20,374 bytes PNG).\n", + "ExecutionCount": 5, + "IsExecuting": false, + "ExecutionTimeText": "181 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": "iVBORw0KGgoAAAANSUhEUgAAAhwAAAEOCAYAAADCCM5xAABPXUlEQVR4nO2dbctt13We/Qf6oaDITqvg1ghhaociEQSSQ4IOCQ5HODhwhI0DEi4YXAg4YOOAP6QQELQQsKGBQA0tLTK0EGJICSS0EBJaAqYl2DStiwnCrWiMIvn8hFOueda9Mp551vtee4/1cn8YPO9772fuuea41j3ePvDbP/zoI9v+1+B3f/ARm9fAe8B7wHvAe2Cze+AD2Y7SZtDIvghsXgPvAe8B7wEDh4HEioYPAjsD7wHvAe+BA+wBKxw7U1myN4zNa+A94D3gPeA9YODYABAYNHwh+jD2HvAe8B7wHrDCkQ4OVjR8EPkg8h7wHvAeOOMecEhlA1Dh8En+hWDzGngPeA94Dxg40h2/QcMHkQ8i7wHvAe8B7wErHOmAYEXDB9GRD6J/+z9+6tCWvb42r8FZ9oBDKgaN9E1ou84aZDvys5n3sa9l7wEDR7py4TwNH0SGh3wg2KLZQflsONMesMJh0EjfhGe2bIdn28caZO9Tm9dgjT1g4LCi4cPEMJHuUG2GEkPN8c9iA4fDJ+mbcK9mJ2lQ2NMeyL5ebF4DA4fzNHwQGCrSnaHNMGIgOf5ZbOBwQuijs5udnR2+94CVkexz6Axm4HD1yWnMTsVg4T3gEE32OXRmM3C4zPVwZqdisPAecJ5I9jlkM3C4p8bBLgQ7FsOF98D290D2OWHbxhpY4XDzrvRNaLjIdwg2r4EhJP+MO7oZONwpdJNmB2gH6D1wvj2Qfe7YDBybNG9Mw0X24WzzGhx9D/icPRYEWeEwbFi92MDBatvPGvzef/vJ3Vn2mhlC8p39FszAYVXj6pss+4CyeQ2OChKGEqsg2RBh4HAIJW1D2bnbuW95D2Q79T77w//y9KgZRqa9x9lO1WaFw7kahox0Z2c7L1hMAYpLzYqIAWTrsOOQihNDrWIYBnYLQ9kgMQcw/uQ/PTXJ9gogWPZ+mGLZTvfMZuBwFcroJsk+IGxegy3DxZCCAUD8+R889eiv/uUHH/3Fv3+q2J9/8Hcef/yDx3YL+MgMyWz9+s12wmcyA4fLXQ0ZGzj0bE+uQTZELIWMWtEAKgCM//mvf6KAx//553+/fB6hQyYo4edYBJK9g8ceACTbIR/dDBzurVE2QvaFbjv3GmQ7wTVBow6f1EABdPzNl5999PD1Fx799W9+uHwUYOhnfF/AUSshR1A9DCD5zt/A4UZeN90E2U7Gdt41yHZy1wKNvlyNPhVDisf7X/xYsYcPXr6jgIyFXo4EHlj2vhyybGd9BDu9wpH9Btzasi9a2znXINuR3QI0+mCjho46bAJ0SOkANvi6CzjG8j3WgJDstTZ85PsIA4fbkxswNuA0beeBjLmgMQU4hqAD0AA4CKcIOC6BjktBJHvtDR/HtNMqHNkLf02zYzQcWMW4LWhcAhxR4QA4pHLIuhJMl0LHXAjJBg3DR74/MXAYNgwZhopNgGW281nT1siFmOLk+/I4SiLp/XuPcznu3ytf8/n//Uf/tE0ivRZ4jAFI9ntj+DiGnUrhyF7stS3b2djOuQbZTmaLoDEVNmqVo87jAC7+zt/9YGt/89Jr5ftdYZausto+COn6vbpyZqwSJvt9Mnzk+xwDxwlhI9vh2M61BtmOZA+wcQlwlPyNBy8/tvv3Hv3F3/sXj372Vx6WBmF/9exXHv31828U6OD3ZBE6huBjDDT0GgQ1U0pws9+zPYMHlu0/DBxWNgwZGzgIbOeAjLVBY244pXb4OPsIFHxOKIWQSgmrvPJqgRDgI+Z41GrHFAiJP5OywuN3hWyGQi3Z798RwOOM8HH4kEr2Al9i2ReD7VxrkO0gzgQbfeWxsggfgg7lckTgWGrxeYCb33vqP5bwjZ6zKzSzR7VjL/CR7WsMHCeGjezNbzvXGmQ7gz2DxhrAEaEjAoGgoySTPnj50b976j+Xj4RagIQ+8IjgUqpf7t9rVRRVwkhFUbfTOkdkbqOx7Pf2KOCBZfufa9phFY7shTVk5F+4NkPGFmBjLIdiDDraEMtLrz36J//4/5XQCtAhWIi5HV3qCMABrMSEVPJCeEw+zulsegTo2At4HBE+Dgkc2Ys6x7I3tO08a5B9yB8NNKYAx5SkzSnQ0bY+f/2FYs98760SZinQ0KgXKqMtuR4vvVZ+/rtP/9c7qkZROR68/Pj3Bvp8XDLFNvs9N3jk+7VTAEf2Yhoy8p2qzZBxK9BYGzZq4OjK55Da8Sc/8W8el8w+/0Zb4fLLP/9+CxsljNKoGAq/xIZiQ51MLwUOg8d1z6Fs/2Xg2Als2BkaCKxmHB825pSkzgGOO0mkUjvu3yuhEtSMAh6NmiFFo6trabShctqlTcIMHrc557L92WkVjuxFNGgYJLYAk9ny9FlA41qwMVax0gUc5GZ84r0v3VEzImwMAceU/h1rQYdDLQaPQwBHNkxYzch3tGe2bCe/FTsqbHSWyH7xY48+/s6bJXRCCIXwSlE47t8rKsdc4LgEOpasX/ZemWPZ1/ccy/Z5h1c4shevy7I3ne0ca5B9EG/FjgAaQ8qG8jbaipPXX3j01Le/8+h/ffifFdj46S/8+NEv/tLD0hgM6wKOodyNLOjYE3hkX+tLLNsPHg44shfOoJF/UZ3Nsg/es4LGJbAxBTL6em/81k/9ZTvM7U6VClUmr7xakkQJp/B7JVG0I4cjdjEdA46l+RxrrG/2fjJ4GDh2ARvZTsh2/DXIPmi3ZHsFjbGOn6W7Z9PQS6WvQEQpa21CJUDGZx5++tGvffSHReH47Z/870XtUI+Nzz/85OPfff2F9nH+8Kn/UL7HYw8NgZsCHteGDoPH9c6QbD+5W4Uje8EMGvkO+AyW7di3ZlsBjT7YmAIXQzka6gIKVAAJJIECEc+9/fUCC6gYytfg5z/zqw/vTJUFTIAQlcSqLJbv1WGWqdCxZm+Oo8FH9vmw1LL95q6AI3uxsOwNYzv2GmQfpFuzrYJGn4KhJl1SFOqkzanVJ5ocCzTwWEDFh77x9qOn/vhP74CG7Olvfr8oHsAIoRY+x4AWJs/ykXkpU5NIh+DjVsBh8DgeeOwGOAwa+c7QZsg4ImiMlX5G2Bjrm6FR8+qNUapHmhbkdSJnDRtd/TVQNFA3uiCjzz718HNP9Oaoy2Vr+BlTZFBO+HxM7bj2e5UNwF2253PRwLEx2MjeELbjrkH2QblF2xJkdIHG2GTXdijag5eL2qAcCgFFTOYUYKhzqDqGKl9DIRFyNGhlPgU2PvSd3y9hGGauqAtpX3+OKaZQD8bXU9SOW72H2XvV4HFAhcOgke8YbQaNM0FGV/hEkCGYiNUgfE1JKqrCP/zajwo8EL4g54KPMZHzTsVJo4R89sV3y+/xOUrC03/2R23ZK48LtBAymQIcPD+AAmy0+RzN69Xr6FJbIiwBO5pMy2MAMfyf/P9TQyy3ek+3Bh97PytPHVKxopG/AW3rrEH2QbhF2xpk9CWF4lxjcicAAAwoiVOJmVGd4HeUxNk25ZLpd1E+XnqtQAWQATAAHgqh1ImhVKj0gcZT3/puUTVIHqUvB3kfPJbAg9fK6+HrZ958547iIqVFIRyqYPi8TjYd6lB667yOrcPH3s/N0wGHQyf5m85m0NgbaCwBjCnVJzGnoeRpNGGSrvAIP0O1QBkAAlS2igECdAnF+fM1vwMkzMnRmGM892985AcFQvi8VT0qxYWvVQ1T2qQ3+R9d1S1zK1gyoGML8HGE8/MUwOEcjfyNZrtsDbLvsLZmWwSMIdCYmiDaWWUSlAucPg6fkAjKgipK6J8BaPzCj79wNdiojaoVlA6AQiEg1AwpL8obUVku/wN/Uz4PcDU09O2WZbN7gI8jnKMGDieEpm9CmyEjAzLWAIwpsNHXJXRsfDzAAUjgzAlzTM29uJWhdPD6CLcQxlH/DqDoZ370tVJ22yohTbIoIMLPSxLq6y9cNNZ+C+CRAR9HOC8PqXC48iR/Y9nmrUG2erAl2zJcTAWNsbbkQ8ARS1tx0MqvyAaNMaNLKR+BD5XyRlWjr2HY0vkrWwGPW8LHUc7VwwDHtWEj+42yHWsNsp37lmzrgDEHNKbOQKk7hbZVK6FxV5zoSviCHhnZcNFlqBq8ToWCunp2zB1tPwU6tgQet4CP7DNrLds9cBg28jeRzZBxNMCYCxpzgUOD1kgOBSpw3uRAoBLIeZMHkQ0UXUYVDF1Ied2EgEq1zUCTsL6OpGtAx5ng40jn7C6Bw6pG/saxDa9BtoKwBdsTYCyBjCWwIeBAzQA6UDEYGa8yVJQD8iT6WpJnGhUyfCSXo+RnxLLdZkrt1PkrU9Z1yXuYDR0Gj3XBIx04HD6xs98y7GQ7+WzbE2BcAhlzYEPAUfIbnn+jGJCh8Ak5G4ROlIiZDRZjSaTqK6LqFTUho4Km/H8Tp8xOXeNL3+MjwUf2+bambR44DBv5m8RmyFgLMjIAYw3IGAONoeqUtpV5U9GBOoCigdLx3K+/lw4UU42wzz/43/+qHfpGTw5ABPXm0gmz14CObBAxdCwDj0MBh52nAcJqhlWMa6kaQ9UpdypUmm6jqATqxbF1YxItHwkLta3QmzboXUPexqAjEzxuCSZWOuZBRxpwGDYMB1sBxOywxZ7CJNdWMtZQK64BGUND2+qSWMITn3jvS8WBE55gvkk2UAyZmoCpHHZosuyccfZbB481wcThlWngkQIcVjXynezZLdvp7wkwhiDjVoCQCRk1aPQBhyarEpogf4OEURSDbKAYM7VWJ4yiQW1xuNvYKPup4HGNqpatgcil12r2uXgNSwUOw0b+BjizZTv/vQBGH2is4eTj5NW5Mv2Q45rzt2OAMQQaNWx0hlWafA4SSSk/JS9iS5UqtFaPX6NukOzaN8Z+DDqWAsjW4KPeT109R6bAh6GjGzx2CxzZjsu2rzXIhoC9AEafmtHl0OWQpzqZrgoP5QrIsS0Bj7XgYggwxpSN2PgrDkQDOEgeveaAtkvVDZSNGEqpO4wuBY+xde4ClFsCyBgox73K+8j/3wceffBhpePuOXxT4DBo5DveM1k2COwFMMZAAyuKRBPjL9NSGwdF/L8umxwLQ6iyg0Ocao52fPvrLyxKTuwDixZsGhionWfXnXyXU+1TM7pgowaOtqfFK69uIoGUnht8RHFpJ8eS5Nq85vh/xP+vXoM58NH1/vOc5Lrw/Jc0Fbtm2E2vidcMmLEm8f/Q6zN0bBA4DBv5Dvgslg0Ee4CLOfkZ8QCWo+au+Jd//v07Mzdqp4xTKV03mwZSMeSgMejPfO+tx105AxCscfcspyAo4vl4PeVuHgh48HI7Lr5rbkifejFkd0BDsBGAAwfP/0xDsAzFg54gavLVwoYafWlQ2wh4aC2k3tRrNqYKtX+v96KphKnf90sSUNfK8dFeqiE5QlKf6rKW2pF9lu4SOAwb+W/00S0bDPYEGEOQ0RfLltWqAY4U50mSpMaby2nxNVBClYacL3e1VG4AIjTH4m9jR8sux7UkHFI7N+CC56TRlcpAcXh8n/beAqMuiMDBkADK7/ERgJEiI7Dgf+J7pT24HHkDGoINjOdTF1L1ugCGrgUZNCTjtfFcPCfPL+DQa2sVJgFSU9ZbRtcH9UMw9dkX3y2PR2Mw3j8ejzyVLjVE7wHr9pmHn27zRXh+HoO1KDDYrBf7RaG1KQrXNUDjCdhoQn+snwCMjwIOQ8eBgCPbkdm2vQbZgLAHuJgDGn2x7a48jDa+3Th1nAjOA0elceY4Gjk6vg98tA67ubOemzcwJceiKCzciTYDyXBkODsqSPj43NtfL86SOSL8TnG64U4f0wRVoEB/ExM/pVjgSJ958532+/y//K/6WjNW9FixcoXXwJqxXjhyHDcwpNc5x3gdT3/z+wUyeCweU4284v/B69HrxekDJHoM/ob3Sq+R8l5+p8BHA1DtWnzru+XnPJdAJiad3oE21vT+vfJcGP8fiavkt1AZw2sqINTRaGzNSpg5eUZS86RqCIyjCjOWY9J3HZ5V6bg6cBg28t/ko1k2KOwFLuaCxhLgeGIse2h6haNCUdBgM7XPvnNHHWT8Lit3xB0KyJBJ/ub5eA04cV4Dn/N4hHHaMENTQcLPi9qi11ZNehWwROPx9JjRcMB1KOPW4ZMleR1SRLoUFyCRtWjnr3zz++3PpPqgAN1Rfhr1p+T5PP9G+V0eg99nzbUuCi/xXrTgV+V2XAIfcxOJBdLsYUzrIaWuDvsYOjYCHIaNfOd8JMuGhr3AxVLQGMrc7600CTH9eEBzp8wdtfInopPmdwQgpbNllPObsATGHTQf+d2+cs2+fIuYtIkTUyiHO3VJ+7XxM70+3d0r0fOr776YDgbZBhDUMBXfV82VieEkdTAFZACSWrkRuOhveZ+KKhWUjkvyeS4Jx8W9zf/DXmCPRAWmT1m5Vt+O7DP4kMCRvSi27axBNjzsCS4uAY0pZYIxtl2cf1AruEtFpsdZK19BIQccPQoBd9LI6IQq+H2+B5jwuf5OTgdIkTPi7/kbnBWKSV8yY4SNCBxzgIHn4g4cB5nt4PdghJiUFwNQlO99+zsFFHkf9fXQY7DevL8KW8xtPFbDAu+dwnZxXxQo6uieOlTurHChKmp4ffF1db2W8lyGjtsCh2Ej31nv2bJBYk9wcSlozAEODlQUByU9Sj1AzagTD3VXKJVCcnyf00FRAEC4i0TdkAP7+Dtv/i3cVEmMdxIam+dXPkGsDlHoo0/dqEMLtsvXAGjsU0RqAyZLe/WBXiBTeqMIEnhu9pSScnnsUlV1/14B4xLe68kZqmGDv2Mfo8bVOSr8XI+hvKGuktmx6/YsSsdVgMOqRv4bu0fLBoq9wcWtYKNL4Yghld7GVxzGzej2SxyX2oRz8PPxiTyBpkQzlpoKLIAd/l5QA8x0Sfu23DWIPUGU18NHOfkpOTxPhEGacBqPQWhHVUbluQK4DuURxTwU9pfCRao2kvKhx9DrnAscZ4GOTQBH9iLYzg0Z2aCQCRpz1I2upNGuu8uYQMphrFwO5QFc4ph4rLaqJOQKKCmxvqMGdpRHYNvWGgB/hM1Qv6hWAQiAAfaKEntVKYTFfi59oTTBZwmnPP9Gq1oBHdo7/Jx9EZODARHCboTUFI5RiA9TWTVKmYClHnDXlUg6BzjOAB2rA4dhI/9N3boZLrYDGnNgo6+bZ4QODuEykyP0nuDARmUg1n9J2ILHlWohR8DnEWSkZtj2twZAgUp0BRxSogQBda8Uga1yiH7t/Z+7U0FUAy7PoZJfnov9o99BQeNn6rnCY/L8hPRKD46md4kqdgTUU4DD0LEB4Mh2fLZjQ0a28rBlRWMJbPSVEz7RaKupNIklkzG58FLDkfCY3HEaMI5vKquNvVLqjq7KJVpbgSH/o35eQQ4/Zw9GpWOoGdica/yoSseqwGHYyH9Dt2SGi+2CxhLYmAIcqlhRKaxCHHSlXMMJAC1qZOU8jOMbuTj1+9w2jmtM5apKMl7LeLwCy9/+zmN1IySMqteLVA72/JLhbgYOw0a6o96zWbnYtqIxBhtzmyXFkEpM1kPhoBullA7yKIYqRKZYbNhlO+caCDalemBdzdcugQxgWaXa5Jho1k9XxUzdkGxuq/OzQcdqCofDKPlv5lEhIzuEcSTQuAZsdDZLCuWxShilU6PyMGxeg0v2gHI1rjGBlzCJSmgVRhkaLtjVcbTv2js7dKwCHIaN/DfyKJCR7eSPChlDsDG3DfTUhkkx3k0yniRqlI6xZlA2r8Fc1WMNExCXniDK3WhyROpcjbmwcQvg2Dp03Aw4sv9R2/YAI9u5nx005qgaYw2X+kpj6xbjcUS7BoepQZS7exoyLgWFtUyt9/mchnSaywPczJ2lYuhYETgMG8eFGQPG/kFjrqoxBhhdkNHbh6MBDSXWqZJABzk5HoYMQ8ZW1Cy10kd909Rj9e4ANNjXY7Bh4LiywmFlIx8MDBj5CsXWIGMKaAwlgMZx8+o/0Nd0qc7b4O6QpDsl9qmZkw52QitrVxTYvAaX7AHa7gMXKG8llNKU3PIz9nQ9O+US4DhzaOUi4DBs5L+BVjDyAWJLkDElfDKW+Ak8ICMjIZc+A6EVdGy6VKsZ3AlqSBrqBQ2VABY+d88MA0EGFLL3hn5Ocqj2uaYcd3UR7YKNPuDYisqxRegwcGzgTTBg5APBUSGjBo22X0bTAjoOoiJRjux/7vA4iOncyAEs4NBd3xOzJprQCb9HLgbVKH1ytc1rkLUHyBcCLsrgtkZ5q+e3TC1/vRQ45kLHJeHtQwCH1Y1tW2aS59p0vyfbCmR0hU80cA2gAC7In+Aj4Q/Fq2NOhRLndDjzMSbnEevWTBMlhPJ4/J3BwmCx5T3QThSuYGMt4NgSdBweOLL/qbPZNeBiDDJu4VCz4WFLcDEVMsZmoNRNugAF4tZx0ioqBcoGd4R8TnybEAtfU94KdKB+KKFOw61oXy4Q8aj3fKdqG18DgTQdbIvqFzqJjvXdmAIda5bJHkHlWAQcho1jwsUUwLiE5vcMJBn/3xzAGKo+qXM2OEyJVyMvo2wAC+rWCHh0hUWGAIJ5Kfw9MOKqEzv6vYAOYcP4NZUotdqBsbf7kkbXUDtuARxbgY5VgSP7nzmaXRsupoRKouPjouJirIcUbQE+9m5zwWJJqWvnoLXXXyh3eKgXVI6gWLSj5H/0tcmHN+PFsx2IzWuwdA8QBiwhljCfBWUPBYSf69wbg46l4GHgsLpxOLCYAhhdd/TRoXGhxXr0IYkx24kfFSymgsZQVUrsncGBisoRe2VwV+ewiB34WSCOAXFAh5KbVbXC54AHSiDQMRZemQogGcCxBZVjtsJhdWMfULEEMvpCB/FCUaWD7gS4MOOFKCdWT048KoBcAyDWAo2xXhtdrcjVe4Dprgxcy3YENq9B1h5A4VMuE2ed5ql0JZJOgY+xAW9nUDlWAY7sf2JLlg0WSyBjLE+hdnK18yrOqpk7wMexWQO228DGWM+NGjjaaa/375W7OknNdvp2+mfcA5SIq+kdMN6VSNoFHn2DDks5etPPRmfiXOjYu8oxCzgMG9sEi0sgY0pCZJ+jizkBJdGKjO/QLGdJjPMWakB53c9+ZXHW+dZVjSWwUc89UXiFuDZrpe6h2U7A5jW4xR5A1WDftzdSoUdHXxXL2DgA3Zjpcbqg4xbAkQkdBo4dKRZrQsbc6os+x82FQ/wfo1RSjaDqmQO3Bo8hB8xr5jAZy0XZCoDMgYwpeRvkZ0jJKJ0VmzHcETYADX6mw9eO3o7+rHuAMu+uhnfcYAHh+jpCSD1fiOotzhxK0HVexsT7qdBxGuA4urqRDQ23hIylwNGnbuCcyOpWHfvUbO61nfqYA67vNnRolLv5L35s0mvdM2jEmScRIiQbR1WD+DWScvZhb/MaZO+B9ox45dUSaqQiS/07VNFV+ng0eR7RuNaAC34HlTBW+fXlc9wCOLKgYzFwZAPCmeDiGqAxBzYicNTO7M7gLu6Mq/kDS5Krpjr2qQ44JrNyh6GGV9xxKPm1hqRbgtIlgDEEGwKr+H9yQJJ9z5wT+g6gdujQJEP/8w8/Wb7m+/TjQLXKPvBtXoOsPcB1wpmB8TXVKlI99DtcN1wzbat0qYWNYkgFDD1qSndT1NVwLnZBh4Fjx8CRDQhbg4wlsCHg4CIpyVPUq0tibGKc0Hvs1NcV25ySXDXH2Y853Ro29NrjMLIYjx17nXNexzXBYuj/7fqfOQQ5MHVYfvydNwtIcJjydd3ki46h+l2b1+Dse0DlsUPGNUR4BeUQFYRwpNRCGuPpzNH8IowzU2GVqcmja/uWTSoce4KNbDjYOmgsVTf6FA7dRatTZbmwQmilL6mqz1HGx+XOvEt5mGr1ayzDyT76w/YupGtY0xTwWBOQ1gCMscRQVAsOvnh40mWR0tfsw9zmNdj6HpjTAE9GE73yt7/6sCiFwEV9NkaFw8CxE+DIhoI9QcYlsNGXwxHvprmYSs16pR7UDr1VGzqSrPTzEgYIiVhzS9C6Rq7zugAO5SwASeX1atrphDkKQ8+1pkKyFK768jaK0tGULROD5n9H6nWvjXyHZjvGGsTBhqgaUlP5vnKkpHCoiq8rh+OWCsetVY7ZCocBY7+gsTSUMhQKwNkV2GgSL3HeyPdccAIPVUTQJEzSoiY1RpVBUqPCADxGF5jUzr2vDK0u/4yvpdTWN3kcvC7u+OmsKfjg9Y09dw01yg9ZUqe/1IbK8Poae+n/J9GNwxEpmCx652vkOy3bfteA66ltj94oqHxE4YjnQl/uhoFjI7CRrTrsWc1YS90Yyj2IeQN3JpHqrjr0d0DSBzqIc6o8rNC/kqwaZ8j3SbRS052oPigRsk8h6StNu+N0m9cj6OBunyRKDTJDEo3P3fWYUnX4G6AF+fSr7774GFhCctg1wGOs5r+GDXUQxcpdVjNKfkp82uY18B7oHmAoaMcACxRT4J1rK96kRJWzPlezgeOWKsdmgSMbBo4GGpfCxpjKMZRD0OXwcebqYom6wF02CgHOH2VDWd111rdgZCxs0/XcTwBHI3FqYBnPC2gwip3XReJXTIZ94nGaZNkWmirFpk8VuRZoDIVSNBtFVSmstfJubF4D74Fpe4CQrCpSFCaJ51Nf+/O+ZPK+8/f0wGHI2C9ozAWOuRUWY4mLde6GVAQs1rPjvFEZVEWBU+SuoYQ3mhAInwMnutBrEIhdM7uMuw8dFrrL53m401e1BsmUn3r4ufKagBF+FmXSqNjwc8CE/4WPquDpSka9JIl2isV15jUDGMSPBVCqSonmcfKGDcPGvD3w3NtfL2dFaQUQrvWYb9aXu7VV4LiVyvGBbHUjW204sqIxFzaW9ooYcpZ90FG30ZbzJzShDG8+kiGOTEndOwAAjGigUkksbS76GCYBSErcFEjQz/nYhFCACTW+khPme/FQ4XlQXgCfthunHk/WhH/02nl9JGQKVGIjoKjELGmNPAYZXcoG/4PW0uY18B5YZw8A81zffM71X25iBga7GThmAodBY7+gMRU4LmlQNSb/DyYwNgmaOGnyH3DyfRc6F7bu1gl/xIu+BYKmLbca9ShHQcpGVDWAm6mZ5+oWKDlVygyvIT6e+lcAKqX7YAM8giz+jnwRhYX6wkFzLYJMvb5ShrQmNq+B98B6e6D02VgAHHPVjWsrHLdQOW4KHNlKw5kUjWvBxlLg6MrlAAYUXpHDjqVlQ6aKCpWfCVr4GJMhNfUU4/EJ2dDYCoVD1RlDz4O6QYtvfh+AULtvHovXzfN3PQa/q5CRQIjnJmzUDoWq5Ngl4NGlnNS5MqyV1tgD2Awbho119gDXFGXlQw0Pa/AwcNwAOLIB4KygMQYba7TgHktwrKtWamVDA8JoD/zM996afdHjQAENTGESDgGce91jQurIGsbr5eOQIkO4plTTNECksAvQQQhHiaYx/2QKeHRBxhOw0XQ1jLkpAiE7HEOH98Dle4DrO4Z0+/K2ynVJcvpI741TKxxrwEY2AJwZNLYAG1gsy1TpaAxDKHmUqpS5F7zyOQQtGF01t3CYkjsRX5eajMWwC1YSUus8lA7lY8zq6hmXu+bvAdux14DzC5hXlZuSy7uS2Iv6+psfLmfjknLYWwHHtaHjKsCRDQBnB40h2FgKGlOBY6xEUw4xJotywaJIkNsw54LfClyMGYmvXTX8qCMcWqgf/C8AicJCbc5HRxVOn9UKktYW2HFjr/x9YDv2GqC2tonsjYLL97kOSwXbb374Ti6HgaMCDoPG/kDjFrAxVro5p/GWEj81ofRMZZocQsAI/zdhFvJByGHRzIU472UMMmrYIFxD2Ac1hedxtUr++207/hqQH8Z1zI0ECmwpwW/6BS1tZ34ahcOqxr5Aow821lY1ppZo1rBRV0/UZaUKsXDBFmfc0TfiiAYMUKGDwqM8E1XCxOZCQ/1F6tyY7P/J5jU44x4gNAxo8Dkl8qUarUneHmtpvhXguCZ0fODScEp2WOPsoZNbggamdt5q4DWYaxBCA9EhAhIqK6XqA9BQ11FMlSBnNUIr6j2C4qPk0jrP4wkLvUZY2888/HQJVdGoKPt/snkNjrYHVGaupGyqwdTVl5uE2J9jDdg4NHAYNs4FGlOmlsZEUBwczoxQSJf0r9bhXITxTl2KBhcrDnXt6pGjGDkdqoRhrdo1VP5LAIy6GRkVMMCG1zX/fbQddw1U8l6PM5jS1tzAMRM4slWHM6saU3I05oxCn9I9tA6dKDyiduQCCb6G8FE/SIpUWAQwoUID56nQiXtCjB9qrCHry91S278jNDmLFtu/q9X63ERcm9fAe2AmePTAxtSGX3PO/lv6wE0oHNkgcEbQmFLeeilgTC1z7WrkVecPEA4gD0N32Di92NCLz5eUwdoeqx0Cjy4D+qQc2bwG3gPX2QNxsGTfLJWp81MMHD3AkQ0DZwCNuTNPBApjUwrnwMWUUteuhFAlRxEOADgAD+7Qz56TsaZptgtgp8TS2GpdLczVidXmNfAeuM4eUOVXHF/A53x/6ij6uf7hkArHmWBjazAxtaS1q3Kk1HmTU9HE8lWKdclY9Knlrm3ehp67aTWuPATbOmuAakT2O+EpNVCLnUzV0+RM5cU2r0HWHkCpVYUdXxNSVoOvKa3MTw8cXepGNhRsFTQuAYglkDEUPomhDjY/zl8yX18Z61LQ6GroFSslYqmrczWuc9AJKBRikQF5dsB2wN4D19kD5J3Ra4PPAXt1E6YUlgnVqLprzU3JVjiwmwPH2UHj2lCxpMKkDzjaNuIPXi7OHtoWeERQmDvyvGuOhxQNSsJ4LiUzAhrcfWvc+5IOorZpa0DYhOF0miWjOS1eP+8h74Hr7YGYuxFnIMXcjWvAhoHjQKBxa7CYAxhjsFErEgUG7t8rxK2ppGNDwSJIQOlFpah6bHS20Q4tyrnbHhpmZlt/DX7hx18o7xeggWmAnc1r4D2w7nXG+abzVN1Dx5JEh85+A0eVv3FkyMgCjCWQMQYbQ+Pg1S8j9sqIdeNdw7+4U44X1uDMjpC3odkdOD46Z/rAu43To/SYJF0Myddrb9jwtXf9/huou7Gp17Vh45AKxxFgY2uAcQlkTIWNGjjqHAvCHtz9qg9GbJX9RB5Go45IOozfv9N4qoENHpuwCbDh3ho5zo6ZKUyrjJNobV4D74HL90B9ppG3UfrdhOTQKRUpBo6VgSOL1LYIGGtARh9sjCV6xlBJHFleWol/9Id3ci74WCYcNs2joHc5ME0yjU299HVsNqUEUY1o9iGX4+gIZ/3iL51j/ozNa3CLPcBNmoYpTg2jjMGGFY4KONYEi1tYNlhcCzS6yl/HKku6qkikTggaKKuknAtS54JCzQAWVPFAZQlhGF10JIHyNX/D76n6RCWZSlB1CWyuE0Di1VwHm9fAe2CdPaAbL86/sZblU2BjT8CxdlilU+G4FWRc4syjA+5608dsy5AxFzRq2OjKyxB0oEjEctXYARRT0ueHvvH2ne/XlSZcfDxGHC3vcEq+k3v6z/4o/TXYvAZ73gOUvaLUcl5yzpUk+i9+bLRl+bXVjSPkcTwBHNcAjbUded17Yog2rwUic/Mt5rYYHytdrctYlVNRJ3m2U12b4V9K7KSk8tJ4Px1FARQuThIWnT+Qf1gytC37Ndi8BnvfAwoNx/DJGrBh4OgBjuwwx5hD1waAQusNsbbasCZMTEn6RJ3Q6Pe6b8ZQAy7yNJDU66TOWLaqkeUoFmveDVMp4fyB/IPS5jXwHli+B3Tuxr4aXSMiLrlhtcJRAceW1Iwuq51z3U2zb0NcAgRKwOzq0LkUMPo6e6qcVe3J+3po9IVOgAvqxqVsIAkqN4N8DJSIa9wJqwOfzWvgPeA9sIc9wE0XuWecjVPnUV2qjhs4AnBcAhpLQxVjQBB/xh27NkVbhaFJmY3TjQDS5+DngEEbsmgAoJbZLrG+oWjl/3j+jSc62sU+GDF80tUPgzAHMUg+cnExVMhTWvMPOZvXwHtgIxNfm/O17sI85CsMHCvmcExRHkRp+tnQGzGUozDk/IfCDXFmB3fscROphKkORfSpCbFR1lD4ojh38iC++LHeNuF6nFK9MfJ4BYqe/Uor37XTVkMHT1WAFPAIYRIBVvu9JhmUtdAgIcACJeOZ772VfmHbvAbeA94D2XuAM1H+IbYknwMbBo6Vk0azN4XNa+A94D3gPeA9cMkeoPQfZTf2Eopj5Ptgw8Bx4yoVKxxWOHzY+7D3HvAeONoesMKxwbJY53A4hyP7YLB5DbwHvAeuvQecw7EB4HCViqtUll7ArlKxkzAoeA/saQ+4SmUDwOE+HO7DMffCdR+O/MPT5jXwHrhc9XAfDncadadRdxr1YTrhwHSnUe8TQ8fle8CdRj1L5YmeHZ6l4lkqPlyflIi9JoYO74Hle8CzVDYwvM3TYnMGuXlarJ3H1IPS02K9Vwwa6+8BT4u9MnBMgY5LQWTNaXbXmOWyhl1rfsucrqVty/OmkRjNwIhTlrr0pmEYDcj4XF1Kucj4vVjDrtH2bU178/v6GY/J1FhJkbbbrwGD9DzPxnvP1956e4CxEGoWpi7WsdP00hH1Z5wUuzpwbMW2CCDXGBo3BhzqYKqJsVw8QAQWu5reGWHfdC797IvvPoYKfa/5fmmeUw2H47EZBge4eER9jsMDEIE9T+w1cBg41t0D9Zn2Gx/5QbnR4nxdCh0Gjgo49g4dfeCxBQC5FnTULdbVBp7psLHDXuy01zUADqWiqBgd4+7jLJeuibRAB0rIV9990QffjZwfFUJ0VcQ4HL32hg5Dx/X2QFE87t8rIUzO2j7oMHBMUDh+9wcfORR0TIGPTABZCzpiFUuZy9KoFAKHvomzXZNnUUI0DK8LSu5AR4ANlBNkfR92t3N4TAXm/QLyMD73+hs4vAfWv8443+KN2JTwytrQcbiQylGBYyp4ZIPIUujQYLsCG02YAwck2OgbONdlfVBSgwmPDeXHnBDAgyFyn3r4uXKhoq4QbvEBuL4TfObNdx596Du/XxQpGe+519rA4T1wvT2gUHO8AdOEcoHHFOgwcHQAxxGhYwl4ZMDIXOAQEChHQ3DQBxZ9yae1YjIEH0/kfzRTfJVI+ucf/B3ndVzh0PvpL/y4fOS9JowlAzrsbAwc3gPX2QOELNVRmZsp8jn4/OPvvFkUZRTGa0LH4RSOOo/jyNCxFnjcAlDGymWlcOBwFOaoYWMIMJaARx1mifkcVLPwWp7+5vd9+K144H3ivS+VSZioSAAGUMdaY3zO9zgIBSQ2r4H3wHUHwnHdAf58XSr5QjLpGHRsGTjWho1e4OhSOY4MHbcGj0uhpAYPNjcOP4ZLYlO0KRUuS6CjCzhQNz7/8JMlgRHaJ6fj197/OR96K4ZRuKsCMFCRogk8OPz4PTsaw4b3wPX2wM/+ysPyMY6953O+3wUcXdBh4BgAjqNDx5bAYyqEDIVZ5toc6Ii5HG1Ipen1AWgAHNyNc0GSw/HUt77bXqh8zp2BD8P5h5xCKIBdlwEeT337O15bw4b3wLUnzYZcjpiUP7dHhxWOAeAweGwXQMbyPC6Bjxo4BBpIiEj4sTkYeRtckKgblGxygRJeIQygPiDO7Zh2sLGGCpEINNR0rTZBB9DH+qKEOGHXjtfwdd09IOCoFeZ4nho4FoRUDB37gI+1enz09fmI/T1KzkhHvw41CkPaB0RiZ1OV0OpOXEqI7W/XgLCU8l9Yq3YNY+M1fR7t/r1SIcQQN6+r95Svqev344h9jYbC2mtAx57zNwaBw9BxTvAYSkzFBBvqXlr36rhjtEFv+nXEsAuqh3IOyO/AocZyzrPnfBCSwgAylI0WNGLX1y5roEMqB9ABFD739tfT/yeb1+Boe0CJorQG4CM3V1x/qIuACNeucuvqnA4Dx0KV4ywhFoNHd5OxsfLZroZhsZolltAq1EL+B+EAhWOObiSZ0R2U8IdUCSWCtiGU0Iq+z2IeTfb/ZPManHEPkJtGiSyfU01Wcj2aZPo1oOOQCscS4DgLdOwBPC5pKjZF8ejL7ZhSPlv37CAvgYuT0AywcaZyzgJVP/pa+b9ReAAtEmo1LKqvtXxXx9caOjjgCMkwW4XnUUa9zWvgPXC9PUCPDq5jbp4Aj3LT0HQl5WzcA3BcCzZGgWMJdBg8zgEdfVUtXeARq1nadujV/JUlHUnJc9jD4QlUPP1nf9SZEKqJvPwvhFEArxiKGoKNwRk3D15u11Yg51LZ/L1gO3ZTsDJ5uznfpDRyHZZSdvokhXyOJX05DBwGj91BxyXgMbeMNrZBp0qFMIkaVimEgi0pk+UOggtZ3f62BCEoCvF18T8CFKxBnOjKhNeu/IzB3JgOu6MgPXi5PG72Gti8BkfeA5xf5G+ow2+5lrmJ6rgRKDcSTUOwSxqBHRY4LlU5rHjkA8cWoEMltDEHIeYayCkj/z/zvbcW3VVojogGl33oG28XxYSP8XfXrNpQBcnQsDrBVewEqnknVJIoGbSeyTA0ZC/2QOkDD0EHd1Ycgkpqw37xlxxayXZUtmOsAdd3vFmIlSpPhJRRG0dCKlOgY8+wcTPgcKhlu9CxBDymNgzraxQmp4gz1LwV7ha4+48NwoZM4QHipXLoCkfwmLrDZ7iZ/obHB0SIswInwMpYzghARNUMv4+zVgUNj6UE167H4Hf5uUrnMJ4b2Gj7aXQoGUOgMRU+Yq4Mz8NaaY35n7MPapvX4Ah7gGuKm5oaNvrGSawxX8XAYfjYRXLpmtCxBDjGupMCC1RqDCkGOHapFDh6JZzWzbDU3pufCTxKiVpT+aFwDtAwdqgIgPgbPvI6YyvxGB6K4RKcPNAR2x6XnIyXXiu5KiV7nf99IWgMgUe9viqT1ZrYvAbeA+vtAc6ivtEScxqAGTgahWNNleOMqscegGMqdExtiT5UsVKHVpQ8ihPGaauigo8kXKonBc5eeRt8r03QqnpRcBfP5MYCIFWTLJwv4QyFX1R6y/fiIcLzAD+aXdICTdVkK5b18vooheNjeW0hntulRGhN+sqMp9jQBF/+B1enGC4MF+vuAW5i4s1O7LcxBziWQMeewymjIZVrhFXODB97gI4x8JjajXQINhRGUchBCofapOMocfTckQMOpWojtO0GKOIcg7FS0WhSOur+HzHX4Rd+/IUCIAIHJYN15aHwc4BErcR5fUPx3D7AGKv4mQMfPC+vmcoYFBqpQl19Tc5UhmzzGqyxB2ikx1mhm5m+WSoGjguA41bQcQb42DpwDEHHEHD0Ob+uPhw4fHUXRUkAIjSYDIlSY5/l6Nu+FK+82oYjVN/eF5aon/uJPiChLBe44HlRBOSgAQheYxfYKDGTn/N6lABatznuAow5k3rnQkiX0qHuhwAIH/lfVSFkJ2wn7D0wfQ8QMlVYNTbli4mjU9ubb0nhuJVf3yxwnAE+9gYdQ/NWorOLVSlywlIEuPPmYiWZE0fdOusAAYIKoKMoG8GJy5GW6o+JuQ9Djcd4fB6LvArUFakvgEd87j6Y4XEUCkJBIA9FuRlrgsYc8OgCLl6nDs2Yy+LyWQOHgWPaHujqpROrvlBjCdWiGnJtxRuOvsTRueWxhweOrUCH4eP2wFFDRx9sFMfbDDHCsXH3jFpwBzbu3ytOuVRtNImitSJwBwru3yuPIRWjLyQxpdupHG98LQrRABu8LlQWDpSYyDr23HUVDv83fz+Uqb62zYGO+v/XYalqHTcGM3wYPpbvAa6ncq6F8CsfSz5ZOBfGWpzfGjhu6b9nA8cWoOPIALJllaNL1YgOl4sKwh8Ld8j59ykURR0g6fL1F3onME51wlHh4HUhiUoOVTOutjNnRx+Mseedmo9RJ4+N2aXg0Rda4f8msZX/HVWn7lVi8xp4DyzbA7GkX+X6nDV8X+GXoi6i0C5sc27g2AB0HBVAtgYdXfkaCqEoH0BzQGIYYkmSZPm7Z7/SmYQ11wnrNSJzAhzxDqQruXOJMjEHJi6xOaAl4KD7qtosK4SCokNyrJ2LAcN7YHgPUCk3d41UHUaYFYWDXK/6bIxKRxZw3No/T1I4tq5yHB1AsoFD0IEjK8pEU4lRKjaaZMmYXNnVAOdWDrzLCd9RVZq7jlqFmQIac17H3EqfNQGk/p8BLMI96hGiKiBVrcROpOVO7Y//9E77dZvX4Mx7YEqeE9cQoUkUU/LTSDhXVV3p09GcOVI4MM5MrlcDx4GA42gQkqlydCkcMVxRmm6NdNub6riX9gQZSmiNeRbK31B/jbFytkte59KGapfAR1R24v+pmQ/ABkmyqlpRu3bUEE3u5c7MeR35Ds+WtwZcJ5wZaponEKcvkH6H60aVK3XVCucioUtV3cVk8r5cjqEze8/qxkUKx96hY+8Qcmvo6MvhKI686aNRl4Xd2nFPAY/oiMvrbQ4JlIApr3VNwLgGgPSFlOpcFjU+w2KJn+7KkJHVxt3mNTjzHmjPiFdeLeoFOVCxfxC/U7oKdwxu41pTl2IUDc6ZOpxSKxwGjpNAx15B5FbA0QUdODGVt3I3LLKPBJ/hvIecsZJSlbw1Bka3hIxbgAfqhapV2i6q4Y5M7eE1WC/Cic1rcLY9gJoRm/6p+gToIIyir3sHt3352VIFxpmDUqLzUuGUDODI8qeTFY6zAsfeQOQa0NHn/KLKoTkhMaSyJQf+xOt+9iu9oJENGGvBx5SE0ljFUrebl9rBWnF35sFv+c7Pdps10OiEroZ+S0YTSFUtHY6bx9FZMxU2Tgccho59gcha4FE7vNqJ6UJS2aUkw+jMsx310ewa0KEwE7ChKbvqBmvzGpxtDxBSVLixLuOfmwyvfDfBSxds3Ao4Mm/UVwGOMysdU20v8DEGHFw8XHBKQqRpFhdQ3feCjzVozK2M2YNtHTqGSoX7GoNx2FIy6x4d+U7PlrcG5DFpUKTyM4aq2aZci315G1NgYw3gyPaDqwGHoWM/MDIXPGp1Yyz34ciAsUVAuTSRlMNVc2tUvaPZK3Z4dvpn2ANUkqDmUXHC19xIqRwWtY/qlHhjdWlCfAZs7BI4rHIcTxWZAh5ybDHLus8BZjv1Pdo1waOvR4dCKORpcBdHsyINstMd3tQDm8z9bKdh8xos3QPARtuWvCkfBzQIqfBznXuXJpn3Xf9nUDdWBw6rHPuHkCngsRXIuKQ6Z+3E2q2AyBTg0Ph6VAzu3DSwjoOVxFD6b9TNwIbUDhob8ff0GvC4ezv9vYAf3Xbj1/SliR2IZeztKbCxBDTmnGOnBQ5Dx3kgJNMB3xIo9gIhS8GjqyGYGqBRqhcrUAAOxa75nDu9T7z3pfI1UMFsCEqgkZ3L1N7n3yjQQodSlRE6HJPvUG3ja6B+GqWPRuiUPDRPaW5V21pn3N5h42rAYaUj/429dehljiPOBoWjQMgc6IjqBpABIGiMNrCB0gFARIUCmVlAoYM5DqgCLGLDMI28d2WLnf3WYad0/WwmVteqxtjwxjVUjVsCR7YvWgU4DB35b142hGQ7963bVuAjQkec7Ku5DsrnKMPt3v+5Ag/IzagYKB2aA6HmYHUDpHba7kuvdYZjMCXj2bwGWXsAhY7wSITokrNRQccawLEV2DBwbMDx2gwgZ4SPKb05BB+EUDicUT3isLu6dXMsp+Ug/+q7L5bD/bm3v17mUJArwuc8jjqX2rwGt9oD7L2hnwPW2ufs1dJ5F3juGEK5JnDMPT+OABsXKxxWOfLfQCsg+UrGVuFjSpilr2RWigWHsJofxS6LtbUD/JrJwYRpyPTXnaQqXzCqYTS+2+Y12MIeIE8J6CCfqYQJG+WOn7Gn+5JGb6FuLAWObH+SAhzO59ivOQRzDPhYUkI71qa5y2LH0jtt0kOrdFQQTHefrmrJd7a2u2E/cpMAbeU1sV+pYIn9hy4BjrPCxirAYejIfxP3CB/OAbkteMzt3TEUehmCjxo6IngosRTVgztJpGzi6hzu5H4YPuz8l8JPTGZew5QwzeeEF9UMD/AYgw4DxwaAw0pHPjRsHUAMITngsbQ9ehd0dA6Ee/ByKacljEJo5TMPP13uIn1nbcBYYw8AAWuDCwnUmg/VwnLI6xgKrVjduDJwGDryAeCI8HF2CNmq2jGUcFrnc2DK36B6Ze07Uds514BqKj7G3KC1jDAKyaSlgiVUr4z15ZgCHdcOp2T7gs0Bh5WO/Dd9z/BxRgDZA3TEZmJABiETcjT4yCHOIDiUjUscwTPfeyvd0dm2oWYoIRlTl9w1DBWOEB9Jz6gc5HXU6sZQuewQcJwdNlYFDkNH/pt5Rvg4E4TcCjqmJJX2zWfhrpDDWoOvyM3Q9Nk1HAIdIeV0GLhlADg2BAGp9fsMbGjmieaekOC5duWTQoCE/0qlVujTURroNeFC7fl6QvYlwHFE2FgdOAwd+W/oVu2W8HF0CMmEjjHgKCrH/XutssGclXJo//GfFlhYwxFwR8tjurfHOUxqRptT0YQ5YtM5VUCt+bxADOBcP69AQ1UsdW6HgWOjwOHwSj4InAU+jgghGdAxFFJRWIU8DVWkqCqFhkoAxyUzVpT/AcxIThfY0GSMj24utl8jsZiqpRg+k7ohVaNuPifnj8JAZ1zldgClmPZFfA6Mz3ku9o9+h7wNfgYko27wmDz/x995s/ye+tHwWoAbqRuxR0edz7FE4TiqunEV4DB05L+pe7JM+DgKhNwSOvoSRjv7cDQHMneB7cj7ygHMNfVF0AwX9fcAPmIIR0apLfkj2c7U1q0gkCNBCepzv/5ecfJqEifg+PzDT7a/z54aajwn1UMty9kTAlxAQnuHn7MvtH/4GsigSy4QIrgRLGs4IX8PxKjzbt2NdAg4DBsbAg4rHfmOfwuWDR57h5BrAUfdFl2g0XXo1+WwHNaX9tdAtcAhcAfKR1UPRMMJxIm3SlDlzlSqB3+rWL/zP7YFQV1zTfgIKAg0+hrOdZVkx1ALjwF08r6zB2Krfj1PnA/0xKygptKK/SVIAUDUxC5Ws+h1CjrmAMeRlY2rAsdS6DB45G+IrVg2cOwVQq6tcgAa3JGqcZdi59z9tQ4jdBfloOZ3+RvaR/c5HBwBDZaAA6CA8ImSAJG0Ywy/1zk0z99OAg1TbFXJMFQpc0m4x/bkGqih25S1AQBL74tqamtXZchQAzoBB8/NntKMFB5bpa6EXsoAt+rxO8Hlwcvl79jHgp848LBAdfMYSppeAhxngI2rAoehI//NPYJlQ8ZeIeRawCGFoxy6goAmS5+DnINduRuSpJGpObRx6GT8I5Pz+3wPwFAJon4fZ4CTUKtp/p6/wSmRB1I7pC5pPd6d8hgaKjdmPBeOyl1Pp0FUTAYWIPIeA5lK4hxr8sZ68/7GHI0h0BjrB8N7p7BI3BdAZz2QbUoDO/aQlAxeX3xdXa+lPNeM/I2zwMZmgcNKR/7G2KJlw8UeIWQt6OjK4aglbKAB09RN5W6oRBbj0Od31Dqau8Y7ykhT4YKhckgd6XJEXQ6ijufHCgZgAvWkT+XgZ3p9fI3iotyTqcByZCP/pk+xKM68yXeIuTXKnwECukJZgkr9rcIUarTVBxlr22ATO/ZRKL/tytlYmix6Jti4OnAYOvLf4CNaNlDsEUIuhY6+pNEoYytvA0eD08YJcfcLVAgwoipSlxp2Gc5oagw/wgd/o/kXOEpeg8IqsYEYP6OvBz9HaYnJqCguPAavHTWmy8l2NZ2qnfLP/Ohr6bAwZDGUBBhEQJSpMkNhMYFCLH0GKJ7Ir2nyH5TUy2Pw+6y51kW5N7wXbQnq/Xu9oDHWin+KjcEHeygCtDrlFrVj5iwVw8YNgcPQke+gj2zZILE3CFkbOKLsrHH23AWicOBkcC6oFBzcfJ9Du23c1ABHX8x+Suy+TxpXPF1VCQIG3WHTBRVHJxCKvRZkqpBAqdHfEEKQo1UICMWEcJK+z//L/6qveQ59zmOpLFPOVooQoILTRRVYktTK6wAEgAYei8fEsatRmv4PXo9erwbo6TH4G94rvUZUHn6H/0eqRbsW3/pu+TnPpbyZOsfhjtp0/155Loz/j1AL1SlADK+Jv4/KxiWQsaSTbr2/VeathFaBbwQOw8bBgMMhlnynvgfLhoe9Qcgc6IgHch1KwQHhNHFUKk9tFYpXXi2OXneGioXjcHB+n3r4uceqQoeCMRSznyOLC4ZU1qh8A5y6wifcmfOzusdD6yzv32t7M+hu/s6d/IOXy/+kcFDsgskaxJ4kqm5Q0q2SGq+lXgAfvDaeS+BXHHvTU0JWJ/zyvQIZVZIu/y+AwePRPZb3j8cDlLqAUe8B66ZQVkzkVb6O1ov90jUcbS5YzLE+8Gj3EWGi11943IsjwNRQk68+ZWPJmZB9tu4OOAwd+W/0mSwbGvYGIGPQEQ9h3fXhLErWf6NQdDkanJUceZ1fgZPhICe8oVkVQ3MqlsTjeSyeS3kkvB5et5yqwj7xf4j/R1fJ75DdSVZtQEThATlZNSuLJby3MkIYhE8EHC0YBWVH/0P8v7oSc1WKPLWSpAaQ9r14/YUnEjmHQGMNwJjT5C7Ca1mXZt30Og0bGwUOQ0e+Iz6bZcPC3iBkCDo4YMud8OsvlANYzqnE54PM3Kc41MqDDm/uots77Ndf6J3EuSQuX6sxdaimLwl1qHqhBpEuIKmrZAQc15hsujRfgzBL7H0xBhpduTJj1SNDylOs/Bh6v68JGGPQodfEawYQWZP4f9Slr2uqGkdTNlKAYy3ocBVL/sbZm2VDwp4ApA88+u4Ap4LBEyDQSNWCGEnpayQFTk0QnAJJU8onOxufNSoAeQoZisYU43VJaRjqgTEXMqascxdkjAHBJX1m5paBx73K+7hkOJth4yDAYejId+J7tGw42BuAzDmk58bHSyXJQjWjyzldA0KmQEddRhnVDRyVKmFiwmm21b0xUGDKDJye8uM5oHHJe3hNwJgLIdon9Wu9RgXK0ZUNDJ99c+AwdOS/8bZzgMeaALLkLnFrdgmATFU6AA0qMNSOPVakbNWkvqB0UDEyt/HWpaCYDRlzbeg6ufRaPeLZHEWCFOBYGzqsduRvqj1bNhDsDUBudYe5RfgYAg6pG+QmUC6KE6dChCqdbKgYMnJLSlvxJkenVjn6YGMtwNgqZMy5Jta4LrPPwWtY7afTgMPQkb8ZbOcGjzUAJMMRZMNHV+VFX/OzLSSKTjF1BC2dX0PVihKClyTzbhU01gw5rnUNHu0s7hMGDgUcVjvyN9oRLBsAsm1P8LEWgMzJ+eiquImlsIQoqL4hWTQbJKYaOSaEgtQoTD1CuhpxbRU01gSJW4HGmWAjHTgMHfmbw2b4sPoxX+2IlTYYDbbUCp3kS5qakcex9bbmNEIDLNR2HkWGMBDqDI29yv9XlT1fAhprwMYtweKaoHE02JgiBqQDxzWhw7kd+ZvwKJatOmzBLj2s96J8TIGOGFIpjazu3yvhCJQN4EPdREkcRfHYUoVKG0bRXJSmGdidRmChlfcUheNaoJEJF9eCjLPCxmaA49rQYfDI35BHsWynvxVb4yDfMnzMgQ6AQwPqcOClu2rTWRTFQLNMtmaU69KundeNolHasg8kjS5tzrU3wDBorA8amwMOQ0e+M7UZPo4OIGsDRz23pR1n3qgEceIs6gehlmzQ6DKUGA26A5KGgKOrN8UlsHEGyDiaqrH0xn9TwHEL6LDakb9Zj2bZasOWbM3DPxs85gBHXRpbKjyakezkdGQDxZhpMi4qh8pj646jXSrHJbBxFsgwbGwYOG4FHQaPfEd9NMt29luzaziHW4LHnGqVWLFCzgYhCkCDcfHZMFEnifL6SAwld4PPgQwluJJrgtpR5qxooNsrr5afqxX9GHDsATRufS1kn01r2Bo+99TAYejI38RHtWxnvzW7luO4JnSMwUZf4y+FUtSDA2cNeDzz5jvlaz6ntTjOnkqQW8EGuSSxGgUoKiPom2RRwIKfkYOiibL8Tfk8qB0CjrmwcTbIMGjsBDhuDR0Gj3wHfVTLdvRbtGs6lbXBows2yrRcoCI44TtD2+7fe/TZF98tVSAkk6IY4Nwx+lt8/J03i6rA1/zONQe78dwAD8pGq1w0k3n1ehUC4rX91k/9ZQGMElZhFkyVyzGkbmwRNDL3efbZs4at7Wc3CxwZ0GHwyN/gR7VsJ79Fu4XDWQM8cK4xOVR9K5QMWqo7SBJtDGfN7/B9HHxx3io5VdlpSCpFbdDYeEIdOH1VkUR4+MzDT/eCBeEb4AZ4AS6ABx5LSoXKdPkapSWOoRcsAUBKbuXzGjiG8je2BBvZ+/oIsHEt/7pp4MiCDoNH/oY/smUfhluzWzmipdBRT7hV6++2KuXBy22IgrkpOG2cPU6ej12Kgh6Hv0ENUbMt4AD4AA74ex4XeJmaC8Lzo2QINFrAIR+jeR1do+f1vfLaXnm1PIam3WqgG/9/VznsFkAjew8bNA4CHJnQYfDId85HtuzDcYu2NfjoC610DXGLThtnDShgCr/IusIwQEqtkgANah72zPfemgQcwMFzb3+9qB0lyZPHap6ja+z8mGkCLsbXW4KN7L16JNi4lT/dBXBkQ4fBI/+COLJlH5RbtFveHS8Bj6FEUikXQINKTLtUhTrhNJbUAikKcSjEMtXo9dFOf23UjaEJsF0j5+v/C6WDz8fCKGeDDIPGQYFjC9Bh8Mh3zke37MPzzOAxBh9TKlhw5jh3cij6nPvYePvYOAyVgscq6sU33u5tkU7IhYoSFJVf/vn32yFsgAfdRPlI2OaScfNZ+RrZe/CIsJHlP3cFHFuBDoNH/gVzdMs+SLdmWwWPLgAZUgvqcExMRlXPC5WlAgmER1Q9oiRVfl4nlKqfRpu30cCKElq7qk2WTH69Zdlr9p4zaBg4NgUdBo98x3wGyz5ct2RbAY+5XUrH4AMYACziqHvgAZBQ4icwQqUKVSSABAoG/TxUdku30JigykflkEhxmQobWT02svfXUWEj20/uVuHYmtJh8Mi/mM5i2Qftlmyv4DEEIfUEWlQNTW2N/TIEH4ROPvHel8rvFWWjqp5R3sgcZWNJm3KDRv7ZsHXQ2D1wbBE6sOwNZjvHGmQ7/K3YHqBjDDyGql8iNGgwHKqGymZ/+gs/Ln03KJ/F6vCJbGzi6yVzUY6sZmDZ1/oSy/aDhwSOrUKHwSP/gjuLZR/GW7EjgMdQjkedWEq30jiJtm0u1gEcU5JEbwkb2XvlqLCR7fNOARxbhg7DR/5FeCbLPpyz7ajQMVTNQv4GYZXSjryn38YYcCyFjbnAkb0/DBr5vvAQwLEH6MCyHZLtPGuQfWCfATyuAR1D02g7y2ebfh2x50bM4RgCjqEhbGvCRvZ+MGjk+77DAcdeoMPgke+Mz2TZB/hZoWMMPJYAR+xkShiFkIq6iWIkkRJiUWlsVD3GcjmuEUrJ3gMGjXxfd2jg2Bt4GD7yHfKZLPtQPyp4rAkdfZUrd6pUXn+htDovnUy/+LEnWqULOvh5KattVA/9ntqdR/hYs+dG9nt+RNDI9lMGjgNBh8Ej/4I+m2Uf9EcCj7HQw9JeHbFSpeRmvPRamZECUNBXQy3T62qUOvyC2qFeHWoUJvWDj5oEe2lH0ez32KCxfTukwrFX6DB85DviM1r24X8G6OgCjzk9OVAlNMFVDcLq3hpdCadF0SC8InUjqB1STLoagvUBx95hI/tam2LZ/sfAcULowLI3vu1ca5DtDPYMHpcAx9gE2hIqeeXVolB0NfFaYvF5AA/mrKCg6Dm7gMOgYci41KcdWuE4AnQYPvId8RktGwrOAB0CjnqSbBtC0QTZV14tTb1KbsaXn70DHHNnodSqCo/D4/OcdS7HEGxkv39TLPsammLZfuXWdgrgOAp0GD7yD4gzWrbj2At4TAGOCB1y+hpprwmxwAUTXgmdABnkWdS5GnM7hQ7lkPAaFFapYWOPqkb29TJm2f7DwGHoMHhs4CCwnRNAsoFDSgNOPyZ2YqX0dWDo2tRy1jHwEGwYNAwZVjisdlj1MBBsEoiyYWFr4DEVOOqwRpsc2gxr46PKXJW70VW+OhVyptrekkKz97+VDIdUThdiccgl/3CxHQc+rgkcXeGMtpqkqSDp6g56LdAY+3+y3wtDxrHsNDkcZ4MOmR2hYcDqx+3AYwlwtHkcDXCMdQK9FlxsGTS2fI5ln/F7slMDx1mgI1r2xWk75xpkO6xLbC3gGCqHlcLRVYEyFzYuUWiy19qQke8jDBwGD8PHBpym7dwAcgl09FWHPNG6/MHLszp/rpXwmr22si1fY9nO+gh2eoXjzGpHtOyL2XbuNTgaeAzBBpBREkSbLp98jGqHOoHydZe6sRZkbAU0svfekGWfy0czA8fJQyx9ln2h27wGewePuiolKhp9A9PqcMs1QCN73bZ8bWefu0c3A4dzOwwfGzjobONrsEf4EHAAGm2I5IO/M2lI2lHUjK3v7WwnfCYzcDihdPamyT4gbF6DrUPI3ITSo+Rl7OHazHa6ZzYDh6tYLt5E2QeIzWtQ74E9AMjaZrgwYGQDhYHD5bM33VB2/nb+W94D2eBxCZBkvr7s980KRj4sGDjcsyN9AxlC8g9Y27FhxFAx/L5ln282KxxuFrbhC8EO0pCw5z2QDQZHVyjGLPv8si1fA+dwuENp+gWUfYDZvAbeA9vcA9lnk23dNTBwuDX6Ji+q7IPO5jXwHjBgZJ+DRzMDh+expG9CQ4idu537+fZA9nliM3CkD3PzILh9XYjZh6bNa+A9YLjIPgf3YlY4DB3pm9AQYqdtp32MPZB97du2vQYGDqsd6ZvQIJLvKGxeA8NF/hl1dDNwOMTy6OxmZ2tn6z1g1SL7HDqDGTic25G+CbdsdkSGkTPsgezrzHaONTBwOKk0fRPu1bKdhM1rYKjIPwdsBo70ihWPvfeFaCAwEFil8DlgILHCYfDwQbCJg8BQYihxyCP/OrTdZg0cUrHi4YttBweOweSYYJK9r2xeg1vuAQOHwyw+dA566GQ707NZ9vtt8xpsfQ8YODZk2ZvB5jU4I7h433vfew8YONIBwODhg8gHkfeA94D3gPeAFY50MLDi4YPIB5H3gPeA94D3gEMq6cDgMIsPIh9E3gPeA94D3gPO4diVecP60PIe8B7wHvAe2PMecNLoBmDC4JF/Idi8Bt4D3gPeAwaOdCe/RfOF4cPRe8B7wHvAe2BPe8AKxwbgweCRfyHYvAbeA94D3gMfueoa/H/S76BhFZ10YwAAAABJRU5ErkJggg==", + "ImageFormat": "PNG", + "ImageWidth": 540, + "ImageHeight": 270, + "HtmlContent": "\u003Cdiv class=\u0022fry-img-card\u0022 style=\u0022margin:8px 0;max-width:840px;\u0022\u003E\n \u003Cimg src=\u0022data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhwAAAEOCAYAAADCCM5xAABPXUlEQVR4nO2dbctt13We/Qf6oaDITqvg1ghhaociEQSSQ4IOCQ5HODhwhI0DEi4YXAg4YOOAP6QQELQQsKGBQA0tLTK0EGJICSS0EBJaAqYl2DStiwnCrWiMIvn8hFOueda9Mp551vtee4/1cn8YPO9772fuuea41j3ePvDbP/zoI9v\u002B1\u002BB3f/ARm9fAe8B7wHvAe2Cze\u002BAD2Y7SZtDIvghsXgPvAe8B7wEDh4HEioYPAjsD7wHvAe\u002BBA\u002BwBKxw7U1myN4zNa\u002BA94D3gPeA9YODYABAYNHwh\u002BjD2HvAe8B7wHrDCkQ4OVjR8EPkg8h7wHvAeOOMecEhlA1Dh8En\u002BhWDzGngPeA94Dxg40h2/QcMHkQ8i7wHvAe8B7wErHOmAYEXDB9GRD6J/\u002Bz9\u002B6tCWvb42r8FZ9oBDKgaN9E1ou84aZDvys5n3sa9l7wEDR7py4TwNH0SGh3wg2KLZQflsONMesMJh0EjfhGe2bIdn28caZO9Tm9dgjT1g4LCi4cPEMJHuUG2GEkPN8c9iA4fDJ\u002BmbcK9mJ2lQ2NMeyL5ebF4DA4fzNHwQGCrSnaHNMGIgOf5ZbOBwQuijs5udnR2\u002B94CVkexz6Axm4HD1yWnMTsVg4T3gEE32OXRmM3C4zPVwZqdisPAecJ5I9jlkM3C4p8bBLgQ7FsOF98D290D2OWHbxhpY4XDzrvRNaLjIdwg2r4EhJP\u002BMO7oZONwpdJNmB2gH6D1wvj2Qfe7YDBybNG9Mw0X24WzzGhx9D/icPRYEWeEwbFi92MDBatvPGvzef/vJ3Vn2mhlC8p39FszAYVXj6pss\u002B4CyeQ2OChKGEqsg2RBh4HAIJW1D2bnbuW95D2Q79T77w//y9KgZRqa9x9lO1WaFw7kahox0Z2c7L1hMAYpLzYqIAWTrsOOQihNDrWIYBnYLQ9kgMQcw/uQ/PTXJ9gogWPZ\u002BmGLZTvfMZuBwFcroJsk\u002BIGxegy3DxZCCAUD8\u002BR889eiv/uUHH/3Fv3\u002Bq2J9/8Hcef/yDx3YL\u002BMgMyWz9\u002Bs12wmcyA4fLXQ0ZGzj0bE\u002BuQTZELIWMWtEAKgCM//mvf6KAx//553\u002B/fB6hQyYo4edYBJK9g8ceACTbIR/dDBzurVE2QvaFbjv3GmQ7wTVBow6f1EABdPzNl5999PD1Fx799W9\u002BuHwUYOhnfF/AUSshR1A9DCD5zt/A4UZeN90E2U7Gdt41yHZy1wKNvlyNPhVDisf7X/xYsYcPXr6jgIyFXo4EHlj2vhyybGd9BDu9wpH9Btzasi9a2znXINuR3QI0\u002BmCjho46bAJ0SOkANvi6CzjG8j3WgJDstTZ85PsIA4fbkxswNuA0beeBjLmgMQU4hqAD0AA4CKcIOC6BjktBJHvtDR/HtNMqHNkLf02zYzQcWMW4LWhcAhxR4QA4pHLIuhJMl0LHXAjJBg3DR74/MXAYNgwZhopNgGW281nT1siFmOLk\u002B/I4SiLp/XuPcznu3ytf8/n//Uf/tE0ivRZ4jAFI9ntj\u002BDiGnUrhyF7stS3b2djOuQbZTmaLoDEVNmqVo87jAC7\u002Bzt/9YGt/89Jr5ftdYZausto\u002BCOn6vbpyZqwSJvt9Mnzk\u002BxwDxwlhI9vh2M61BtmOZA\u002BwcQlwlPyNBy8/tvv3Hv3F3/sXj372Vx6WBmF/9exXHv31828U6OD3ZBE6huBjDDT0GgQ1U0pws9\u002BzPYMHlu0/DBxWNgwZGzgIbOeAjLVBY244pXb4OPsIFHxOKIWQSgmrvPJqgRDgI\u002BZ41GrHFAiJP5OywuN3hWyGQi3Z798RwOOM8HH4kEr2Al9i2ReD7VxrkO0gzgQbfeWxsggfgg7lckTgWGrxeYCb33vqP5bwjZ6zKzSzR7VjL/CR7WsMHCeGjezNbzvXGmQ7gz2DxhrAEaEjAoGgoySTPnj50b976j\u002BXj4RagIQ\u002B8IjgUqpf7t9rVRRVwkhFUbfTOkdkbqOx7Pf2KOCBZfufa9phFY7shTVk5F\u002B4NkPGFmBjLIdiDDraEMtLrz36J//4/5XQCtAhWIi5HV3qCMABrMSEVPJCeEw\u002BzulsegTo2At4HBE\u002BDgkc2Ys6x7I3tO08a5B9yB8NNKYAx5SkzSnQ0bY\u002Bf/2FYs98760SZinQ0KgXKqMtuR4vvVZ\u002B/rtP/9c7qkZROR68/Pj3Bvp8XDLFNvs9N3jk\u002B7VTAEf2Yhoy8p2qzZBxK9BYGzZq4OjK55Da8Sc/8W8el8w\u002B/0Zb4fLLP/9\u002BCxsljNKoGAq/xIZiQ51MLwUOg8d1z6Fs/2Xg2Als2BkaCKxmHB825pSkzgGOO0mkUjvu3yuhEtSMAh6NmiFFo6trabShctqlTcIMHrc557L92WkVjuxFNGgYJLYAk9ny9FlA41qwMVax0gUc5GZ84r0v3VEzImwMAceU/h1rQYdDLQaPQwBHNkxYzch3tGe2bCe/FTsqbHSWyH7xY48\u002B/s6bJXRCCIXwSlE47t8rKsdc4LgEOpasX/ZemWPZ1/ccy/Z5h1c4shevy7I3ne0ca5B9EG/FjgAaQ8qG8jbaipPXX3j01Le/8\u002Bh/ffifFdj46S/8\u002BNEv/tLD0hgM6wKOodyNLOjYE3hkX\u002BtLLNsPHg44shfOoJF/UZ3Nsg/es4LGJbAxBTL6em/81k/9ZTvM7U6VClUmr7xakkQJp/B7JVG0I4cjdjEdA46l\u002BRxrrG/2fjJ4GDh2ARvZTsh2/DXIPmi3ZHsFjbGOn6W7Z9PQS6WvQEQpa21CJUDGZx5\u002B\u002BtGvffSHReH47Z/870XtUI\u002BNzz/85OPfff2F9nH\u002B8Kn/UL7HYw8NgZsCHteGDoPH9c6QbD\u002B5W4Uje8EMGvkO\u002BAyW7di3ZlsBjT7YmAIXQzka6gIKVAAJJIECEc\u002B9/fUCC6gYytfg5z/zqw/vTJUFTIAQlcSqLJbv1WGWqdCxZm\u002BOo8FH9vmw1LL95q6AI3uxsOwNYzv2GmQfpFuzrYJGn4KhJl1SFOqkzanVJ5ocCzTwWEDFh77x9qOn/vhP74CG7Olvfr8oHsAIoRY\u002Bx4AWJs/ykXkpU5NIh\u002BDjVsBh8DgeeOwGOAwa\u002Bc7QZsg4ImiMlX5G2Bjrm6FR8\u002BqNUapHmhbkdSJnDRtd/TVQNFA3uiCjzz718HNP9Oaoy2Vr\u002BBlTZFBO\u002BHxM7bj2e5UNwF2253PRwLEx2MjeELbjrkH2QblF2xJkdIHG2GTXdijag5eL2qAcCgFFTOYUYKhzqDqGKl9DIRFyNGhlPgU2PvSd3y9hGGauqAtpX3\u002BOKaZQD8bXU9SOW72H2XvV4HFAhcOgke8YbQaNM0FGV/hEkCGYiNUgfE1JKqrCP/zajwo8EL4g54KPMZHzTsVJo4R89sV3y\u002B/xOUrC03/2R23ZK48LtBAymQIcPD\u002BAAmy0\u002BRzN69Xr6FJbIiwBO5pMy2MAMfyf/P9TQyy3ek\u002B3Bh97PytPHVKxopG/AW3rrEH2QbhF2xpk9CWF4lxjcicAAAwoiVOJmVGd4HeUxNk25ZLpd1E\u002BXnqtQAWQATAAHgqh1ImhVKj0gcZT3/puUTVIHqUvB3kfPJbAg9fK6\u002BHrZ958547iIqVFIRyqYPi8TjYd6lB667yOrcPH3s/N0wGHQyf5m85m0NgbaCwBjCnVJzGnoeRpNGGSrvAIP0O1QBkAAlS2igECdAnF\u002BfM1vwMkzMnRmGM892985AcFQvi8VT0qxYWvVQ1T2qQ3\u002BR9d1S1zK1gyoGML8HGE8/MUwOEcjfyNZrtsDbLvsLZmWwSMIdCYmiDaWWUSlAucPg6fkAjKgipK6J8BaPzCj79wNdiojaoVlA6AQiEg1AwpL8obUVku/wN/Uz4PcDU09O2WZbN7gI8jnKMGDieEpm9CmyEjAzLWAIwpsNHXJXRsfDzAAUjgzAlzTM29uJWhdPD6CLcQxlH/DqDoZ370tVJ22yohTbIoIMLPSxLq6y9cNNZ\u002BC\u002BCRAR9HOC8PqXC48iR/Y9nmrUG2erAl2zJcTAWNsbbkQ8ARS1tx0MqvyAaNMaNLKR\u002BBD5XyRlWjr2HY0vkrWwGPW8LHUc7VwwDHtWEj\u002B42yHWsNsp37lmzrgDEHNKbOQKk7hbZVK6FxV5zoSviCHhnZcNFlqBq8ToWCunp2zB1tPwU6tgQet4CP7DNrLds9cBg28jeRzZBxNMCYCxpzgUOD1kgOBSpw3uRAoBLIeZMHkQ0UXUYVDF1Ied2EgEq1zUCTsL6OpGtAx5ng40jn7C6Bw6pG/saxDa9BtoKwBdsTYCyBjCWwIeBAzQA6UDEYGa8yVJQD8iT6WpJnGhUyfCSXo\u002BRnxLLdZkrt1PkrU9Z1yXuYDR0Gj3XBIx04HD6xs98y7GQ7\u002BWzbE2BcAhlzYEPAUfIbnn\u002BjGJCh8Ak5G4ROlIiZDRZjSaTqK6LqFTUho4Km/H8Tp8xOXeNL3\u002BMjwUf2\u002BbambR44DBv5m8RmyFgLMjIAYw3IGAONoeqUtpV5U9GBOoCigdLx3K\u002B/lw4UU42wzz/43/\u002BqHfpGTw5ABPXm0gmz14CObBAxdCwDj0MBh52nAcJqhlWMa6kaQ9UpdypUmm6jqATqxbF1YxItHwkLta3QmzboXUPexqAjEzxuCSZWOuZBRxpwGDYMB1sBxOywxZ7CJNdWMtZQK64BGUND2\u002BqSWMITn3jvS8WBE55gvkk2UAyZmoCpHHZosuyccfZbB481wcThlWngkQIcVjXynezZLdvp7wkwhiDjVoCQCRk1aPQBhyarEpogf4OEURSDbKAYM7VWJ4yiQW1xuNvYKPup4HGNqpatgcil12r2uXgNSwUOw0b\u002BBjizZTv/vQBGH2is4eTj5NW5Mv2Q45rzt2OAMQQaNWx0hlWafA4SSSk/JS9iS5UqtFaPX6NukOzaN8Z\u002BDDqWAsjW4KPeT109R6bAh6GjGzx2CxzZjsu2rzXIhoC9AEafmtHl0OWQpzqZrgoP5QrIsS0Bj7XgYggwxpSN2PgrDkQDOEgeveaAtkvVDZSNGEqpO4wuBY\u002Bxde4ClFsCyBgox73K\u002B8j/3wceffBhpePuOXxT4DBo5DveM1k2COwFMMZAAyuKRBPjL9NSGwdF/L8umxwLQ6iyg0Ocao52fPvrLyxKTuwDixZsGhionWfXnXyXU\u002B1TM7pgowaOtqfFK69uIoGUnht8RHFpJ8eS5Nq85vh/xP\u002BvXoM58NH1/vOc5Lrw/Jc0Fbtm2E2vidcMmLEm8f/Q6zN0bBA4DBv5Dvgslg0Ee4CLOfkZ8QCWo\u002Bau\u002BJd//v07Mzdqp4xTKV03mwZSMeSgMejPfO\u002Btx105AxCscfcspyAo4vl4PeVuHgh48HI7Lr5rbkifejFkd0BDsBGAAwfP/0xDsAzFg54gavLVwoYafWlQ2wh4aC2k3tRrNqYKtX\u002Bv96KphKnf90sSUNfK8dFeqiE5QlKf6rKW2pF9lu4SOAwb\u002BW/00S0bDPYEGEOQ0RfLltWqAY4U50mSpMaby2nxNVBClYacL3e1VG4AIjTH4m9jR8sux7UkHFI7N\u002BCC56TRlcpAcXh8n/beAqMuiMDBkADK7/ERgJEiI7Dgf\u002BJ7pT24HHkDGoINjOdTF1L1ugCGrgUZNCTjtfFcPCfPL\u002BDQa2sVJgFSU9ZbRtcH9UMw9dkX3y2PR2Mw3j8ejzyVLjVE7wHr9pmHn27zRXh\u002BHoO1KDDYrBf7RaG1KQrXNUDjCdhoQn\u002BsnwCMjwIOQ8eBgCPbkdm2vQbZgLAHuJgDGn2x7a48jDa\u002B3Th1nAjOA0elceY4Gjk6vg98tA67ubOemzcwJceiKCzciTYDyXBkODsqSPj43NtfL86SOSL8TnG64U4f0wRVoEB/ExM/pVjgSJ958532\u002B/y//K/6WjNW9FixcoXXwJqxXjhyHDcwpNc5x3gdT3/z\u002BwUyeCweU4284v/B69HrxekDJHoM/ob3Sq\u002BR8l5\u002Bp8BHA1DtWnzru\u002BXnPJdAJiad3oE21vT\u002BvfJcGP8fiavkt1AZw2sqINTRaGzNSpg5eUZS86RqCIyjCjOWY9J3HZ5V6bg6cBg28t/ko1k2KOwFLuaCxhLgeGIse2h6haNCUdBgM7XPvnNHHWT8Lit3xB0KyJBJ/ub5eA04cV4Dn/N4hHHaMENTQcLPi9qi11ZNehWwROPx9JjRcMB1KOPW4ZMleR1SRLoUFyCRtWjnr3zz\u002B\u002B3PpPqgAN1Rfhr1p\u002BT5PP9G\u002BV0eg99nzbUuCi/xXrTgV\u002BV2XAIfcxOJBdLsYUzrIaWuDvsYOjYCHIaNfOd8JMuGhr3AxVLQGMrc7600CTH9eEBzp8wdtfInopPmdwQgpbNllPObsATGHTQf\u002Bd2\u002Bcs2\u002BfIuYtIkTUyiHO3VJ\u002B7XxM70\u002B3d0r0fOr776YDgbZBhDUMBXfV82VieEkdTAFZACSWrkRuOhveZ\u002BKKhWUjkvyeS4Jx8W9zf/DXmCPRAWmT1m5Vt\u002BO7DP4kMCRvSi27axBNjzsCS4uAY0pZYIxtl2cf1AruEtFpsdZK19BIQccPQoBd9LI6IQq\u002BH2\u002BB5jwuf5OTgdIkTPi7/kbnBWKSV8yY4SNCBxzgIHn4g4cB5nt4PdghJiUFwNQlO99\u002BzsFFHkf9fXQY7DevL8KW8xtPFbDAu\u002BdwnZxXxQo6uieOlTurHChKmp4ffF1db2W8lyGjtsCh2Ej31nv2bJBYk9wcSlozAEODlQUByU9Sj1AzagTD3VXKJVCcnyf00FRAEC4i0TdkAP7\u002BDtv/i3cVEmMdxIam\u002BdXPkGsDlHoo0/dqEMLtsvXAGjsU0RqAyZLe/WBXiBTeqMIEnhu9pSScnnsUlV1/14B4xLe68kZqmGDv2Mfo8bVOSr8XI\u002BhvKGuktmx6/YsSsdVgMOqRv4bu0fLBoq9wcWtYKNL4Yghld7GVxzGzej2SxyX2oRz8PPxiTyBpkQzlpoKLIAd/l5QA8x0Sfu23DWIPUGU18NHOfkpOTxPhEGacBqPQWhHVUbluQK4DuURxTwU9pfCRao2kvKhx9DrnAscZ4GOTQBH9iLYzg0Z2aCQCRpz1I2upNGuu8uYQMphrFwO5QFc4ph4rLaqJOQKKCmxvqMGdpRHYNvWGgB/hM1Qv6hWAQiAAfaKEntVKYTFfi59oTTBZwmnPP9Gq1oBHdo7/Jx9EZODARHCboTUFI5RiA9TWTVKmYClHnDXlUg6BzjOAB2rA4dhI/9N3boZLrYDGnNgo6\u002BbZ4QODuEykyP0nuDARmUg1n9J2ILHlWohR8DnEWSkZtj2twZAgUp0BRxSogQBda8Uga1yiH7t/Z\u002B7U0FUAy7PoZJfnov9o99BQeNn6rnCY/L8hPRKD46md4kqdgTUU4DD0LEB4Mh2fLZjQ0a28rBlRWMJbPSVEz7RaKupNIklkzG58FLDkfCY3HEaMI5vKquNvVLqjq7KJVpbgSH/o35eQQ4/Zw9GpWOoGdica/yoSseqwGHYyH9Dt2SGi\u002B2CxhLYmAIcqlhRKaxCHHSlXMMJAC1qZOU8jOMbuTj1\u002B9w2jmtM5apKMl7LeLwCy9/\u002BzmN1IySMqteLVA72/JLhbgYOw0a6o96zWbnYtqIxBhtzmyXFkEpM1kPhoBullA7yKIYqRKZYbNhlO\u002BcaCDalemBdzdcugQxgWaXa5Jho1k9XxUzdkGxuq/OzQcdqCofDKPlv5lEhIzuEcSTQuAZsdDZLCuWxShilU6PyMGxeg0v2gHI1rjGBlzCJSmgVRhkaLtjVcbTv2js7dKwCHIaN/DfyKJCR7eSPChlDsDG3DfTUhkkx3k0yniRqlI6xZlA2r8Fc1WMNExCXniDK3WhyROpcjbmwcQvg2Dp03Aw4sv9R2/YAI9u5nx005qgaYw2X\u002Bkpj6xbjcUS7BoepQZS7exoyLgWFtUyt9/mchnSaywPczJ2lYuhYETgMG8eFGQPG/kFjrqoxBhhdkNHbh6MBDSXWqZJABzk5HoYMQ8ZW1Cy10kd909Rj9e4ANNjXY7Bh4LiywmFlIx8MDBj5CsXWIGMKaAwlgMZx8\u002Bo/0Nd0qc7b4O6QpDsl9qmZkw52QitrVxTYvAaX7AHa7gMXKG8llNKU3PIz9nQ9O\u002BUS4DhzaOUi4DBs5L\u002BBVjDyAWJLkDElfDKW\u002BAk8ICMjIZc\u002BA6EVdGy6VKsZ3AlqSBrqBQ2VABY\u002Bd88MA0EGFLL3hn5Ocqj2uaYcd3UR7YKNPuDYisqxRegwcGzgTTBg5APBUSGjBo22X0bTAjoOoiJRjux/7vA4iOncyAEs4NBd3xOzJprQCb9HLgbVKH1ytc1rkLUHyBcCLsrgtkZ5q\u002Be3TC1/vRQ45kLHJeHtQwCH1Y1tW2aS59p0vyfbCmR0hU80cA2gAC7In\u002BAj4Q/Fq2NOhRLndDjzMSbnEevWTBMlhPJ4/J3BwmCx5T3QThSuYGMt4NgSdBweOLL/qbPZNeBiDDJu4VCz4WFLcDEVMsZmoNRNugAF4tZx0ioqBcoGd4R8TnybEAtfU94KdKB\u002BKKFOw61oXy4Q8aj3fKdqG18DgTQdbIvqFzqJjvXdmAIda5bJHkHlWAQcho1jwsUUwLiE5vcMJBn/3xzAGKo\u002BqXM2OEyJVyMvo2wAC\u002BrWCHh0hUWGAIJ5Kfw9MOKqEzv6vYAOYcP4NZUotdqBsbf7kkbXUDtuARxbgY5VgSP7nzmaXRsupoRKouPjouJirIcUbQE\u002B9m5zwWJJqWvnoLXXXyh3eKgXVI6gWLSj5H/0tcmHN\u002BPFsx2IzWuwdA8QBiwhljCfBWUPBYSf69wbg46l4GHgsLpxOLCYAhhdd/TRoXGhxXr0IYkx24kfFSymgsZQVUrsncGBisoRe2VwV\u002BewiB34WSCOAXFAh5KbVbXC54AHSiDQMRZemQogGcCxBZVjtsJhdWMfULEEMvpCB/FCUaWD7gS4MOOFKCdWT048KoBcAyDWAo2xXhtdrcjVe4Dprgxcy3YENq9B1h5A4VMuE2ed5ql0JZJOgY\u002BxAW9nUDlWAY7sf2JLlg0WSyBjLE\u002BhdnK18yrOqpk7wMexWQO228DGWM\u002BNGjjaaa/375W7OknNdvp2\u002BmfcA5SIq\u002BkdMN6VSNoFHn2DDks5etPPRmfiXOjYu8oxCzgMG9sEi0sgY0pCZJ\u002BjizkBJdGKjO/QLGdJjPMWakB53c9\u002BZXHW\u002BdZVjSWwUc89UXiFuDZrpe6h2U7A5jW4xR5A1WDftzdSoUdHXxXL2DgA3Zjpcbqg4xbAkQkdBo4dKRZrQsbc6os\u002Bx82FQ/wfo1RSjaDqmQO3Bo8hB8xr5jAZy0XZCoDMgYwpeRvkZ0jJKJ0VmzHcETYADX6mw9eO3o7\u002BrHuAMu\u002BuhnfcYAHh\u002BjpCSD1fiOotzhxK0HVexsT7qdBxGuA4urqRDQ23hIylwNGnbuCcyOpWHfvUbO61nfqYA67vNnRolLv5L35s0mvdM2jEmScRIiQbR1WD\u002BDWScvZhb/MaZO\u002BB9ox45dUSaqQiS/07VNFV\u002Bng0eR7RuNaAC34HlTBW\u002BfXlc9wCOLKgYzFwZAPCmeDiGqAxBzYicNTO7M7gLu6Mq/kDS5Krpjr2qQ44JrNyh6GGV9xxKPm1hqRbgtIlgDEEGwKr\u002BH9yQJJ9z5wT\u002Bg6gdujQJEP/8w8/Wb7m\u002B/TjQLXKPvBtXoOsPcB1wpmB8TXVKlI99DtcN1wzbat0qYWNYkgFDD1qSndT1NVwLnZBh4Fjx8CRDQhbg4wlsCHg4CIpyVPUq0tibGKc0Hvs1NcV25ySXDXH2Y853Ro29NrjMLIYjx17nXNexzXBYuj/7fqfOQQ5MHVYfvydNwtIcJjydd3ki46h\u002Bl2b1\u002BDse0DlsUPGNUR4BeUQFYRwpNRCGuPpzNH8IowzU2GVqcmja/uWTSoce4KNbDjYOmgsVTf6FA7dRatTZbmwQmilL6mqz1HGx\u002BXOvEt5mGr1ayzDyT76w/YupGtY0xTwWBOQ1gCMscRQVAsOvnh40mWR0tfsw9zmNdj6HpjTAE9GE73yt7/6sCiFwEV9NkaFw8CxE\u002BDIhoI9QcYlsNGXwxHvprmYSs16pR7UDr1VGzqSrPTzEgYIiVhzS9C6Rq7zugAO5SwASeX1atrphDkKQ8\u002B1pkKyFK768jaK0tGULROD5n9H6nWvjXyHZjvGGsTBhqgaUlP5vnKkpHCoiq8rh\u002BOWCsetVY7ZCocBY7\u002BgsTSUMhQKwNkV2GgSL3HeyPdccAIPVUTQJEzSoiY1RpVBUqPCADxGF5jUzr2vDK0u/4yvpdTWN3kcvC7u\u002BOmsKfjg9Y09dw01yg9ZUqe/1IbK8Poae\u002Bn/J9GNwxEpmCx652vkOy3bfteA66ltj94oqHxE4YjnQl/uhoFjI7CRrTrsWc1YS90Yyj2IeQN3JpHqrjr0d0DSBzqIc6o8rNC/kqwaZ8j3SbRS052oPigRsk8h6StNu\u002BN0m9cj6OBunyRKDTJDEo3P3fWYUnX4G6AF\u002BfSr7774GFhCctg1wGOs5r\u002BGDXUQxcpdVjNKfkp82uY18B7oHmAoaMcACxRT4J1rK96kRJWzPlezgeOWKsdmgSMbBo4GGpfCxpjKMZRD0OXwcebqYom6wF02CgHOH2VDWd111rdgZCxs0/XcTwBHI3FqYBnPC2gwip3XReJXTIZ94nGaZNkWmirFpk8VuRZoDIVSNBtFVSmstfJubF4D74Fpe4CQrCpSFCaJ51Nf\u002B/O\u002BZPK\u002B8/f0wGHI2C9ozAWOuRUWY4mLde6GVAQs1rPjvFEZVEWBU\u002BSuoYQ3mhAInwMnutBrEIhdM7uMuw8dFrrL53m401e1BsmUn3r4ufKagBF\u002BFmXSqNjwc8CE/4WPquDpSka9JIl2isV15jUDGMSPBVCqSonmcfKGDcPGvD3w3NtfL2dFaQUQrvWYb9aXu7VV4LiVyvGBbHUjW204sqIxFzaW9ooYcpZ90FG30ZbzJzShDG8\u002BkiGOTEndOwAAjGigUkksbS76GCYBSErcFEjQz/nYhFCACTW\u002BkhPme/FQ4XlQXgCfthunHk/WhH/02nl9JGQKVGIjoKjELGmNPAYZXcoG/4PW0uY18B5YZw8A81zffM71X25iBga7GThmAodBY7\u002BgMRU4LmlQNSb/DyYwNgmaOGnyH3DyfRc6F7bu1gl/xIu\u002BBYKmLbca9ShHQcpGVDWAm6mZ5\u002BoWKDlVygyvIT6e\u002BlcAKqX7YAM8giz\u002BjnwRhYX6wkFzLYJMvb5ShrQmNq\u002BB98B6e6D02VgAHHPVjWsrHLdQOW4KHNlKw5kUjWvBxlLg6MrlAAYUXpHDjqVlQ6aKCpWfCVr4GJMhNfUU4/EJ2dDYCoVD1RlDz4O6QYtvfh\u002BAULtvHovXzfN3PQa/q5CRQIjnJmzUDoWq5Ngl4NGlnNS5MqyV1tgD2Awbho119gDXFGXlQw0Pa/AwcNwAOLIB4KygMQYba7TgHktwrKtWamVDA8JoD/zM996afdHjQAENTGESDgGce91jQurIGsbr5eOQIkO4plTTNECksAvQQQhHiaYx/2QKeHRBxhOw0XQ1jLkpAiE7HEOH98Dle4DrO4Z0\u002B/K2ynVJcvpI741TKxxrwEY2AJwZNLYAG1gsy1TpaAxDKHmUqpS5F7zyOQQtGF01t3CYkjsRX5eajMWwC1YSUus8lA7lY8zq6hmXu\u002BbvAdux14DzC5hXlZuSy7uS2Iv6\u002BpsfLmfjknLYWwHHtaHjKsCRDQBnB40h2FgKGlOBY6xEUw4xJotywaJIkNsw54LfClyMGYmvXTX8qCMcWqgf/C8AicJCbc5HRxVOn9UKktYW2HFjr/x9YDv2GqC2tonsjYLL97kOSwXbb374Ti6HgaMCDoPG/kDjFrAxVro5p/GWEj81ofRMZZocQsAI/zdhFvJByGHRzIU472UMMmrYIFxD2Ac1hedxtUr\u002B\u002B207/hqQH8Z1zI0ECmwpwW/6BS1tZ34ahcOqxr5Aow821lY1ppZo1rBRV0/UZaUKsXDBFmfc0TfiiAYMUKGDwqM8E1XCxOZCQ/1F6tyY7P/J5jU44x4gNAxo8Dkl8qUarUneHmtpvhXguCZ0fODScEp2WOPsoZNbggamdt5q4DWYaxBCA9EhAhIqK6XqA9BQ11FMlSBnNUIr6j2C4qPk0jrP4wkLvUZY2888/HQJVdGoKPt/snkNjrYHVGaupGyqwdTVl5uE2J9jDdg4NHAYNs4FGlOmlsZEUBwczoxQSJf0r9bhXITxTl2KBhcrDnXt6pGjGDkdqoRhrdo1VP5LAIy6GRkVMMCG1zX/fbQddw1U8l6PM5jS1tzAMRM4slWHM6saU3I05oxCn9I9tA6dKDyiduQCCb6G8FE/SIpUWAQwoUID56nQiXtCjB9qrCHry91S278jNDmLFtu/q9X63ERcm9fAe2AmePTAxtSGX3PO/lv6wE0oHNkgcEbQmFLeeilgTC1z7WrkVecPEA4gD0N32Di92NCLz5eUwdoeqx0Cjy4D\u002BqQc2bwG3gPX2QNxsGTfLJWp81MMHD3AkQ0DZwCNuTNPBApjUwrnwMWUUteuhFAlRxEOADgAD\u002B7Qz56TsaZptgtgp8TS2GpdLczVidXmNfAeuM4eUOVXHF/A53x/6ij6uf7hkArHmWBjazAxtaS1q3Kk1HmTU9HE8lWKdclY9Knlrm3ehp67aTWuPATbOmuAakT2O\u002BEpNVCLnUzV0\u002BRM5cU2r0HWHkCpVYUdXxNSVoOvKa3MTw8cXepGNhRsFTQuAYglkDEUPomhDjY/zl8yX18Z61LQ6GroFSslYqmrczWuc9AJKBRikQF5dsB2wN4D19kD5J3Ra4PPAXt1E6YUlgnVqLprzU3JVjiwmwPH2UHj2lCxpMKkDzjaNuIPXi7OHtoWeERQmDvyvGuOhxQNSsJ4LiUzAhrcfWvc\u002B5IOorZpa0DYhOF0miWjOS1eP\u002B8h74Hr7YGYuxFnIMXcjWvAhoHjQKBxa7CYAxhjsFErEgUG7t8rxK2ppGNDwSJIQOlFpah6bHS20Q4tyrnbHhpmZlt/DX7hx18o7xeggWmAnc1r4D2w7nXG\u002BabzVN1Dx5JEh85\u002BA0eVv3FkyMgCjCWQMQYbQ\u002BPg1S8j9sqIdeNdw7\u002B4U44X1uDMjpC3odkdOD46Z/rAu43To/SYJF0Myddrb9jwtXf9/huou7Gp17Vh45AKxxFgY2uAcQlkTIWNGjjqHAvCHtz9qg9GbJX9RB5Go45IOozfv9N4qoENHpuwCbDh3ho5zo6ZKUyrjJNobV4D74HL90B9ppG3UfrdhOTQKRUpBo6VgSOL1LYIGGtARh9sjCV6xlBJHFleWol/9Id3ci74WCYcNs2joHc5ME0yjU299HVsNqUEUY1o9iGX4\u002BgIZ/3iL51j/ozNa3CLPcBNmoYpTg2jjMGGFY4KONYEi1tYNlhcCzS6yl/HKku6qkikTggaKKuknAtS54JCzQAWVPFAZQlhGF10JIHyNX/D76n6RCWZSlB1CWyuE0Di1VwHm9fAe2CdPaAbL86/sZblU2BjT8CxdlilU\u002BG4FWRc4syjA\u002B5608dsy5AxFzRq2OjKyxB0oEjEctXYARRT0ueHvvH2ne/XlSZcfDxGHC3vcEq\u002Bk3v6z/4o/TXYvAZ73gOUvaLUcl5yzpUk\u002Bi9\u002BbLRl\u002BbXVjSPkcTwBHNcAjbUded17Yog2rwUic/Mt5rYYHytdrctYlVNRJ3m2U12b4V9K7KSk8tJ4Px1FARQuThIWnT\u002BQf1gytC37Ndi8BnvfAwoNx/DJGrBh4OgBjuwwx5hD1waAQusNsbbasCZMTEn6RJ3Q6Pe6b8ZQAy7yNJDU66TOWLaqkeUoFmveDVMp4fyB/IPS5jXwHli\u002BB3Tuxr4aXSMiLrlhtcJRAceW1Iwuq51z3U2zb0NcAgRKwOzq0LkUMPo6e6qcVe3J\u002B3po9IVOgAvqxqVsIAkqN4N8DJSIa9wJqwOfzWvgPeA9sIc9wE0XuWecjVPnUV2qjhs4AnBcAhpLQxVjQBB/xh27NkVbhaFJmY3TjQDS5\u002BDngEEbsmgAoJbZLrG\u002BoWjl/3j\u002BjSc62sU\u002BGDF80tUPgzAHMUg\u002BcnExVMhTWvMPOZvXwHtgIxNfm/O17sI85CsMHCvmcExRHkRp\u002BtnQGzGUozDk/IfCDXFmB3fscROphKkORfSpCbFR1lD4ojh38iC\u002B\u002BLHeNuF6nFK9MfJ4BYqe/Uor37XTVkMHT1WAFPAIYRIBVvu9JhmUtdAgIcACJeOZ772VfmHbvAbeA94D2XuAM1H\u002BIbYknwMbBo6Vk0azN4XNa\u002BA94D3gPeA9cMkeoPQfZTf2Eopj5Ptgw8Bx4yoVKxxWOHzY\u002B7D3HvAeONoesMKxwbJY53A4hyP7YLB5DbwHvAeuvQecw7EB4HCViqtUll7ArlKxkzAoeA/saQ\u002B4SmUDwOE\u002BHO7DMffCdR\u002BO/MPT5jXwHrhc9XAfDncadadRdxr1YTrhwHSnUe8TQ8fle8CdRj1L5YmeHZ6l4lkqPlyflIi9JoYO74Hle8CzVDYwvM3TYnMGuXlarJ3H1IPS02K9Vwwa6\u002B8BT4u9MnBMgY5LQWTNaXbXmOWyhl1rfsucrqVty/OmkRjNwIhTlrr0pmEYDcj4XF1Kucj4vVjDrtH2bU178/v6GY/J1FhJkbbbrwGD9DzPxnvP1956e4CxEGoWpi7WsdP00hH1Z5wUuzpwbMW2CCDXGBo3BhzqYKqJsVw8QAQWu5reGWHfdC797IvvPoYKfa/5fmmeUw2H47EZBge4eER9jsMDEIE9T\u002Bw1cBg41t0D9Zn2Gx/5QbnR4nxdCh0Gjgo49g4dfeCxBQC5FnTULdbVBp7psLHDXuy01zUADqWiqBgd4\u002B7jLJeuibRAB0rIV9990QffjZwfFUJ0VcQ4HL32hg5Dx/X2QFE87t8rIUzO2j7oMHBMUDh\u002B9wcfORR0TIGPTABZCzpiFUuZy9KoFAKHvomzXZNnUUI0DK8LSu5AR4ANlBNkfR92t3N4TAXm/QLyMD73\u002Bhs4vAfWv8443\u002BKN2JTwytrQcbiQylGBYyp4ZIPIUujQYLsCG02YAwck2OgbONdlfVBSgwmPDeXHnBDAgyFyn3r4uXKhoq4QbvEBuL4TfObNdx596Du/XxQpGe\u002B519rA4T1wvT2gUHO8AdOEcoHHFOgwcHQAxxGhYwl4ZMDIXOAQEChHQ3DQBxZ9yae1YjIEH0/kfzRTfJVI\u002Bucf/B3ndVzh0PvpL/y4fOS9JowlAzrsbAwc3gPX2QOELNVRmZsp8jn4/OPvvFkUZRTGa0LH4RSOOo/jyNCxFnjcAlDGymWlcOBwFOaoYWMIMJaARx1mifkcVLPwWp7\u002B5vd9\u002BK144H3ivS\u002BVSZioSAAGUMdaY3zO9zgIBSQ2r4H3wHUHwnHdAf58XSr5QjLpGHRsGTjWho1e4OhSOY4MHbcGj0uhpAYPNjcOP4ZLYlO0KRUuS6CjCzhQNz7/8JMlgRHaJ6fj197/OR96K4ZRuKsCMFCRogk8OPz4PTsaw4b3wPX2wM/\u002BysPyMY6953O\u002B3wUcXdBh4BgAjqNDx5bAYyqEDIVZ5toc6Ii5HG1Ipen1AWgAHNyNc0GSw/HUt77bXqh8zp2BD8P5h5xCKIBdlwEeT337O15bw4b3wLUnzYZcjpiUP7dHhxWOAeAweGwXQMbyPC6Bjxo4BBpIiEj4sTkYeRtckKgblGxygRJeIQygPiDO7Zh2sLGGCpEINNR0rTZBB9DH\u002BqKEOGHXjtfwdd09IOCoFeZ4nho4FoRUDB37gI\u002B1enz09fmI/T1KzkhHvw41CkPaB0RiZ1OV0OpOXEqI7W/XgLCU8l9Yq3YNY\u002BM1fR7t/r1SIcQQN6\u002Br95Svqev344h9jYbC2mtAx57zNwaBw9BxTvAYSkzFBBvqXlr36rhjtEFv\u002BnXEsAuqh3IOyO/AocZyzrPnfBCSwgAylI0WNGLX1y5roEMqB9ABFD739tfT/yeb1\u002BBoe0CJorQG4CM3V1x/qIuACNeucuvqnA4Dx0KV4ywhFoNHd5OxsfLZroZhsZolltAq1EL\u002BB\u002BEAhWOObiSZ0R2U8IdUCSWCtiGU0Iq\u002Bz2IeTfb/ZPManHEPkJtGiSyfU01Wcj2aZPo1oOOQCscS4DgLdOwBPC5pKjZF8ejL7ZhSPlv37CAvgYuT0AywcaZyzgJVP/pa\u002Bb9ReAAtEmo1LKqvtXxXx9caOjjgCMkwW4XnUUa9zWvgPXC9PUCPDq5jbp4Aj3LT0HQl5WzcA3BcCzZGgWMJdBg8zgEdfVUtXeARq1nadujV/JUlHUnJc9jD4QlUPP1nf9SZEKqJvPwvhFEArxiKGoKNwRk3D15u11Yg51LZ/L1gO3ZTsDJ5uznfpDRyHZZSdvokhXyOJX05DBwGj91BxyXgMbeMNrZBp0qFMIkaVimEgi0pk\u002BUOggtZ3f62BCEoCvF18T8CFKxBnOjKhNeu/IzB3JgOu6MgPXi5PG72Gti8BkfeA5xf5G\u002Bow2\u002B5lrmJ6rgRKDcSTUOwSxqBHRY4LlU5rHjkA8cWoEMltDEHIeYayCkj/z/zvbcW3VVojogGl33oG28XxYSP8XfXrNpQBcnQsDrBVewEqnknVJIoGbSeyTA0ZC/2QOkDD0EHd1Ycgkpqw37xlxxayXZUtmOsAdd3vFmIlSpPhJRRG0dCKlOgY8\u002BwcTPgcKhlu9CxBDymNgzraxQmp4gz1LwV7ha4\u002B48NwoZM4QHipXLoCkfwmLrDZ7iZ/obHB0SIswInwMpYzghARNUMv4\u002BzVgUNj6UE167H4Hf5uUrnMJ4b2Gj7aXQoGUOgMRU\u002BYq4Mz8NaaY35n7MPapvX4Ah7gGuKm5oaNvrGSawxX8XAYfjYRXLpmtCxBDjGupMCC1RqDCkGOHapFDh6JZzWzbDU3pufCTxKiVpT\u002BaFwDtAwdqgIgPgbPvI6YyvxGB6K4RKcPNAR2x6XnIyXXiu5KiV7nf99IWgMgUe9viqT1ZrYvAbeA\u002BvtAc6ivtEScxqAGTgahWNNleOMqscegGMqdExtiT5UsVKHVpQ8ihPGaauigo8kXKonBc5eeRt8r03QqnpRcBfP5MYCIFWTLJwv4QyFX1R6y/fiIcLzAD\u002BaXdICTdVkK5b18vooheNjeW0hntulRGhN\u002BsqMp9jQBF/\u002BB1enGC4MF\u002BvuAW5i4s1O7LcxBziWQMeewymjIZVrhFXODB97gI4x8JjajXQINhRGUchBCofapOMocfTckQMOpWojtO0GKOIcg7FS0WhSOur\u002BHzHX4Rd\u002B/IUCIAIHJYN15aHwc4BErcR5fUPx3D7AGKv4mQMfPC\u002BvmcoYFBqpQl19Tc5UhmzzGqyxB2ikx1mhm5m\u002BWSoGjguA41bQcQb42DpwDEHHEHD0Ob\u002BuPhw4fHUXRUkAIjSYDIlSY5/l6Nu\u002BFK\u002B82oYjVN/eF5aon/uJPiChLBe44HlRBOSgAQheYxfYKDGTn/N6lABatznuAow5k3rnQkiX0qHuhwAIH/lfVSFkJ2wn7D0wfQ8QMlVYNTbli4mjU9ubb0nhuJVf3yxwnAE\u002B9gYdQ/NWorOLVSlywlIEuPPmYiWZE0fdOusAAYIKoKMoG8GJy5GW6o\u002BJuQ9Djcd4fB6LvArUFakvgEd87j6Y4XEUCkJBIA9FuRlrgsYc8OgCLl6nDs2Yy\u002BLyWQOHgWPaHujqpROrvlBjCdWiGnJtxRuOvsTRueWxhweOrUCH4eP2wFFDRx9sFMfbDDHCsXH3jFpwBzbu3ytOuVRtNImitSJwBwru3yuPIRWjLyQxpdupHG98LQrRABu8LlQWDpSYyDr23HUVDv83fz\u002BUqb62zYGO\u002Bv/XYalqHTcGM3wYPpbvAa6ncq6F8CsfSz5ZOBfGWpzfGjhu6b9nA8cWoOPIALJllaNL1YgOl4sKwh8Ld8j59ykURR0g6fL1F3onME51wlHh4HUhiUoOVTOutjNnRx\u002BMseedmo9RJ4\u002BN2aXg0Rda4f8msZX/HVWn7lVi8xp4DyzbA7GkX\u002BX6nDV8X\u002BGXoi6i0C5sc27g2AB0HBVAtgYdXfkaCqEoH0BzQGIYYkmSZPm7Z7/SmYQ11wnrNSJzAhzxDqQruXOJMjEHJi6xOaAl4KD7qtosK4SCokNyrJ2LAcN7YHgPUCk3d41UHUaYFYWDXK/6bIxKRxZw3No/T1I4tq5yHB1AsoFD0IEjK8pEU4lRKjaaZMmYXNnVAOdWDrzLCd9RVZq7jlqFmQIac17H3EqfNQGk/p8BLMI96hGiKiBVrcROpOVO7Y//9E77dZvX4Mx7YEqeE9cQoUkUU/LTSDhXVV3p09GcOVI4MM5MrlcDx4GA42gQkqlydCkcMVxRmm6NdNub6riX9gQZSmiNeRbK31B/jbFytkte59KGapfAR1R24v\u002BpmQ/ABkmyqlpRu3bUEE3u5c7MeR35Ds\u002BWtwZcJ5wZaponEKcvkH6H60aVK3XVCucioUtV3cVk8r5cjqEze8/qxkUKx96hY\u002B8Qcmvo6MvhKI686aNRl4Xd2nFPAY/oiMvrbQ4JlIApr3VNwLgGgPSFlOpcFjU\u002Bw2KJn\u002B7KkJHVxt3mNTjzHmjPiFdeLeoFOVCxfxC/U7oKdwxu41pTl2IUDc6ZOpxSKxwGjpNAx15B5FbA0QUdODGVt3I3LLKPBJ/hvIecsZJSlbw1Bka3hIxbgAfqhapV2i6q4Y5M7eE1WC/Cic1rcLY9gJoRm/6p\u002BgToIIyir3sHt3352VIFxpmDUqLzUuGUDODI8qeTFY6zAsfeQOQa0NHn/KLKoTkhMaSyJQf\u002BxOt\u002B9iu9oJENGGvBx5SE0ljFUrebl9rBWnF35sFv\u002Bc7Pdps10OiEroZ\u002BS0YTSFUtHY6bx9FZMxU2Tgccho59gcha4FE7vNqJ6UJS2aUkw\u002BjMsx310ewa0KEwE7ChKbvqBmvzGpxtDxBSVLixLuOfmwyvfDfBSxds3Ao4Mm/UVwGOMysdU20v8DEGHFw8XHBKQqRpFhdQ3feCjzVozK2M2YNtHTqGSoX7GoNx2FIy6x4d\u002BU7PlrcG5DFpUKTyM4aq2aZci315G1NgYw3gyPaDqwGHoWM/MDIXPGp1Yyz34ciAsUVAuTSRlMNVc2tUvaPZK3Z4dvpn2ANUkqDmUXHC19xIqRwWtY/qlHhjdWlCfAZs7BI4rHIcTxWZAh5ybDHLus8BZjv1Pdo1waOvR4dCKORpcBdHsyINstMd3tQDm8z9bKdh8xos3QPARtuWvCkfBzQIqfBznXuXJpn3Xf9nUDdWBw6rHPuHkCngsRXIuKQ6Z\u002B3E2q2AyBTg0Ph6VAzu3DSwjoOVxFD6b9TNwIbUDhob8ff0GvC4ezv9vYAf3Xbj1/SliR2IZeztKbCxBDTmnGOnBQ5Dx3kgJNMB3xIo9gIhS8GjqyGYGqBRqhcrUAAOxa75nDu9T7z3pfI1UMFsCEqgkZ3L1N7n3yjQQodSlRE6HJPvUG3ja6B\u002BGqWPRuiUPDRPaW5V21pn3N5h42rAYaUj/429dehljiPOBoWjQMgc6IjqBpABIGiMNrCB0gFARIUCmVlAoYM5DqgCLGLDMI28d2WLnf3WYad0/WwmVteqxtjwxjVUjVsCR7YvWgU4DB35b142hGQ7963bVuAjQkec7Ku5DsrnKMPt3v\u002B5Ag/IzagYKB2aA6HmYHUDpHba7kuvdYZjMCXj2bwGWXsAhY7wSITokrNRQccawLEV2DBwbMDx2gwgZ4SPKb05BB\u002BEUDicUT3isLu6dXMsp\u002BUg/\u002Bq7L5bD/bm3v17mUJArwuc8jjqX2rwGt9oD7L2hnwPW2ufs1dJ5F3juGEK5JnDMPT\u002BOABsXKxxWOfLfQCsg\u002BUrGVuFjSpilr2RWigWHsJofxS6LtbUD/JrJwYRpyPTXnaQqXzCqYTS\u002B2\u002BY12MIeIE8J6CCfqYQJG\u002BWOn7Gn\u002B5JGb6FuLAWObH\u002BSAhzO59ivOQRzDPhYUkI71qa5y2LH0jtt0kOrdFQQTHefrmrJd7a2u2E/cpMAbeU1sV\u002BpYIn9hy4BjrPCxirAYejIfxP3CB/OAbkteMzt3TEUehmCjxo6IngosRTVgztJpGzi6hzu5H4YPuz8l8JPTGZew5QwzeeEF9UMD/AYgw4DxwaAw0pHPjRsHUAMITngsbQ9ehd0dA6Ee/ByKacljEJo5TMPP13uIn1nbcBYYw8AAWuDCwnUmg/VwnLI6xgKrVjduDJwGDryAeCI8HF2CNmq2jGUcFrnc2DK36B6Ze07Uds514BqKj7G3KC1jDAKyaSlgiVUr4z15ZgCHdcOp2T7gs0Bh5WO/Dd9z/BxRgDZA3TEZmJABiETcjT4yCHOIDiUjUscwTPfeyvd0dm2oWYoIRlTl9w1DBWOEB9Jz6gc5HXU6sZQuewQcJwdNlYFDkNH/pt5Rvg4E4TcCjqmJJX2zWfhrpDDWoOvyM3Q9Nk1HAIdIeV0GLhlADg2BAGp9fsMbGjmieaekOC5duWTQoCE/0qlVujTURroNeFC7fl6QvYlwHFE2FgdOAwd\u002BW/oVu2W8HF0CMmEjjHgKCrH/XutssGclXJo//GfFlhYwxFwR8tjurfHOUxqRptT0YQ5YtM5VUCt\u002BbxADOBcP69AQ1UsdW6HgWOjwOHwSj4InAU\u002BjgghGdAxFFJRWIU8DVWkqCqFhkoAxyUzVpT/AcxIThfY0GSMj24utl8jsZiqpRg\u002Bk7ohVaNuPifnj8JAZ1zldgClmPZFfA6Mz3ku9o9\u002Bh7wNfgYko27wmDz/x995s/ye\u002BtHwWoAbqRuxR0edz7FE4TiqunEV4DB05L\u002Bpe7JM\u002BDgKhNwSOvoSRjv7cDQHMneB7cj7ygHMNfVF0AwX9fcAPmIIR0apLfkj2c7U1q0gkCNBCepzv/5ecfJqEifg\u002BPzDT7a/z54aajwn1UMty9kTAlxAQnuHn7MvtH/4GsigSy4QIrgRLGs4IX8PxKjzbt2NdAg4DBsbAg4rHfmOfwuWDR57h5BrAUfdFl2g0XXo1\u002BWwHNaX9tdAtcAhcAfKR1UPRMMJxIm3SlDlzlSqB3\u002BrWL/zP7YFQV1zTfgIKAg0\u002BhrOdZVkx1ALjwF08r6zB2Krfj1PnA/0xKygptKK/SVIAUDUxC5Ws\u002Bh1CjrmAMeRlY2rAsdS6DB45G\u002BIrVg2cOwVQq6tcgAa3JGqcZdi59z9tQ4jdBfloOZ3\u002BRvaR/c5HBwBDZaAA6CA8ImSAJG0Ywy/1zk0z99OAg1TbFXJMFQpc0m4x/bkGqih25S1AQBL74tqamtXZchQAzoBB8/NntKMFB5bpa6EXsoAt\u002BrxO8Hlwcvl79jHgp848LBAdfMYSppeAhxngI2rAoehI//NPYJlQ8ZeIeRawCGFoxy6goAmS5\u002BDnINduRuSpJGpObRx6GT8I5Pz\u002B3wPwFAJon4fZ4CTUKtp/p6/wSmRB1I7pC5pPd6d8hgaKjdmPBeOyl1Pp0FUTAYWIPIeA5lK4hxr8sZ68/7GHI0h0BjrB8N7p7BI3BdAZz2QbUoDO/aQlAxeX3xdXa\u002BlPNeM/I2zwMZmgcNKR/7G2KJlw8UeIWQt6OjK4aglbKAB09RN5W6oRBbj0Od31Dqau8Y7ykhT4YKhckgd6XJEXQ6ijufHCgZgAvWkT\u002BXgZ3p9fI3iotyTqcByZCP/pk\u002BxKM68yXeIuTXKnwECukJZgkr9rcIUarTVBxlr22ATO/ZRKL/tytlYmix6Jti4OnAYOvLf4CNaNlDsEUIuhY6\u002BpNEoYytvA0eD08YJcfcLVAgwoipSlxp2Gc5oagw/wgd/o/kXOEpeg8IqsYEYP6OvBz9HaYnJqCguPAavHTWmy8l2NZ2qnfLP/Ohr6bAwZDGUBBhEQJSpMkNhMYFCLH0GKJ7Ir2nyH5TUy2Pw\u002B6y51kW5N7wXbQnq/Xu9oDHWin\u002BKjcEHeygCtDrlFrVj5iwVw8YNgcPQke\u002Bgj2zZILE3CFkbOKLsrHH23AWicOBkcC6oFBzcfJ9Du23c1ABHX8x\u002BSuy\u002BTxpXPF1VCQIG3WHTBRVHJxCKvRZkqpBAqdHfEEKQo1UICMWEcJK\u002Bz//L/6qveQ59zmOpLFPOVooQoILTRRVYktTK6wAEgAYei8fEsatRmv4PXo9erwbo6TH4G94rvUZUHn6H/0eqRbsW3/pu\u002BTnPpbyZOsfhjtp0/155Loz/j1AL1SlADK\u002BJv4/KxiWQsaSTbr2/VeathFaBbwQOw8bBgMMhlnynvgfLhoe9Qcgc6IgHch1KwQHhNHFUKk9tFYpXXi2OXneGioXjcHB\u002Bn3r4uceqQoeCMRSznyOLC4ZU1qh8A5y6wifcmfOzusdD6yzv32t7M\u002Bhu/s6d/IOXy/\u002BkcFDsgskaxJ4kqm5Q0q2SGq\u002BlXgAfvDaeS\u002BBXHHvTU0JWJ/zyvQIZVZIu/y\u002BAwePRPZb3j8cDlLqAUe8B66ZQVkzkVb6O1ov90jUcbS5YzLE\u002B8Gj3EWGi11943IsjwNRQk68\u002BZWPJmZB9tu4OOAwd\u002BW/0mSwbGvYGIGPQEQ9h3fXhLErWf6NQdDkanJUceZ1fgZPhICe8oVkVQ3MqlsTjeSyeS3kkvB5et5yqwj7xf4j/R1fJ75DdSVZtQEThATlZNSuLJby3MkIYhE8EHC0YBWVH/0P8v7oSc1WKPLWSpAaQ9r14/YUnEjmHQGMNwJjT5C7Ca1mXZt30Og0bGwUOQ0e\u002BIz6bZcPC3iBkCDo4YMud8OsvlANYzqnE54PM3Kc41MqDDm/uots77Ndf6J3EuSQuX6sxdaimLwl1qHqhBpEuIKmrZAQc15hsujRfgzBL7H0xBhpduTJj1SNDylOs/Bh6v68JGGPQodfEawYQWZP4f9Slr2uqGkdTNlKAYy3ocBVL/sbZm2VDwp4ApA88\u002Bu4Ap4LBEyDQSNWCGEnpayQFTk0QnAJJU8onOxufNSoAeQoZisYU43VJaRjqgTEXMqascxdkjAHBJX1m5paBx73K\u002B7hkOJth4yDAYejId\u002BJ7tGw42BuAzDmk58bHSyXJQjWjyzldA0KmQEddRhnVDRyVKmFiwmm21b0xUGDKDJye8uM5oHHJe3hNwJgLIdon9Wu9RgXK0ZUNDJ99c\u002BAwdOS/8bZzgMeaALLkLnFrdgmATFU6AA0qMNSOPVakbNWkvqB0UDEyt/HWpaCYDRlzbeg6ufRaPeLZHEWCFOBYGzqsduRvqj1bNhDsDUBudYe5RfgYAg6pG\u002BQmUC6KE6dChCqdbKgYMnJLSlvxJkenVjn6YGMtwNgqZMy5Jta4LrPPwWtY7afTgMPQkb8ZbOcGjzUAJMMRZMNHV\u002BVFX/OzLSSKTjF1BC2dX0PVihKClyTzbhU01gw5rnUNHu0s7hMGDgUcVjvyN9oRLBsAsm1P8LEWgMzJ\u002BeiquImlsIQoqL4hWTQbJKYaOSaEgtQoTD1CuhpxbRU01gSJW4HGmWAjHTgMHfmbw2b4sPoxX\u002B2IlTYYDbbUCp3kS5qakcex9bbmNEIDLNR2HkWGMBDqDI29yv9XlT1fAhprwMYtweKaoHE02JgiBqQDxzWhw7kd\u002BZvwKJatOmzBLj2s96J8TIGOGFIpjazu3yvhCJQN4EPdREkcRfHYUoVKG0bRXJSmGdidRmChlfcUheNaoJEJF9eCjLPCxmaA49rQYfDI35BHsWynvxVb4yDfMnzMgQ6AQwPqcOClu2rTWRTFQLNMtmaU69KundeNolHasg8kjS5tzrU3wDBorA8amwMOQ0e\u002BM7UZPo4OIGsDRz23pR1n3qgEceIs6gehlmzQ6DKUGA26A5KGgKOrN8UlsHEGyDiaqrH0xn9TwHEL6LDakb9Zj2bZasOWbM3DPxs85gBHXRpbKjyakezkdGQDxZhpMi4qh8pj646jXSrHJbBxFsgwbGwYOG4FHQaPfEd9NMt29luzaziHW4LHnGqVWLFCzgYhCkCDcfHZMFEnifL6SAwld4PPgQwluJJrgtpR5qxooNsrr5afqxX9GHDsATRufS1kn01r2Bo\u002B99TAYejI38RHtWxnvzW7luO4JnSMwUZf4y\u002BFUtSDA2cNeDzz5jvlaz6ntTjOnkqQW8EGuSSxGgUoKiPom2RRwIKfkYOiibL8Tfk8qB0CjrmwcTbIMGjsBDhuDR0Gj3wHfVTLdvRbtGs6lbXBows2yrRcoCI44TtD2\u002B7fe/TZF98tVSAkk6IY4Nwx\u002Blt8/J03i6rA1/zONQe78dwAD8pGq1w0k3n1ehUC4rX91k/9ZQGMElZhFkyVyzGkbmwRNDL3efbZs4at7Wc3CxwZ0GHwyN/gR7VsJ79Fu4XDWQM8cK4xOVR9K5QMWqo7SBJtDGfN7/B9HHxx3io5VdlpSCpFbdDYeEIdOH1VkUR4\u002BMzDT/eCBeEb4AZ4AS6ABx5LSoXKdPkapSWOoRcsAUBKbuXzGjiG8je2BBvZ\u002B/oIsHEt/7pp4MiCDoNH/oY/smUfhluzWzmipdBRT7hV6\u002B\u002B2KuXBy22IgrkpOG2cPU6ej12Kgh6Hv0ENUbMt4AD4AA74ex4XeJmaC8Lzo2QINFrAIR\u002BjeR1do\u002Bf1vfLaXnm1PIam3WqgG/9/VznsFkAjew8bNA4CHJnQYfDId85HtuzDcYu2NfjoC610DXGLThtnDShgCr/IusIwQEqtkgANah72zPfemgQcwMFzb3\u002B9qB0lyZPHap6ja\u002Bz8mGkCLsbXW4KN7L16JNi4lT/dBXBkQ4fBI/\u002BCOLJlH5RbtFveHS8Bj6FEUikXQINKTLtUhTrhNJbUAikKcSjEMtXo9dFOf23UjaEJsF0j5\u002Bv/C6WDz8fCKGeDDIPGQYFjC9Bh8Mh3zke37MPzzOAxBh9TKlhw5jh3cij6nPvYePvYOAyVgscq6sU33u5tkU7IhYoSFJVf/vn32yFsgAfdRPlI2OaScfNZ\u002BRrZe/CIsJHlP3cFHFuBDoNH/gVzdMs\u002BSLdmWwWPLgAZUgvqcExMRlXPC5WlAgmER1Q9oiRVfl4nlKqfRpu30cCKElq7qk2WTH69Zdlr9p4zaBg4NgUdBo98x3wGyz5ct2RbAY\u002B5XUrH4AMYACziqHvgAZBQ4icwQqUKVSSABAoG/TxUdku30JigykflkEhxmQobWT02svfXUWEj20/uVuHYmtJh8Mi/mM5i2Qftlmyv4DEEIfUEWlQNTW2N/TIEH4ROPvHel8rvFWWjqp5R3sgcZWNJm3KDRv7ZsHXQ2D1wbBE6sOwNZjvHGmQ7/K3YHqBjDDyGql8iNGgwHKqGymZ/\u002Bgs/Ln03KJ/F6vCJbGzi6yVzUY6sZmDZ1/oSy/aDhwSOrUKHwSP/gjuLZR/GW7EjgMdQjkedWEq30jiJtm0u1gEcU5JEbwkb2XvlqLCR7fNOARxbhg7DR/5FeCbLPpyz7ajQMVTNQv4GYZXSjryn38YYcCyFjbnAkb0/DBr5vvAQwLEH6MCyHZLtPGuQfWCfATyuAR1D02g7y2ebfh2x50bM4RgCjqEhbGvCRvZ\u002BMGjk\u002B77DAcdeoMPgke\u002BMz2TZB/hZoWMMPJYAR\u002BxkShiFkIq6iWIkkRJiUWlsVD3GcjmuEUrJ3gMGjXxfd2jg2Bt4GD7yHfKZLPtQPyp4rAkdfZUrd6pUXn\u002BhtDovnUy/\u002BLEnWqULOvh5KattVA/9ntqdR/hYs\u002BdG9nt\u002BRNDI9lMGjgNBh8Ej/4I\u002Bm2Uf9EcCj7HQw9JeHbFSpeRmvPRamZECUNBXQy3T62qUOvyC2qFeHWoUJvWDj5oEe2lH0ez32KCxfTukwrFX6DB85DviM1r24X8G6OgCjzk9OVAlNMFVDcLq3hpdCadF0SC8InUjqB1STLoagvUBx95hI/tam2LZ/sfAcULowLI3vu1ca5DtDPYMHpcAx9gE2hIqeeXVolB0NfFaYvF5AA/mrKCg6Dm7gMOgYci41KcdWuE4AnQYPvId8RktGwrOAB0CjnqSbBtC0QTZV14tTb1KbsaXn70DHHNnodSqCo/D4/OcdS7HEGxkv39TLPsammLZfuXWdgrgOAp0GD7yD4gzWrbj2At4TAGOCB1y\u002BhpprwmxwAUTXgmdABnkWdS5GnM7hQ7lkPAaFFapYWOPqkb29TJm2f7DwGHoMHhs4CCwnRNAsoFDSgNOPyZ2YqX0dWDo2tRy1jHwEGwYNAwZVjisdlj1MBBsEoiyYWFr4DEVOOqwRpsc2gxr46PKXJW70VW\u002BOhVyptrekkKz97\u002BVDIdUThdiccgl/3CxHQc\u002BrgkcXeGMtpqkqSDp6g56LdAY\u002B3\u002By3wtDxrHsNDkcZ4MOmR2hYcDqx\u002B3AYwlwtHkcDXCMdQK9FlxsGTS2fI5ln/F7slMDx1mgI1r2xWk75xpkO6xLbC3gGCqHlcLRVYEyFzYuUWiy19qQke8jDBwGD8PHBpym7dwAcgl09FWHPNG6/MHLszp/rpXwmr22si1fY9nO\u002Bgh2eoXjzGpHtOyL2XbuNTgaeAzBBpBREkSbLp98jGqHOoHydZe6sRZkbAU0svfekGWfy0czA8fJQyx9ln2h27wGewePuiolKhp9A9PqcMs1QCN73bZ8bWefu0c3A4dzOwwfGzjobONrsEf4EHAAGm2I5IO/M2lI2lHUjK3v7WwnfCYzcDihdPamyT4gbF6DrUPI3ITSo\u002BRl7OHazHa6ZzYDh6tYLt5E2QeIzWtQ74E9AMjaZrgwYGQDhYHD5bM33VB2/nb\u002BW94D2eBxCZBkvr7s980KRj4sGDjcsyN9AxlC8g9Y27FhxFAx/L5ln282KxxuFrbhC8EO0pCw5z2QDQZHVyjGLPv8si1fA\u002BdwuENp\u002BgWUfYDZvAbeA9vcA9lnk23dNTBwuDX6Ji\u002Bq7IPO5jXwHjBgZJ\u002BDRzMDh\u002BexpG9CQ4idu537\u002BfZA9nliM3CkD3PzILh9XYjZh6bNa\u002BA9YLjIPgf3YlY4DB3pm9AQYqdtp32MPZB97du2vQYGDqsd6ZvQIJLvKGxeA8NF/hl1dDNwOMTy6OxmZ2tn6z1g1SL7HDqDGTic25G\u002BCbdsdkSGkTPsgezrzHaONTBwOKk0fRPu1bKdhM1rYKjIPwdsBo70ihWPvfeFaCAwEFil8DlgILHCYfDwQbCJg8BQYihxyCP/OrTdZg0cUrHi4YttBweOweSYYJK9r2xeg1vuAQOHwyw\u002BdA566GQ707NZ9vtt8xpsfQ8YODZk2ZvB5jU4I7h433vfew8YONIBwODhg8gHkfeA94D3gPeAFY50MLDi4YPIB5H3gPeA94D3gEMq6cDgMIsPIh9E3gPeA94D3gPO4diVecP60PIe8B7wHvAe2PMecNLoBmDC4JF/Idi8Bt4D3gPeAwaOdCe/RfOF4cPRe8B7wHvAe2BPe8AKxwbgweCRfyHYvAbeA94D3gMfueoa/H/S76BhFZ10YwAAAABJRU5ErkJggg==\u0022 style=\u0022max-width:100%;height:auto;border-radius:6px;border:1px solid #30363d;box-shadow:0 4px 16px rgba(0,0,0,0.35);display:block;\u0022 alt=\u0022Synthesized Image\u0022 /\u003E\n\u003C/div\u003E", + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": true, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "cell-polyglot-python-md", + "Type": 1, + "Source": "### 6. Python Analytics \u0026 Distribution Table (#!share)", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-polyglot-python-code", + "Type": 0, + "Language": "python", + "Source": "#!share --from csharp clusterNodes --as nodes\n\nprint(f\u0022\uD83D\uDC0D Python Kernel received {len(nodes)} cluster nodes from C#:\u0022)\nfor n in nodes:\n print(f\u0022 \u2022 {n[\u0027Region\u0027]:\u003C16} CPU: {n[\u0027CpuPercent\u0027]:\u003E5.1f}% | RAM: {n[\u0027MemoryGb\u0027]:\u003E3.0f}GB | Status: {n[\u0027Status\u0027]}\u0022)\n\ncpus = [n[\u0027CpuPercent\u0027] for n in nodes]\nmean_cpu = sum(cpus) / len(cpus)\nvariance = sum((x - mean_cpu) ** 2 for x in cpus) / len(cpus)\nstd_dev = variance ** 0.5\n\nprint(f\u0022\\n\uD83D\uDCCA Python Statistical Moments:\u0022)\nprint(f\u0022 - Mean CPU Utilization: {mean_cpu:.2f}%\u0022)\nprint(f\u0022 - Population Std Dev: {std_dev:.2f}%\u0022)\n\n# Display summary metrics table directly from Python\ndisplay([\n {\u0022Metric\u0022: \u0022Monitored Nodes\u0022, \u0022Value\u0022: str(len(nodes)), \u0022Status\u0022: \u0022Optimal\u0022},\n {\u0022Metric\u0022: \u0022Mean CPU\u0022, \u0022Value\u0022: f\u0022{mean_cpu:.1f}%\u0022, \u0022Status\u0022: \u0022Optimal\u0022 if mean_cpu \u003C 70 else \u0022Warning\u0022},\n {\u0022Metric\u0022: \u0022CPU Std Dev\u0022, \u0022Value\u0022: f\u0022{std_dev:.1f}%\u0022, \u0022Status\u0022: \u0022Nominal\u0022},\n {\u0022Metric\u0022: \u0022Peak Load Node\u0022, \u0022Value\u0022: max(nodes, key=lambda x: x[\u0022CpuPercent\u0022])[\u0022NodeId\u0022], \u0022Status\u0022: \u0022Investigate\u0022}\n])", + "OutputText": "\uD83D\uDC0D Python Kernel received 6 cluster nodes from C#:\n \u2022 us-east-1 CPU: 24.5% | RAM: 64GB | Status: Optimal\n \u2022 us-west-2 CPU: 78.2% | RAM: 128GB | Status: Under Load\n \u2022 eu-west-1 CPU: 45.0% | RAM: 256GB | Status: Optimal\n \u2022 ap-southeast-1 CPU: 15.8% | RAM: 32GB | Status: Optimal\n \u2022 sa-east-1 CPU: 88.9% | RAM: 64GB | Status: Degraded\n \u2022 ap-northeast-1 CPU: 92.1% | RAM: 192GB | Status: Heavy Compute\n\n\uD83D\uDCCA Python Statistical Moments:\n - Mean CPU Utilization: 57.42%\n - Population Std Dev: 30.54%\n", + "ExecutionCount": 6, + "IsExecuting": false, + "ExecutionTimeText": "386 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-tbl-container\u0022 id=\u0022fry_tbl_fdc0419f\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;margin:10px 0;max-width:960px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow:hidden;\u0022\u003E\n \u003Cstyle\u003E\n #fry_tbl_fdc0419f .fry-tbl-header {\n display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 8px 12px; background: var(--vscode-sideBar-background, #161b22);\n border-bottom: 1px solid var(--vscode-widget-border, #30363d);\n }\n #fry_tbl_fdc0419f .fry-tbl-toggle-btn {\n display: flex; align-items: center; gap: 8px; background: transparent;\n border: none; color: var(--vscode-foreground, #c9d1d9); cursor: pointer;\n padding: 0; font-size: 13px; font-weight: 600; text-align: left;\n }\n #fry_tbl_fdc0419f .fry-tbl-pill {\n font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-badge-background, #21262d); border: 1px solid var(--vscode-widget-border, #30363d);\n padding: 2px 8px; border-radius: 12px;\n }\n #fry_tbl_fdc0419f .fry-tbl-actions {\n display: flex; align-items: center; gap: 8px;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-wrap {\n position: relative; display: flex; align-items: center;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-input {\n background: var(--vscode-input-background, #0d1117);\n border: 1px solid var(--vscode-input-border, #30363d);\n color: var(--vscode-input-foreground, #c9d1d9);\n padding: 3px 8px 3px 24px; border-radius: 4px; font-size: 11.5px;\n width: 140px; transition: border-color 0.2s, width 0.2s;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-input:focus {\n outline: none; border-color: var(--vscode-focusBorder, #58a6ff); width: 180px;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-icon {\n position: absolute; left: 7px; font-size: 11px; opacity: 0.6; pointer-events: none;\n }\n #fry_tbl_fdc0419f .fry-tbl-btn {\n background: var(--vscode-button-secondaryBackground, #21262d);\n color: var(--vscode-button-secondaryForeground, #c9d1d9);\n border: 1px solid var(--vscode-widget-border, #30363d);\n border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500;\n cursor: pointer; transition: background 0.15s;\n }\n #fry_tbl_fdc0419f .fry-tbl-btn:hover {\n background: var(--vscode-button-secondaryHoverBackground, #30363d);\n }\n #fry_tbl_fdc0419f .fry-tbl-scroll {\n max-height: 420px; overflow-y: auto; overflow-x: auto;\n }\n #fry_tbl_fdc0419f table.fry-tbl-grid {\n width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;\n }\n #fry_tbl_fdc0419f thead th {\n position: sticky; top: 0; z-index: 5; background: var(--vscode-sideBar-background, #161b22);\n padding: 8px 12px; border-bottom: 2px solid var(--vscode-widget-border, #30363d);\n font-weight: 600; color: var(--vscode-foreground, #c9d1d9); user-select: none;\n }\n #fry_tbl_fdc0419f thead th.sortable:hover {\n background: var(--vscode-list-hoverBackground, #21262d);\n }\n #fry_tbl_fdc0419f tbody tr.fry-data-row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.06);\n transition: background 0.12s;\n }\n #fry_tbl_fdc0419f tbody tr.fry-data-row:hover {\n background: var(--vscode-list-hoverBackground, rgba(56, 139, 253, 0.08)) !important;\n }\n #fry_tbl_fdc0419f td {\n padding: 7px 12px; vertical-align: middle;\n }\n #fry_tbl_fdc0419f .fry-num {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right; color: #79c0ff;\n }\n #fry_tbl_fdc0419f .fry-badge {\n display: inline-block; padding: 2px 7px; border-radius: 12px;\n font-size: 11px; font-weight: 500; letter-spacing: 0.2px;\n }\n #fry_tbl_fdc0419f .fry-badge-green {\n background: rgba(35, 134, 54, 0.2); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.4);\n }\n #fry_tbl_fdc0419f .fry-badge-amber {\n background: rgba(187, 128, 9, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4);\n }\n #fry_tbl_fdc0419f .fry-badge-red {\n background: rgba(218, 54, 51, 0.2); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.4);\n }\n #fry_tbl_fdc0419f .fry-badge-blue {\n background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.4);\n }\n #fry_tbl_fdc0419f .fry-expander-btn {\n background: transparent; border: none; cursor: pointer;\n color: var(--vscode-textLink-foreground, #58a6ff); font-size: 10px;\n padding: 3px 5px; border-radius: 4px; transition: transform 0.15s;\n }\n #fry_tbl_fdc0419f .fry-expander-btn:hover {\n background: rgba(255,255,255,0.1);\n }\n #fry_tbl_fdc0419f tr.fry-detail-row {\n background: rgba(0, 0, 0, 0.28);\n }\n #fry_tbl_fdc0419f .fry-prop-grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; margin-top: 6px;\n }\n #fry_tbl_fdc0419f .fry-prop-card {\n background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);\n border-radius: 4px; padding: 6px 10px;\n }\n #fry_tbl_fdc0419f .fry-prop-key {\n font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px;\n }\n #fry_tbl_fdc0419f .fry-prop-val {\n font-size: 12px; color: #c9d1d9; margin-top: 2px; word-break: break-word;\n }\n #fry_tbl_fdc0419f .fry-tbl-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px 12px; font-size: 11px; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-sideBar-background, #161b22);\n border-top: 1px solid var(--vscode-widget-border, #30363d);\n }\n \u003C/style\u003E\n\n \u003Cdiv class=\u0022fry-tbl-header\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-toggle-btn\u0022 onclick=\u0022fryTblToggle(\u0027fry_tbl_fdc0419f\u0027)\u0022 title=\u0022Click to collapse/expand table\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-chevron\u0022 style=\u0022color:#58a6ff;\u0022\u003E\u25BC\u003C/span\u003E\n \u003Cspan\u003E\uD83D\uDCCA Records[4]\u003C/span\u003E\n \u003Cspan class=\u0022fry-tbl-pill\u0022\u003E4 rows \u00D7 3 cols\u003C/span\u003E\n \u003C/button\u003E\n \u003Cdiv class=\u0022fry-tbl-actions\u0022\u003E\n \u003Cdiv class=\u0022fry-tbl-search-wrap\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-search-icon\u0022\u003E\uD83D\uDD0D\u003C/span\u003E\n \u003Cinput type=\u0022text\u0022 class=\u0022fry-tbl-search-input\u0022 placeholder=\u0022Filter rows...\u0022 oninput=\u0022fryTblFilter(\u0027fry_tbl_fdc0419f\u0027, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_fdc0419f\u0027, \u0027csv\u0027)\u0022 title=\u0022Copy as CSV\u0022\u003ECSV\u003C/button\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_fdc0419f\u0027, \u0027json\u0027)\u0022 title=\u0022Copy as JSON\u0022\u003EJSON\u003C/button\u003E\n \u003C/div\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-scroll\u0022\u003E\n \u003Ctable class=\u0022fry-tbl-grid\u0022\u003E\n \u003Cthead\u003E\n \u003Ctr\u003E\n \u003Cth style=\u0022width:36px;text-align:center;padding:8px 4px;\u0022\u003E\u003C/th\u003E\n \u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_fdc0419f\u0027, 0, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Metric\u0022\u003E\n Metric \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_fdc0419f\u0027, 1, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Value\u0022\u003E\n Value \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_fdc0419f\u0027, 2, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Status\u0022\u003E\n Status \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\n \u003C/tr\u003E\n \u003C/thead\u003E\n \u003Ctbody\u003E\n \u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00220\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 0)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EMonitored Nodes\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E6\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_0\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #1 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EMonitored Nodes\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E6\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00221\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 1)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EMean CPU\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E57.4%\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_1\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #2 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EMean CPU\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E57.4%\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00222\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 2)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ECPU Std Dev\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E30.5%\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ENominal\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_2\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #3 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ECPU Std Dev\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E30.5%\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ENominal\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00223\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 3)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EPeak Load Node\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-ai-06\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EInvestigate\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_3\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #4 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EPeak Load Node\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-ai-06\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EInvestigate\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\n \u003C/tbody\u003E\n \u003C/table\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-footer\u0022\u003E\n \u003Cspan id=\u0022fry_tbl_fdc0419f_counter\u0022\u003EShowing 4 of 4 rows\u003C/span\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10.5px;\u0022\u003EFrySharp Polyglot Data\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Cscript\u003E\n (function() {\n if (window.__fryTblInit) return;\n window.__fryTblInit = true;\n\n window.fryTblToggle = function(id) {\n var el = document.getElementById(id);\n if (!el) return;\n var body = el.querySelector(\u0027.fry-tbl-scroll\u0027);\n var footer = el.querySelector(\u0027.fry-tbl-footer\u0027);\n var chevron = el.querySelector(\u0027.fry-tbl-chevron\u0027);\n var isHidden = body.style.display === \u0027none\u0027;\n body.style.display = isHidden ? \u0027block\u0027 : \u0027none\u0027;\n footer.style.display = isHidden ? \u0027flex\u0027 : \u0027none\u0027;\n if (chevron) chevron.textContent = isHidden ? \u0027\u25BC\u0027 : \u0027\u25B6\u0027;\n };\n\n window.fryTblToggleRow = function(id, rowIdx) {\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rowIdx);\n var el = document.getElementById(id);\n if (!drawer || !el) return;\n var btn = el.querySelector(\u0027tr[data-row=\u0022\u0027 \u002B rowIdx \u002B \u0027\u0022] .fry-expander-btn\u0027);\n var isOpen = drawer.style.display !== \u0027none\u0027;\n drawer.style.display = isOpen ? \u0027none\u0027 : \u0027table-row\u0027;\n if (btn) btn.textContent = isOpen ? \u0027\u25B6\u0027 : \u0027\u25BC\u0027;\n };\n\n window.fryTblFilter = function(id, query) {\n var el = document.getElementById(id);\n if (!el) return;\n var q = (query || \u0027\u0027).toLowerCase().trim();\n var rows = el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027);\n var count = 0;\n rows.forEach(function(row) {\n var rIdx = row.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n var match = !q || row.innerText.toLowerCase().indexOf(q) !== -1;\n row.style.display = match ? \u0027\u0027 : \u0027none\u0027;\n if (!match \u0026\u0026 drawer) drawer.style.display = \u0027none\u0027;\n if (match) count\u002B\u002B;\n });\n var cnt = document.getElementById(id \u002B \u0027_counter\u0027);\n if (cnt) {\n cnt.textContent = q ? (\u0027Showing \u0027 \u002B count \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows (filtered)\u0027) : (\u0027Showing \u0027 \u002B rows.length \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows\u0027);\n }\n };\n\n window.fryTblSort = function(id, colIdx, isNum) {\n var el = document.getElementById(id);\n if (!el) return;\n var tbody = el.querySelector(\u0027tbody\u0027);\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var th = el.querySelectorAll(\u0027thead th\u0027)[colIdx \u002B 1];\n var dir = th.getAttribute(\u0027data-sort-dir\u0027) === \u0027asc\u0027 ? \u0027desc\u0027 : \u0027asc\u0027;\n\n el.querySelectorAll(\u0027thead th\u0027).forEach(function(h) {\n h.removeAttribute(\u0027data-sort-dir\u0027);\n var s = h.querySelector(\u0027.fry-sort-ind\u0027);\n if (s) s.textContent = \u0027\u2195\u0027;\n });\n\n th.setAttribute(\u0027data-sort-dir\u0027, dir);\n var ind = th.querySelector(\u0027.fry-sort-ind\u0027);\n if (ind) ind.textContent = dir === \u0027asc\u0027 ? \u0027\u25B2\u0027 : \u0027\u25BC\u0027;\n\n rows.sort(function(a, b) {\n var vA = a.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n var vB = b.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n if (isNum) {\n var nA = parseFloat(vA.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n var nB = parseFloat(vB.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n return dir === \u0027asc\u0027 ? nA - nB : nB - nA;\n }\n return dir === \u0027asc\u0027 ? vA.localeCompare(vB) : vB.localeCompare(vA);\n });\n\n rows.forEach(function(r) {\n var rIdx = r.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n tbody.appendChild(r);\n if (drawer) tbody.appendChild(drawer);\n });\n };\n\n window.fryTblCopy = function(id, format) {\n var el = document.getElementById(id);\n if (!el) return;\n var ths = Array.from(el.querySelectorAll(\u0027thead th\u0027)).slice(1);\n var headers = ths.map(function(h) { return h.innerText.replace(/[\u2195\u25B2\u25BC]/g, \u0027\u0027).trim(); });\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var data = rows.map(function(r) {\n return Array.from(r.children).slice(1).map(function(c) { return c.innerText.trim(); });\n });\n\n var payload = \u0027\u0027;\n if (format === \u0027json\u0027) {\n var list = data.map(function(r) {\n var o = {};\n headers.forEach(function(h, idx) { o[h] = r[idx]; });\n return o;\n });\n payload = JSON.stringify(list, null, 2);\n } else {\n payload = headers.join(\u0027,\u0027) \u002B \u0027\\n\u0027 \u002B data.map(function(r) {\n return r.map(function(val) { return \u0027\u0022\u0027 \u002B val.replace(/\u0022/g, \u0027\u0022\u0022\u0027) \u002B \u0027\u0022\u0027; }).join(\u0027,\u0027);\n }).join(\u0027\\n\u0027);\n }\n\n navigator.clipboard.writeText(payload).then(function() {\n var btn = el.querySelector(\u0027.fry-tbl-btn[onclick*=\u0022\u0027 \u002B format \u002B \u0027\u0022]\u0027);\n if (btn) {\n var prev = btn.textContent;\n btn.textContent = \u0027\u2713 Copied!\u0027;\n setTimeout(function() { btn.textContent = prev; }, 1500);\n }\n });\n };\n })();\n \u003C/script\u003E\n\u003C/div\u003E", + "TableSnapshot": { + "Title": "Records[4]", + "Label": null, + "Columns": [ + { + "Header": "Metric", + "IsNumeric": false + }, + { + "Header": "Value", + "IsNumeric": false + }, + { + "Header": "Status", + "IsNumeric": false + } + ], + "Rows": [ + { + "Cells": [ + { + "DisplayText": "Monitored Nodes", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "6", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Optimal", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "Mean CPU", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "57.4%", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Optimal", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "CPU Std Dev", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "30.5%", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Nominal", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "Peak Load Node", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-ai-06", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Investigate", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + } + ] + }, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "cell-polyglot-js-md", + "Type": 1, + "Source": "### 7. Node.js SLA Filtering Pipeline (#!share)", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-polyglot-js-code", + "Type": 0, + "Language": "javascript", + "Source": "#!share --from csharp clusterNodes --as nodes\n\nconsole.log(\u0060\u26A1 Node.js Kernel received ${nodes.length} cluster nodes from C#!\u0060);\n\nconst highLoadNodes = nodes.filter(n =\u003E n.CpuPercent \u003E 70);\nconsole.log(\u0060\uD83D\uDEA8 High Load Nodes (\u003E70% CPU):\u0060);\nhighLoadNodes.forEach(n =\u003E console.log(\u0060 - [${n.Region}] ${n.NodeId} (${n.Role}): ${n.CpuPercent}% CPU\u0060));\n\nconst totalLatency = nodes.reduce((sum, n) =\u003E sum \u002B n.LatencyMs, 0);\nconst avgLatency = (totalLatency / nodes.length).toFixed(1);\nconsole.log(\u0060\\n\u23F1\uFE0F Aggregated JavaScript SLA Latency: ${avgLatency} ms\u0060);\n\n// Display filtered high-load cluster nodes as interactive table from Node.js\ndisplay(highLoadNodes);", + "OutputText": "\u26A1 Node.js Kernel received 6 cluster nodes from C#!\n\uD83D\uDEA8 High Load Nodes (\u003E70% CPU):\n - [us-west-2] node-worker-02 (Compute Shard): 78.2% CPU\n - [sa-east-1] node-cache-05 (Redis In-Memory): 88.9% CPU\n - [ap-northeast-1] node-ai-06 (Tensor Pipeline): 92.1% CPU\n\n\u23F1\uFE0F Aggregated JavaScript SLA Latency: 33.5 ms\n", + "ExecutionCount": 7, + "IsExecuting": false, + "ExecutionTimeText": "224 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-tbl-container\u0022 id=\u0022fry_tbl_29ce62e8\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;margin:10px 0;max-width:960px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow:hidden;\u0022\u003E\n \u003Cstyle\u003E\n #fry_tbl_29ce62e8 .fry-tbl-header {\n display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 8px 12px; background: var(--vscode-sideBar-background, #161b22);\n border-bottom: 1px solid var(--vscode-widget-border, #30363d);\n }\n #fry_tbl_29ce62e8 .fry-tbl-toggle-btn {\n display: flex; align-items: center; gap: 8px; background: transparent;\n border: none; color: var(--vscode-foreground, #c9d1d9); cursor: pointer;\n padding: 0; font-size: 13px; font-weight: 600; text-align: left;\n }\n #fry_tbl_29ce62e8 .fry-tbl-pill {\n font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-badge-background, #21262d); border: 1px solid var(--vscode-widget-border, #30363d);\n padding: 2px 8px; border-radius: 12px;\n }\n #fry_tbl_29ce62e8 .fry-tbl-actions {\n display: flex; align-items: center; gap: 8px;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-wrap {\n position: relative; display: flex; align-items: center;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-input {\n background: var(--vscode-input-background, #0d1117);\n border: 1px solid var(--vscode-input-border, #30363d);\n color: var(--vscode-input-foreground, #c9d1d9);\n padding: 3px 8px 3px 24px; border-radius: 4px; font-size: 11.5px;\n width: 140px; transition: border-color 0.2s, width 0.2s;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-input:focus {\n outline: none; border-color: var(--vscode-focusBorder, #58a6ff); width: 180px;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-icon {\n position: absolute; left: 7px; font-size: 11px; opacity: 0.6; pointer-events: none;\n }\n #fry_tbl_29ce62e8 .fry-tbl-btn {\n background: var(--vscode-button-secondaryBackground, #21262d);\n color: var(--vscode-button-secondaryForeground, #c9d1d9);\n border: 1px solid var(--vscode-widget-border, #30363d);\n border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500;\n cursor: pointer; transition: background 0.15s;\n }\n #fry_tbl_29ce62e8 .fry-tbl-btn:hover {\n background: var(--vscode-button-secondaryHoverBackground, #30363d);\n }\n #fry_tbl_29ce62e8 .fry-tbl-scroll {\n max-height: 420px; overflow-y: auto; overflow-x: auto;\n }\n #fry_tbl_29ce62e8 table.fry-tbl-grid {\n width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;\n }\n #fry_tbl_29ce62e8 thead th {\n position: sticky; top: 0; z-index: 5; background: var(--vscode-sideBar-background, #161b22);\n padding: 8px 12px; border-bottom: 2px solid var(--vscode-widget-border, #30363d);\n font-weight: 600; color: var(--vscode-foreground, #c9d1d9); user-select: none;\n }\n #fry_tbl_29ce62e8 thead th.sortable:hover {\n background: var(--vscode-list-hoverBackground, #21262d);\n }\n #fry_tbl_29ce62e8 tbody tr.fry-data-row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.06);\n transition: background 0.12s;\n }\n #fry_tbl_29ce62e8 tbody tr.fry-data-row:hover {\n background: var(--vscode-list-hoverBackground, rgba(56, 139, 253, 0.08)) !important;\n }\n #fry_tbl_29ce62e8 td {\n padding: 7px 12px; vertical-align: middle;\n }\n #fry_tbl_29ce62e8 .fry-num {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right; color: #79c0ff;\n }\n #fry_tbl_29ce62e8 .fry-badge {\n display: inline-block; padding: 2px 7px; border-radius: 12px;\n font-size: 11px; font-weight: 500; letter-spacing: 0.2px;\n }\n #fry_tbl_29ce62e8 .fry-badge-green {\n background: rgba(35, 134, 54, 0.2); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-badge-amber {\n background: rgba(187, 128, 9, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-badge-red {\n background: rgba(218, 54, 51, 0.2); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-badge-blue {\n background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-expander-btn {\n background: transparent; border: none; cursor: pointer;\n color: var(--vscode-textLink-foreground, #58a6ff); font-size: 10px;\n padding: 3px 5px; border-radius: 4px; transition: transform 0.15s;\n }\n #fry_tbl_29ce62e8 .fry-expander-btn:hover {\n background: rgba(255,255,255,0.1);\n }\n #fry_tbl_29ce62e8 tr.fry-detail-row {\n background: rgba(0, 0, 0, 0.28);\n }\n #fry_tbl_29ce62e8 .fry-prop-grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; margin-top: 6px;\n }\n #fry_tbl_29ce62e8 .fry-prop-card {\n background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);\n border-radius: 4px; padding: 6px 10px;\n }\n #fry_tbl_29ce62e8 .fry-prop-key {\n font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px;\n }\n #fry_tbl_29ce62e8 .fry-prop-val {\n font-size: 12px; color: #c9d1d9; margin-top: 2px; word-break: break-word;\n }\n #fry_tbl_29ce62e8 .fry-tbl-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px 12px; font-size: 11px; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-sideBar-background, #161b22);\n border-top: 1px solid var(--vscode-widget-border, #30363d);\n }\n \u003C/style\u003E\n\n \u003Cdiv class=\u0022fry-tbl-header\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-toggle-btn\u0022 onclick=\u0022fryTblToggle(\u0027fry_tbl_29ce62e8\u0027)\u0022 title=\u0022Click to collapse/expand table\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-chevron\u0022 style=\u0022color:#58a6ff;\u0022\u003E\u25BC\u003C/span\u003E\n \u003Cspan\u003E\uD83D\uDCCA Array[3]\u003C/span\u003E\n \u003Cspan class=\u0022fry-tbl-pill\u0022\u003E3 rows \u00D7 7 cols\u003C/span\u003E\n \u003C/button\u003E\n \u003Cdiv class=\u0022fry-tbl-actions\u0022\u003E\n \u003Cdiv class=\u0022fry-tbl-search-wrap\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-search-icon\u0022\u003E\uD83D\uDD0D\u003C/span\u003E\n \u003Cinput type=\u0022text\u0022 class=\u0022fry-tbl-search-input\u0022 placeholder=\u0022Filter rows...\u0022 oninput=\u0022fryTblFilter(\u0027fry_tbl_29ce62e8\u0027, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_29ce62e8\u0027, \u0027csv\u0027)\u0022 title=\u0022Copy as CSV\u0022\u003ECSV\u003C/button\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_29ce62e8\u0027, \u0027json\u0027)\u0022 title=\u0022Copy as JSON\u0022\u003EJSON\u003C/button\u003E\n \u003C/div\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-scroll\u0022\u003E\n \u003Ctable class=\u0022fry-tbl-grid\u0022\u003E\n \u003Cthead\u003E\n \u003Ctr\u003E\n \u003Cth style=\u0022width:36px;text-align:center;padding:8px 4px;\u0022\u003E\u003C/th\u003E\n \u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 0, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Region\u0022\u003E\n Region \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 1, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by NodeId\u0022\u003E\n NodeId \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 2, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Role\u0022\u003E\n Role \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 3, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by CpuPercent\u0022\u003E\n CpuPercent \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 4, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by MemoryGb\u0022\u003E\n MemoryGb \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 5, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by LatencyMs\u0022\u003E\n LatencyMs \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 6, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Status\u0022\u003E\n Status \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\n \u003C/tr\u003E\n \u003C/thead\u003E\n \u003Ctbody\u003E\n \u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00220\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_29ce62e8\u0027, 0)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eus-west-2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-worker-02\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ECompute Shard\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E78.2\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E128\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E28.1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_29ce62e8_detail_0\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #1 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eus-west-2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-worker-02\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ECompute Shard\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E78.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E128\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E28.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00221\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_29ce62e8\u0027, 1)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Esa-east-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-cache-05\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ERedis In-Memory\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E88.9\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E64\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E68.5\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_29ce62e8_detail_1\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #2 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Esa-east-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-cache-05\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ERedis In-Memory\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E88.9\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E64\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E68.5\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00222\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_29ce62e8\u0027, 2)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eap-northeast-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-ai-06\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ETensor Pipeline\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E92.1\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E192\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E31.2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EHeavy Compute\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_29ce62e8_detail_2\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #3 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eap-northeast-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-ai-06\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ETensor Pipeline\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E92.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E192\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E31.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EHeavy Compute\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\n \u003C/tbody\u003E\n \u003C/table\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-footer\u0022\u003E\n \u003Cspan id=\u0022fry_tbl_29ce62e8_counter\u0022\u003EShowing 3 of 3 rows\u003C/span\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10.5px;\u0022\u003EFrySharp Polyglot Data\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Cscript\u003E\n (function() {\n if (window.__fryTblInit) return;\n window.__fryTblInit = true;\n\n window.fryTblToggle = function(id) {\n var el = document.getElementById(id);\n if (!el) return;\n var body = el.querySelector(\u0027.fry-tbl-scroll\u0027);\n var footer = el.querySelector(\u0027.fry-tbl-footer\u0027);\n var chevron = el.querySelector(\u0027.fry-tbl-chevron\u0027);\n var isHidden = body.style.display === \u0027none\u0027;\n body.style.display = isHidden ? \u0027block\u0027 : \u0027none\u0027;\n footer.style.display = isHidden ? \u0027flex\u0027 : \u0027none\u0027;\n if (chevron) chevron.textContent = isHidden ? \u0027\u25BC\u0027 : \u0027\u25B6\u0027;\n };\n\n window.fryTblToggleRow = function(id, rowIdx) {\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rowIdx);\n var el = document.getElementById(id);\n if (!drawer || !el) return;\n var btn = el.querySelector(\u0027tr[data-row=\u0022\u0027 \u002B rowIdx \u002B \u0027\u0022] .fry-expander-btn\u0027);\n var isOpen = drawer.style.display !== \u0027none\u0027;\n drawer.style.display = isOpen ? \u0027none\u0027 : \u0027table-row\u0027;\n if (btn) btn.textContent = isOpen ? \u0027\u25B6\u0027 : \u0027\u25BC\u0027;\n };\n\n window.fryTblFilter = function(id, query) {\n var el = document.getElementById(id);\n if (!el) return;\n var q = (query || \u0027\u0027).toLowerCase().trim();\n var rows = el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027);\n var count = 0;\n rows.forEach(function(row) {\n var rIdx = row.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n var match = !q || row.innerText.toLowerCase().indexOf(q) !== -1;\n row.style.display = match ? \u0027\u0027 : \u0027none\u0027;\n if (!match \u0026\u0026 drawer) drawer.style.display = \u0027none\u0027;\n if (match) count\u002B\u002B;\n });\n var cnt = document.getElementById(id \u002B \u0027_counter\u0027);\n if (cnt) {\n cnt.textContent = q ? (\u0027Showing \u0027 \u002B count \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows (filtered)\u0027) : (\u0027Showing \u0027 \u002B rows.length \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows\u0027);\n }\n };\n\n window.fryTblSort = function(id, colIdx, isNum) {\n var el = document.getElementById(id);\n if (!el) return;\n var tbody = el.querySelector(\u0027tbody\u0027);\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var th = el.querySelectorAll(\u0027thead th\u0027)[colIdx \u002B 1];\n var dir = th.getAttribute(\u0027data-sort-dir\u0027) === \u0027asc\u0027 ? \u0027desc\u0027 : \u0027asc\u0027;\n\n el.querySelectorAll(\u0027thead th\u0027).forEach(function(h) {\n h.removeAttribute(\u0027data-sort-dir\u0027);\n var s = h.querySelector(\u0027.fry-sort-ind\u0027);\n if (s) s.textContent = \u0027\u2195\u0027;\n });\n\n th.setAttribute(\u0027data-sort-dir\u0027, dir);\n var ind = th.querySelector(\u0027.fry-sort-ind\u0027);\n if (ind) ind.textContent = dir === \u0027asc\u0027 ? \u0027\u25B2\u0027 : \u0027\u25BC\u0027;\n\n rows.sort(function(a, b) {\n var vA = a.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n var vB = b.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n if (isNum) {\n var nA = parseFloat(vA.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n var nB = parseFloat(vB.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n return dir === \u0027asc\u0027 ? nA - nB : nB - nA;\n }\n return dir === \u0027asc\u0027 ? vA.localeCompare(vB) : vB.localeCompare(vA);\n });\n\n rows.forEach(function(r) {\n var rIdx = r.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n tbody.appendChild(r);\n if (drawer) tbody.appendChild(drawer);\n });\n };\n\n window.fryTblCopy = function(id, format) {\n var el = document.getElementById(id);\n if (!el) return;\n var ths = Array.from(el.querySelectorAll(\u0027thead th\u0027)).slice(1);\n var headers = ths.map(function(h) { return h.innerText.replace(/[\u2195\u25B2\u25BC]/g, \u0027\u0027).trim(); });\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var data = rows.map(function(r) {\n return Array.from(r.children).slice(1).map(function(c) { return c.innerText.trim(); });\n });\n\n var payload = \u0027\u0027;\n if (format === \u0027json\u0027) {\n var list = data.map(function(r) {\n var o = {};\n headers.forEach(function(h, idx) { o[h] = r[idx]; });\n return o;\n });\n payload = JSON.stringify(list, null, 2);\n } else {\n payload = headers.join(\u0027,\u0027) \u002B \u0027\\n\u0027 \u002B data.map(function(r) {\n return r.map(function(val) { return \u0027\u0022\u0027 \u002B val.replace(/\u0022/g, \u0027\u0022\u0022\u0027) \u002B \u0027\u0022\u0027; }).join(\u0027,\u0027);\n }).join(\u0027\\n\u0027);\n }\n\n navigator.clipboard.writeText(payload).then(function() {\n var btn = el.querySelector(\u0027.fry-tbl-btn[onclick*=\u0022\u0027 \u002B format \u002B \u0027\u0022]\u0027);\n if (btn) {\n var prev = btn.textContent;\n btn.textContent = \u0027\u2713 Copied!\u0027;\n setTimeout(function() { btn.textContent = prev; }, 1500);\n }\n });\n };\n })();\n \u003C/script\u003E\n\u003C/div\u003E", + "TableSnapshot": { + "Title": "Array[3]", + "Label": null, + "Columns": [ + { + "Header": "Region", + "IsNumeric": false + }, + { + "Header": "NodeId", + "IsNumeric": false + }, + { + "Header": "Role", + "IsNumeric": false + }, + { + "Header": "CpuPercent", + "IsNumeric": true + }, + { + "Header": "MemoryGb", + "IsNumeric": true + }, + { + "Header": "LatencyMs", + "IsNumeric": true + }, + { + "Header": "Status", + "IsNumeric": false + } + ], + "Rows": [ + { + "Cells": [ + { + "DisplayText": "us-west-2", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-worker-02", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Compute Shard", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "78.2", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "128", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "28.1", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Under Load", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "sa-east-1", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-cache-05", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Redis In-Memory", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "88.9", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "64", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "68.5", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Degraded", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "ap-northeast-1", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "node-ai-06", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Tensor Pipeline", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "92.1", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "192", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "31.2", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "Heavy Compute", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + } + ] + }, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "cell-closure-md", + "Type": 1, + "Source": "### 8. Triangular Closure in C#", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "cell-closure-code", + "Type": 0, + "Language": "csharp", + "Source": "#!share --from python mean_cpu\n#!share --from python std_dev\n\nConsole.WriteLine(\u0022\uD83E\uDE90 C# Kernel: Verifying Triangular Polyglot Closure\u0022);\nConsole.WriteLine($\u0022 \u2713 Verified Python mean_cpu: {mean_cpu:F2}%\u0022);\nConsole.WriteLine($\u0022 \u2713 Verified Python std_dev: {std_dev:F2}%\u0022);\nConsole.WriteLine(\u0022 \u2713 Cross-language polyglot state synchronization verified.\u0022);", + "OutputText": "\uD83E\uDE90 C# Kernel: Verifying Triangular Polyglot Closure\n \u2713 Verified Python mean_cpu: 57.42%\n \u2713 Verified Python std_dev: 30.54%\n \u2713 Cross-language polyglot state synchronization verified.\n", + "ExecutionCount": 8, + "IsExecuting": false, + "ExecutionTimeText": "38 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + } + ], + "Created": "2026-10-13T15:08:27.59171Z", + "LastModified": "2026-10-13T15:08:29.718301Z", + "ExecutionCount": 8 +} \ No newline at end of file diff --git a/samples/hello.cs b/samples/hello.cs new file mode 100644 index 00000000..31a35c41 --- /dev/null +++ b/samples/hello.cs @@ -0,0 +1,4 @@ +Console.WriteLine($"Hello from FrySharp CLI! Time is: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); +int sum = 0; +for (int i = 1; i <= 10; i++) sum += i; +Console.WriteLine($"Calculated sum of 1 to 10: {sum}"); diff --git a/tools/UiSnapshots/Program.cs b/tools/UiSnapshots/Program.cs index 716694ca..c7fe03b7 100644 --- a/tools/UiSnapshots/Program.cs +++ b/tools/UiSnapshots/Program.cs @@ -3,6 +3,7 @@ using PdfEditorApp.Plugins.CSharpEditor.Tools.UiSnapshots; using PdfEditorApp.Plugins.CSharpEditor.Tools.UiSnapshots.Commands; +PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn.DesktopScriptHost.Configure(); var options = new Options(args); if (options.Command is null or "help" or "-h") { diff --git a/tools/test-demo-notebook.js b/tools/test-demo-notebook.js new file mode 100644 index 00000000..42754fc5 --- /dev/null +++ b/tools/test-demo-notebook.js @@ -0,0 +1,89 @@ +const fs = require('fs'); +const cp = require('child_process'); +const path = require('path'); +const rpc = require(path.join(__dirname, '..', 'vscode-frysharp', 'node_modules', 'vscode-jsonrpc', 'node.js')); + +async function testDemoNotebook() { + console.log('🚀 Executing All Cells in samples/demo.frynb...'); + const workspaceRoot = path.resolve(__dirname, '..'); + const sidecarDll = path.join(workspaceRoot, 'vscode-frysharp', 'bin', 'sidecar', 'frysharp-sidecar.dll'); + + const child = cp.spawn('dotnet', [sidecarDll, '--workspace', workspaceRoot], { + cwd: workspaceRoot, + env: { ...process.env, DOTNET_NOLOGO: '1' } + }); + + const connection = rpc.createMessageConnection( + new rpc.StreamMessageReader(child.stdout), + new rpc.StreamMessageWriter(child.stdin) + ); + + let displaysByCell = new Map(); + let streamsByCell = new Map(); + + connection.onNotification('fry/notebook/output', (n) => { + if (n.output?.kind === 'display') { + const list = displaysByCell.get(n.cellId) || []; + list.push(Object.keys(n.output.data || {})); + displaysByCell.set(n.cellId, list); + } else if (n.output?.kind === 'stream') { + const prev = streamsByCell.get(n.cellId) || ''; + streamsByCell.set(n.cellId, prev + n.output.text); + } + }); + + connection.listen(); + + await connection.sendRequest('fry/initialize', { + clientId: 'demo-verifier', + clientName: 'Demo Notebook Verifier', + protocolVersion: 1 + }); + + const raw = fs.readFileSync(path.join(workspaceRoot, 'samples', 'demo.frynb'), 'utf8'); + const nb = JSON.parse(raw); + const codeCells = nb.Cells.filter(c => c.Type === 0); + + console.log(`Found ${nb.Cells.length} total cells (${codeCells.length} code cells to execute).\n`); + + let failures = 0; + for (let i = 0; i < codeCells.length; i++) { + const cell = codeCells[i]; + const lang = cell.Language || nb.Kernel || 'csharp'; + process.stdout.write(`[${i + 1}/${codeCells.length}] Cell '${cell.Id}' (${lang})... `); + + const res = await connection.sendRequest('fry/notebook/execute', { + path: 'samples/demo.frynb', + cellId: cell.Id, + source: cell.Source, + language: lang + }); + + if (res.success) { + const disp = displaysByCell.get(cell.Id); + const dispInfo = disp ? ` [Displays: ${disp.map(d => d.join(',')).join('; ')}]` : ''; + console.log(`✅ Success (${res.durationMs}ms)${dispInfo}`); + } else { + console.log(`❌ FAILED (${res.durationMs}ms)`); + const stream = streamsByCell.get(cell.Id); + if (stream) console.log(` Output: ${stream.trim()}`); + failures++; + } + } + + connection.dispose(); + child.kill(); + + console.log('\n----------------------------------------'); + if (failures === 0) { + console.log(`🎉 ALL ${codeCells.length} CELLS PASSED WITH ZERO ERRORS!`); + } else { + console.error(`💥 ${failures} cells failed execution.`); + process.exit(1); + } +} + +testDemoNotebook().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tools/test-extension-e2e.js b/tools/test-extension-e2e.js new file mode 100644 index 00000000..3ab008b5 --- /dev/null +++ b/tools/test-extension-e2e.js @@ -0,0 +1,189 @@ +const cp = require('child_process'); +const path = require('path'); +const rpc = require(path.join(__dirname, '..', 'vscode-frysharp', 'node_modules', 'vscode-jsonrpc', 'node.js')); + +async function main() { + console.log('🚀 Starting FrySharp Sidecar E2E Integration Test...'); + const workspaceRoot = path.resolve(__dirname, '..'); + const sidecarDll = path.join(workspaceRoot, 'vscode-frysharp', 'bin', 'sidecar', 'frysharp-sidecar.dll'); + + console.log(`📦 Sidecar binary: ${sidecarDll}`); + console.log(`📂 Workspace root: ${workspaceRoot}`); + + const child = cp.spawn('dotnet', [sidecarDll, '--workspace', workspaceRoot], { + cwd: workspaceRoot, + env: { ...process.env, DOTNET_NOLOGO: '1' } + }); + + child.stderr.on('data', (d) => { + // console.log(`[stderr] ${d.toString().trim()}`); + }); + + const connection = rpc.createMessageConnection( + new rpc.StreamMessageReader(child.stdout), + new rpc.StreamMessageWriter(child.stdin) + ); + + let capturedOutputs = []; + connection.onNotification('fry/notebook/output', (n) => { + if (n.output?.kind === 'stream') { + capturedOutputs.push(n.output.text); + } + }); + + connection.listen(); + + // 1. Initialize + console.log('\n--- Test 1: fry/initialize ---'); + const initResult = await connection.sendRequest('fry/initialize', { + clientId: 'e2e-tester', + clientName: 'Integration Tester', + protocolVersion: 1 + }); + console.log('Initialize result raw:', initResult); + const version = initResult.serverVersion || initResult.ServerVersion; + const langs = initResult.languages || initResult.Languages || []; + console.log(`✅ Sidecar initialized! Server version: ${version}`); + console.log(`✅ Languages registered: ${langs.map(l => `${l.id || l.Id} (${l.displayName || l.DisplayName})`).join(', ')}`); + + // 2. Execute C# Cell (State definition) + console.log('\n--- Test 2: Execute C# Cell (defining state) ---'); + capturedOutputs = []; + const csharpCode1 = ` +int x = 50; +int y = 75; +Console.WriteLine($"C# sum of {x} + {y} is: {x + y}"); +`; + const res1 = await connection.sendRequest('fry/notebook/execute', { + path: 'samples/demo.frynb', + cellId: 'csharp-state-1', + source: csharpCode1, + language: 'csharp' + }); + console.log(`✅ C# Cell 1 Result: success=${res1.success}, executionCount=${res1.executionCount}, time=${res1.durationMs}ms`); + console.log(`📢 Output: ${capturedOutputs.join('')}`.trim()); + + if (!res1.success || !capturedOutputs.some(o => o.includes('125'))) { + throw new Error('C# Cell 1 failed or output missing'); + } + + // 3. Execute second C# Cell (State continuity) + console.log('\n--- Test 3: Execute C# Cell (persisted session state) ---'); + capturedOutputs = []; + const csharpCode2 = ` +Console.WriteLine($"State continuity check: x was {x}, now x * 2 = {x * 2}"); +`; + const res2 = await connection.sendRequest('fry/notebook/execute', { + path: 'samples/demo.frynb', + cellId: 'csharp-state-2', + source: csharpCode2, + language: 'csharp' + }); + console.log(`✅ C# Cell 2 Result: success=${res2.success}, executionCount=${res2.executionCount}, time=${res2.durationMs}ms`); + console.log(`📢 Output: ${capturedOutputs.join('')}`.trim()); + + if (!res2.success || !capturedOutputs.some(o => o.includes('100'))) { + throw new Error('C# Cell 2 failed or state continuity broken'); + } + + // 4. Execute C# Cell with LINQ and records + console.log('\n--- Test 4: Execute modern C# (.NET 10 records & LINQ) ---'); + capturedOutputs = []; + const csharpCode3 = ` +record Person(string Name, int Score); +var list = new[] { new Person("Alice", 95), new Person("Bob", 88), new Person("Charlie", 99) }; +var top = list.OrderByDescending(p => p.Score).First(); +Console.WriteLine($"Top scorer: {top.Name} with score {top.Score}"); +`; + const res3 = await connection.sendRequest('fry/notebook/execute', { + path: 'samples/demo.frynb', + cellId: 'csharp-modern-3', + source: csharpCode3, + language: 'csharp' + }); + console.log(`✅ C# Cell 3 Result: success=${res3.success}, executionCount=${res3.executionCount}, time=${res3.durationMs}ms`); + console.log(`📢 Output: ${capturedOutputs.join('')}`.trim()); + + if (!res3.success || !capturedOutputs.some(o => o.includes('Charlie'))) { + throw new Error('C# Cell 3 failed'); + } + + // 5. Execute Python Cell + console.log('\n--- Test 5: Execute Python Cell ---'); + capturedOutputs = []; + try { + const pyCode = ` +import math +nums = [1, 2, 3, 4, 5] +sq_sum = sum(x**2 for x in nums) +print(f"Python calculated sum of squares: {sq_sum}") +`; + const resPy = await connection.sendRequest('fry/notebook/execute', { + path: 'samples/demo.frynb', + cellId: 'python-test-1', + source: pyCode, + language: 'python' + }); + console.log(`✅ Python Result: success=${resPy.success}, time=${resPy.durationMs}ms`); + console.log(`📢 Output: ${capturedOutputs.join('')}`.trim()); + } catch (err) { + console.log(`⚠️ Python execution notice: ${err.message}`); + } + + // 6. Execute JavaScript Cell + console.log('\n--- Test 6: Execute JavaScript Cell ---'); + capturedOutputs = []; + try { + const jsCode = ` +const items = ['alpha', 'beta', 'gamma']; +console.log('JavaScript transformed:', items.map(s => s.toUpperCase()).join(' | ')); +`; + const resJs = await connection.sendRequest('fry/notebook/execute', { + path: 'samples/demo.frynb', + cellId: 'javascript-test-1', + source: jsCode, + language: 'javascript' + }); + console.log(`✅ JavaScript Result: success=${resJs.success}, time=${resJs.durationMs}ms`); + console.log(`📢 Output: ${capturedOutputs.join('')}`.trim()); + } catch (err) { + console.log(`⚠️ JavaScript execution notice: ${err.message}`); + } + + // 7. Test standalone script run (RunService) + console.log('\n--- Test 7: Standalone C# script execution (samples/hello.cs) ---'); + let runOutputs = []; + connection.onNotification('fry/run/output', (n) => { + runOutputs.push(n.text); + }); + + const runPromise = new Promise((resolve) => { + connection.onNotification('fry/run/exited', (n) => { + resolve(n); + }); + }); + + const runStart = await connection.sendRequest('fry/run/start', { + runId: 'e2e-run-script-1', + path: 'samples/hello.cs' + }); + console.log(`✅ Run started: ${runStart.title}`); + const runExit = await runPromise; + console.log(`✅ Run exited with code ${runExit.exitCode} in ${runExit.durationMs}ms`); + console.log(`📢 Script output: ${runOutputs.join('')}`.trim()); + + if (runExit.exitCode !== 0 || !runOutputs.some(o => o.includes('Calculated sum'))) { + throw new Error('Script run failed or output missing'); + } + + console.log('\n🎉 ALL E2E INTEGRATION TESTS PASSED 100%!'); + + connection.dispose(); + child.kill(); + process.exit(0); +} + +main().catch(err => { + console.error('\n❌ E2E TEST FAILED:', err); + process.exit(1); +}); diff --git a/vscode-frysharp/.gitignore b/vscode-frysharp/.gitignore new file mode 100644 index 00000000..cd0a60e5 --- /dev/null +++ b/vscode-frysharp/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +out/ +dist/ +*.vsix +.vscode-test/ diff --git a/vscode-frysharp/.vscodeignore b/vscode-frysharp/.vscodeignore new file mode 100644 index 00000000..b05ce68b --- /dev/null +++ b/vscode-frysharp/.vscodeignore @@ -0,0 +1,5 @@ +.vscode/** +.vscode-test/** +src/** +tsconfig.json +**/*.map diff --git a/vscode-frysharp/LICENSE b/vscode-frysharp/LICENSE new file mode 100644 index 00000000..b5b31a03 --- /dev/null +++ b/vscode-frysharp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CodeFryDev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vscode-frysharp/README.md b/vscode-frysharp/README.md new file mode 100644 index 00000000..aad7abc4 --- /dev/null +++ b/vscode-frysharp/README.md @@ -0,0 +1,34 @@ +# FrySharp for Visual Studio Code + +**FrySharp** brings high-performance .NET 10 Roslyn scripting, polyglot notebooks (C#, Python, JavaScript, Java, SQL, Rust), and rich interactive visualizations directly into Visual Studio Code. + +Following Microsoft's deprecation of Polyglot Notebooks (.NET Interactive) and Azure Data Studio, **FrySharp** provides a fast, native replacement powered by a lightweight .NET sidecar process. + +--- + +## Features + +- **Polyglot Notebooks (`.frynb` & `.ipynb`)**: + - Run C# cells using in-process Roslyn compiler (.NET 10). + - Polyglot support: mix C#, Python, JavaScript, Java, and SQL in the same notebook. + - Cross-language variable sharing: `#!share --from [--as ]`. +- **Script Runner**: + - Run standalone C# (`.cs`, `.csx`), Python (`.py`), or JavaScript (`.js`) scripts with streaming stdout/stderr in the output channel. +- **Rich Visuals & Displays**: + - Full support for interactive tables, ECharts, 3D surface plots, and data structures. +- **Zero Configuration**: + - Automatically launches the headless `FrySharp.Sidecar` over stdio JSON-RPC. No network ports, no web servers, no auth tokens. + +--- + +## Commands + +- `FrySharp: New Polyglot Notebook` — Create a new `.frynb` notebook with C# and markdown cells. +- `FrySharp: Run Script with FrySharp` — Execute the active script file with the FrySharp engine. +- `FrySharp: Restart Engine Sidecar` — Restart the backend .NET execution sidecar. + +--- + +## Requirements + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) or later installed. diff --git a/vscode-frysharp/package-lock.json b/vscode-frysharp/package-lock.json new file mode 100644 index 00000000..7c6a442c --- /dev/null +++ b/vscode-frysharp/package-lock.json @@ -0,0 +1,70 @@ +{ + "name": "frysharp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frysharp", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/vscode": "^1.90.0", + "typescript": "^5.4.5" + }, + "engines": { + "vscode": "^1.90.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.140.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.140.0.tgz", + "integrity": "sha512-KAB5RBZIgnUhpp4mKsVJPPHLOQKu6WtGy2xpfWjaajEXO4xIIC+UESAXLiQ9ewx6QD1uBmvC/S8BJzkbvv4bRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/vscode-frysharp/package.json b/vscode-frysharp/package.json new file mode 100644 index 00000000..d12840f9 --- /dev/null +++ b/vscode-frysharp/package.json @@ -0,0 +1,134 @@ +{ + "name": "frysharp", + "displayName": "FrySharp: Polyglot Notebooks & Roslyn Scripting", + "description": "Interactive .NET 10 Roslyn scripting, polyglot notebooks (C#, Python, JavaScript), variable sharing, and rich visuals for VS Code", + "version": "1.0.0", + "publisher": "CodeFryDev", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/PrashantUnity/CSharpPlayground" + }, + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Programming Languages", + "Notebooks", + "Data Science" + ], + "keywords": [ + "csharp", + "c#", + "dotnet", + "notebook", + "polyglot", + "roslyn", + "jupyter", + "interactive" + ], + "activationEvents": [ + "onNotebook:frysharp-notebook", + "onCommand:frysharp.newNotebook", + "onCommand:frysharp.runScript" + ], + "main": "./out/extension.js", + "contributes": { + "notebooks": [ + { + "type": "frysharp-notebook", + "displayName": "FrySharp Polyglot Notebook", + "selector": [ + { + "filenamePattern": "*.frynb" + }, + { + "filenamePattern": "*.ipynb" + }, + { + "filenamePattern": "*.csnb" + } + ], + "priority": "default" + } + ], + "commands": [ + { + "command": "frysharp.newNotebook", + "title": "New Polyglot Notebook", + "category": "FrySharp" + }, + { + "command": "frysharp.runScript", + "title": "Run Script with FrySharp", + "category": "FrySharp" + }, + { + "command": "frysharp.restartSidecar", + "title": "Restart Engine Sidecar", + "category": "FrySharp" + }, + { + "command": "frysharp.restartNotebook", + "title": "Restart Kernel", + "category": "FrySharp", + "icon": "$(debug-restart)" + }, + { + "command": "frysharp.interruptNotebook", + "title": "Interrupt Kernel", + "category": "FrySharp", + "icon": "$(primitive-square)" + }, + { + "command": "frysharp.showVariables", + "title": "Variables View", + "category": "FrySharp", + "icon": "$(symbol-variable)" + }, + { + "command": "frysharp.clearNotebookOutputs", + "title": "Clear All Outputs", + "category": "FrySharp", + "icon": "$(clear-all)" + } + ], + "menus": { + "notebook/toolbar": [ + { + "command": "frysharp.restartNotebook", + "when": "notebookType == frysharp-notebook", + "group": "navigation@1" + }, + { + "command": "frysharp.interruptNotebook", + "when": "notebookType == frysharp-notebook", + "group": "navigation@2" + }, + { + "command": "frysharp.showVariables", + "when": "notebookType == frysharp-notebook", + "group": "navigation@3" + }, + { + "command": "frysharp.clearNotebookOutputs", + "when": "notebookType == frysharp-notebook", + "group": "navigation@4" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/vscode": "^1.90.0", + "typescript": "^5.4.5" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + } +} diff --git a/vscode-frysharp/src/extension.ts b/vscode-frysharp/src/extension.ts new file mode 100644 index 00000000..e640edb5 --- /dev/null +++ b/vscode-frysharp/src/extension.ts @@ -0,0 +1,189 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { FryNotebookController } from './notebook/notebookController'; +import { FryNotebookSerializer } from './notebook/notebookSerializer'; +import { showVariablesWebview } from './notebook/variablesView'; +import { SidecarClient } from './sidecar/sidecarClient'; + +let sidecar: SidecarClient | undefined; + +export async function activate(context: vscode.ExtensionContext) { + const outputChannel = vscode.window.createOutputChannel('FrySharp Script Runner'); + sidecar = new SidecarClient(context); + + // Auto-start sidecar + sidecar.start().catch((err) => { + vscode.window.showErrorMessage(`Failed to start FrySharp Sidecar: ${err.message}`); + }); + + // Register Notebook Serializer + context.subscriptions.push( + vscode.workspace.registerNotebookSerializer( + 'frysharp-notebook', + new FryNotebookSerializer(), + { transientOutputs: false } + ) + ); + + // Register Notebook Controller + const controller = new FryNotebookController(sidecar); + context.subscriptions.push(controller); + + // Register Output stream for Run Script + context.subscriptions.push( + sidecar.onRunOutput((n) => { + outputChannel.append(n.text); + }), + sidecar.onRunExited((n) => { + outputChannel.appendLine(`\n[Process exited with code ${n.exitCode} in ${n.durationMs}ms]`); + if (n.problems.length > 0) { + outputChannel.appendLine(`Problems (${n.problems.length}):`); + for (const p of n.problems) { + outputChannel.appendLine(` ${p.path || ''}(${p.line},${p.column}): ${p.severity} ${p.code}: ${p.message}`); + } + } + }) + ); + + // Command: New Polyglot Notebook + context.subscriptions.push( + vscode.commands.registerCommand('frysharp.newNotebook', async () => { + const initialData = new vscode.NotebookData([ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Markup, + '# Welcome to FrySharp Polyglot Notebook\nRun interactive C#, Python, JavaScript, and share variables across cells with `#!share`.', + 'markdown' + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '// C# .NET 10 Roslyn cell\nint a = 42;\nConsole.WriteLine($"Hello from FrySharp! a = {a}");', + 'csharp' + ) + ]); + initialData.metadata = { title: 'New Notebook', kernel: 'csharp' }; + + const doc = await vscode.workspace.openNotebookDocument('frysharp-notebook', initialData); + await vscode.window.showNotebookDocument(doc); + }) + ); + + // Command: Run Script with FrySharp + context.subscriptions.push( + vscode.commands.registerCommand('frysharp.runScript', async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage('No active script open to run.'); + return; + } + + const filePath = editor.document.uri.fsPath; + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/'); + + outputChannel.clear(); + outputChannel.show(true); + outputChannel.appendLine(`▶ Running ${path.basename(filePath)} with FrySharp...\n`); + + const runId = `run-${Date.now()}`; + try { + await sidecar!.startRun({ + runId, + path: relativePath + }); + } catch (err: any) { + vscode.window.showErrorMessage(`Run failed: ${err.message}`); + } + }) + ); + + // Command: Restart Sidecar + context.subscriptions.push( + vscode.commands.registerCommand('frysharp.restartSidecar', async () => { + await sidecar?.restart(); + vscode.window.showInformationMessage('FrySharp engine sidecar restarted successfully.'); + }) + ); + + function getTargetNotebook(arg?: any): { notebook: vscode.NotebookDocument; relativePath: string } | undefined { + let doc: vscode.NotebookDocument | undefined; + if (arg?.notebook) doc = arg.notebook; + else if (arg?.document) doc = arg.document; + else if (arg?.uri) { + doc = vscode.workspace.notebookDocuments.find(d => d.uri.toString() === arg.uri.toString()); + } else { + doc = vscode.window.activeNotebookEditor?.notebook; + } + + if (!doc) return undefined; + const filePath = doc.uri.fsPath; + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/'); + return { notebook: doc, relativePath }; + } + + // Command: Restart Notebook Kernel + context.subscriptions.push( + vscode.commands.registerCommand('frysharp.restartNotebook', async (arg?: any) => { + const target = getTargetNotebook(arg); + if (!target) { + vscode.window.showWarningMessage('No active FrySharp notebook found to restart.'); + return; + } + try { + await sidecar?.restartNotebook(target.relativePath); + vscode.window.showInformationMessage(`✓ FrySharp Kernel Restarted: session state and variables reset for ${path.basename(target.relativePath)}.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Restart failed: ${err.message}`); + } + }) + ); + + // Command: Interrupt Notebook Kernel + context.subscriptions.push( + vscode.commands.registerCommand('frysharp.interruptNotebook', async (arg?: any) => { + const target = getTargetNotebook(arg); + if (!target) { + vscode.window.showWarningMessage('No active FrySharp notebook found to interrupt.'); + return; + } + try { + await sidecar?.interruptNotebook(target.relativePath); + vscode.window.showInformationMessage(`⚠️ FrySharp Kernel Interrupted for ${path.basename(target.relativePath)}.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Interrupt failed: ${err.message}`); + } + }) + ); + + // Command: Show Polyglot Variables + context.subscriptions.push( + vscode.commands.registerCommand('frysharp.showVariables', async (arg?: any) => { + const target = getTargetNotebook(arg); + if (!target) { + vscode.window.showWarningMessage('No active FrySharp notebook found to inspect variables.'); + return; + } + try { + const vars = (await sidecar?.getVariables(target.relativePath)) || []; + showVariablesWebview(context, target.relativePath, vars, async () => { + return (await sidecar?.getVariables(target.relativePath)) || []; + }); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch variables: ${err.message}`); + } + }) + ); + + // Command: Clear All Outputs + context.subscriptions.push( + vscode.commands.registerCommand('frysharp.clearNotebookOutputs', async () => { + await vscode.commands.executeCommand('notebook.clearAllCellsOutputs'); + }) + ); + + context.subscriptions.push(sidecar); +} + +export function deactivate() { + sidecar?.dispose(); +} diff --git a/vscode-frysharp/src/notebook/notebookController.ts b/vscode-frysharp/src/notebook/notebookController.ts new file mode 100644 index 00000000..d46b3da9 --- /dev/null +++ b/vscode-frysharp/src/notebook/notebookController.ts @@ -0,0 +1,184 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { SidecarClient } from '../sidecar/sidecarClient'; +import { NotebookOutputNotification } from '../sidecar/protocol'; +import { FryNotebookSerializer } from './notebookSerializer'; + +export class FryNotebookController implements vscode.Disposable { + private controller: vscode.NotebookController; + private disposables: vscode.Disposable[] = []; + private activeExecutions = new Map(); + private pendingOutputs = new Map[]>(); + + constructor(private sidecar: SidecarClient) { + this.controller = vscode.notebooks.createNotebookController( + 'frysharp-kernel', + 'frysharp-notebook', + 'FrySharp Polyglot Kernel' + ); + + this.controller.supportedLanguages = ['csharp', 'python', 'javascript', 'java', 'sql', 'fsharp', 'rust', 'cpp']; + this.controller.supportsExecutionOrder = true; + this.controller.executeHandler = this.executeHandler.bind(this); + this.controller.interruptHandler = this.interruptHandler.bind(this); + + // Listen to output streamed from sidecar + this.disposables.push( + this.sidecar.onNotebookOutput(this.handleOutput.bind(this)) + ); + } + + private normalizePath(p: string): string { + return p.replace(/\\/g, '/').replace(/^\/+/, ''); + } + + private getRelativePath(notebook: vscode.NotebookDocument): string { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + let rel = workspaceRoot ? path.relative(workspaceRoot, notebook.uri.fsPath).replace(/\\/g, '/') : ''; + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + rel = path.basename(notebook.uri.fsPath) || 'untitled.frynb'; + } + return this.normalizePath(rel); + } + + private async executeHandler(cells: vscode.NotebookCell[], notebook: vscode.NotebookDocument): Promise { + const relativePath = this.getRelativePath(notebook); + + for (const cell of cells) { + await this.executeCell(cell, relativePath); + } + } + + private async executeCell(cell: vscode.NotebookCell, relativePath: string): Promise { + const execution = this.controller.createNotebookCellExecution(cell); + execution.executionOrder = (cell.executionSummary?.executionOrder || 0) + 1; + execution.start(Date.now()); + await execution.clearOutput(); + + const cellId = cell.metadata?.id || `cell-${cell.index + 1}`; + const key = `${relativePath}:${cellId}`; + this.activeExecutions.set(key, { execution, cell }); + this.pendingOutputs.set(key, []); + + execution.token.onCancellationRequested(async () => { + await this.sidecar.interruptNotebook(relativePath); + execution.end(false, Date.now()); + }); + + try { + const source = cell.document.getText(); + const language = cell.kind === vscode.NotebookCellKind.Code ? cell.document.languageId : undefined; + const result = await this.sidecar.executeCell(relativePath, cellId, source, language); + + // Wait for all in-flight output notifications to flush into the notebook cell + const pending = this.pendingOutputs.get(key) || []; + await Promise.all(pending); + // Brief pause to allow trailing notifications in the pipe + await new Promise((r) => setTimeout(r, 60)); + const trailing = this.pendingOutputs.get(key) || []; + await Promise.all(trailing); + + execution.executionOrder = result.executionCount; + execution.end(result.success, Date.now()); + } catch (err: any) { + const errorOutput = new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.error({ + name: 'ExecutionError', + message: err?.message || String(err) + }) + ]); + await execution.appendOutput(errorOutput); + execution.end(false, Date.now()); + } finally { + this.activeExecutions.delete(key); + this.pendingOutputs.delete(key); + } + } + + private async interruptHandler(notebook: vscode.NotebookDocument): Promise { + const relativePath = this.getRelativePath(notebook); + await this.sidecar.interruptNotebook(relativePath); + } + + private findExecution(notifPath: string, cellId: string): { execution?: vscode.NotebookCellExecution; cell?: vscode.NotebookCell; key: string } { + const norm = this.normalizePath(notifPath); + const exactKey = `${norm}:${cellId}`; + + if (this.activeExecutions.has(exactKey)) { + return { ...this.activeExecutions.get(exactKey)!, key: exactKey }; + } + + // Match by base name or cellId if single active + for (const [k, val] of this.activeExecutions.entries()) { + if (k.endsWith(`:${cellId}`) || k.includes(path.basename(notifPath))) { + return { ...val, key: k }; + } + } + + return { key: exactKey }; + } + + private async handleOutput(notification: NotebookOutputNotification): Promise { + const found = this.findExecution(notification.path, notification.cellId); + if (!found.execution) return; + + const execution = found.execution; + const key = found.key; + + const promise = (async () => { + try { + const out = notification.output; + if (out.kind === 'stream' && out.text) { + await execution.appendOutput(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(out.text, 'text/plain') + ])); + } else if (out.kind === 'display' && out.data) { + if (!out.data['text/html']) { + if (out.data['application/vnd.fry.table+json']) { + out.data['text/html'] = FryNotebookSerializer.formatTableJsonToHtml(out.data['application/vnd.fry.table+json']); + } else { + const imgKey = Object.keys(out.data).find(k => k.startsWith('image/') && k !== 'image/svg+xml'); + if (imgKey && typeof out.data[imgKey] === 'string') { + const b64 = out.data[imgKey]; + out.data['text/html'] = `
Image Output
`; + } + } + } + // Prioritize standard web types so VS Code renders text/html, image, and markdown first + const preferredOrder = ['text/html', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'text/markdown', 'text/plain']; + const entries = Object.entries(out.data).sort(([mimeA], [mimeB]) => { + const idxA = preferredOrder.indexOf(mimeA); + const idxB = preferredOrder.indexOf(mimeB); + const rankA = idxA >= 0 ? idxA : 100; + const rankB = idxB >= 0 ? idxB : 100; + return rankA - rankB; + }); + + const items = entries.map(([mime, val]) => { + let bytes: Uint8Array; + if (typeof val === 'string' && mime.startsWith('image/') && mime !== 'image/svg+xml') { + bytes = Buffer.from(val, 'base64'); + } else { + const text = typeof val === 'string' ? val : JSON.stringify(val); + bytes = new TextEncoder().encode(text); + } + return new vscode.NotebookCellOutputItem(bytes, mime); + }); + await execution.appendOutput(new vscode.NotebookCellOutput(items, out.metadata || undefined)); + } + } catch (err) { + console.error('[NotebookController] Error appending output:', err); + } + })(); + + const list = this.pendingOutputs.get(key) || []; + list.push(promise); + this.pendingOutputs.set(key, list); + await promise; + } + + public dispose(): void { + this.controller.dispose(); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/vscode-frysharp/src/notebook/notebookSerializer.ts b/vscode-frysharp/src/notebook/notebookSerializer.ts new file mode 100644 index 00000000..6e76e4d5 --- /dev/null +++ b/vscode-frysharp/src/notebook/notebookSerializer.ts @@ -0,0 +1,871 @@ +import * as vscode from 'vscode'; + +export class FryNotebookSerializer implements vscode.NotebookSerializer { + async deserializeNotebook(content: Uint8Array, token: vscode.CancellationToken): Promise { + const text = new TextDecoder().decode(content); + if (!text.trim()) { + return new vscode.NotebookData([]); + } + + let parsed: any; + try { + parsed = JSON.parse(text); + } catch { + return new vscode.NotebookData([]); + } + + // 1. Check if standard Jupyter format (.ipynb) + if (parsed.cells && Array.isArray(parsed.cells) && parsed.nbformat) { + return this.deserializeJupyter(parsed); + } + + // 2. Native FrySharp (.frynb / .csnb) format + // Accommodate both PascalCase (from C# System.Text.Json) and camelCase + const rawCells: any[] = parsed.Cells || parsed.cells || []; + const defaultLanguage: string = parsed.Kernel || parsed.kernel || 'csharp'; + const notebookTitle: string = parsed.Title || parsed.title || 'Untitled'; + const notebookId: string = parsed.Id || parsed.id || ''; + + const cells: vscode.NotebookCellData[] = rawCells.map((cell: any, idx: number) => { + const rawType = cell.Type ?? cell.type; + const isMarkdown = rawType === 1 || rawType === '1' || String(rawType).toLowerCase() === 'markdown'; + const kind = isMarkdown ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code; + + let source = ''; + if (typeof cell.Source === 'string') { + source = cell.Source; + } else if (typeof cell.source === 'string') { + source = cell.source; + } else if (Array.isArray(cell.Source)) { + source = cell.Source.join(''); + } else if (Array.isArray(cell.source)) { + source = cell.source.join(''); + } + + const explicitLanguage = cell.Language ?? cell.language; + const languageId = isMarkdown ? 'markdown' : (explicitLanguage || defaultLanguage); + + const cellData = new vscode.NotebookCellData(kind, source, languageId); + const cellId = cell.Id ?? cell.id ?? `cell-${idx + 1}`; + cellData.metadata = { id: cellId }; + + const execCount = cell.ExecutionCount ?? cell.executionCount; + if (execCount != null) { + cellData.executionSummary = { executionOrder: execCount }; + } + + // Restore cell outputs + const outputs: vscode.NotebookCellOutput[] = []; + + // 2a. OutputText (console stream) + const outputText = cell.OutputText ?? cell.outputText; + if (outputText && typeof outputText === 'string' && outputText.length > 0) { + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(outputText, 'text/plain') + ])); + } + + // 2b. HtmlContent + const htmlContent = cell.HtmlContent ?? cell.htmlContent; + if (htmlContent && typeof htmlContent === 'string' && htmlContent.length > 0) { + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(htmlContent, 'text/html') + ])); + } else { + // Fallback: Check if TableSnapshot is saved + const tableSnapshot = cell.TableSnapshot ?? cell.tableSnapshot; + if (tableSnapshot && Array.isArray(tableSnapshot.Columns || tableSnapshot.columns)) { + const tableHtml = FryNotebookSerializer.formatTableSnapshotToHtml(tableSnapshot); + if (tableHtml) { + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(tableHtml, 'text/html') + ])); + } + } + } + + // 2b-2. Visuals array (saved charts & 3D plots) + const visuals = cell.Visuals ?? cell.visuals; + if (Array.isArray(visuals)) { + for (const viz of visuals) { + const vizHtml = this.formatVisualToHtml(viz); + if (vizHtml) { + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(vizHtml, 'text/html') + ])); + } + } + } + + // 2c. ImageBytes (base64 string or byte array) + const rawImage = cell.ImageBytes ?? cell.imageBytes; + if (rawImage) { + try { + let bytes: Uint8Array; + if (typeof rawImage === 'string') { + bytes = Uint8Array.from(Buffer.from(rawImage, 'base64')); + } else if (Array.isArray(rawImage)) { + bytes = new Uint8Array(rawImage); + } else { + bytes = new Uint8Array(); + } + + if (bytes.length > 0) { + const format = (cell.ImageFormat ?? cell.imageFormat ?? 'png').toLowerCase(); + const mime = format.startsWith('image/') ? format : `image/${format}`; + const b64 = typeof rawImage === 'string' ? rawImage : Buffer.from(bytes).toString('base64'); + const html = `
Synthesized Image
`; + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(html, 'text/html'), + new vscode.NotebookCellOutputItem(bytes, mime) + ])); + } + } catch { + // ignore corrupted image decode + } + } + + // 2d. Explicit outputs array (if present from mixed format) + const explicitOutputs = cell.outputs ?? cell.Outputs; + if (Array.isArray(explicitOutputs)) { + for (const out of explicitOutputs) { + if (out.kind === 'stream' && out.text) { + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(out.text, 'text/plain') + ])); + } else if (out.kind === 'display' && out.data) { + if (!out.data['text/html']) { + if (out.data['application/vnd.fry.table+json']) { + out.data['text/html'] = FryNotebookSerializer.formatTableJsonToHtml(out.data['application/vnd.fry.table+json']); + } else { + const imgKey = Object.keys(out.data).find(k => k.startsWith('image/') && k !== 'image/svg+xml'); + if (imgKey && typeof out.data[imgKey] === 'string') { + const b64 = out.data[imgKey]; + out.data['text/html'] = `
Synthesized Image
`; + } + } + } + const preferredOrder = ['text/html', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'text/markdown', 'text/plain']; + const entries = Object.entries(out.data).sort(([mimeA], [mimeB]) => { + const idxA = preferredOrder.indexOf(mimeA); + const idxB = preferredOrder.indexOf(mimeB); + const rankA = idxA >= 0 ? idxA : 100; + const rankB = idxB >= 0 ? idxB : 100; + return rankA - rankB; + }); + + const items = entries.map(([mime, val]) => { + let bytes: Uint8Array; + const str = Array.isArray(val) ? val.join('') : (typeof val === 'string' ? val : JSON.stringify(val)); + if (typeof str === 'string' && mime.startsWith('image/') && mime !== 'image/svg+xml') { + bytes = Buffer.from(str, 'base64'); + } else { + bytes = new TextEncoder().encode(str); + } + return new vscode.NotebookCellOutputItem(bytes, mime); + }); + outputs.push(new vscode.NotebookCellOutput(items, out.metadata || undefined)); + } + } + } + + if (outputs.length > 0) { + cellData.outputs = outputs; + } + + return cellData; + }); + + const notebookData = new vscode.NotebookData(cells); + notebookData.metadata = { + isJupyter: false, + id: notebookId, + title: notebookTitle, + kernel: defaultLanguage, + schemaVersion: parsed.SchemaVersion || parsed.schemaVersion || '1.0' + }; + return notebookData; + } + + async serializeNotebook(data: vscode.NotebookData, token: vscode.CancellationToken): Promise { + // 1. If original was Jupyter (.ipynb), serialize back as Jupyter notebook + if (data.metadata?.isJupyter) { + const cells = data.cells.map((cell, idx) => { + const isMarkdown = cell.kind === vscode.NotebookCellKind.Markup; + const sourceLines = cell.value.split('\n').map((line, i, arr) => i < arr.length - 1 ? line + '\n' : line); + + if (isMarkdown) { + return { + cell_type: 'markdown', + id: cell.metadata?.id || `cell-${idx + 1}`, + metadata: {}, + source: sourceLines + }; + } + + const outputs: any[] = []; + for (const out of cell.outputs || []) { + for (const item of out.items || []) { + if (item.mime === 'text/plain') { + outputs.push({ + output_type: 'stream', + name: 'stdout', + text: new TextDecoder().decode(item.data).split('\n').map((l, i, a) => i < a.length - 1 ? l + '\n' : l) + }); + } else if (item.mime.startsWith('image/')) { + outputs.push({ + output_type: 'display_data', + data: { + [item.mime]: Buffer.from(item.data).toString('base64'), + 'text/plain': ['
'] + }, + metadata: out.metadata || {} + }); + } else { + outputs.push({ + output_type: 'display_data', + data: { + [item.mime]: new TextDecoder().decode(item.data) + }, + metadata: out.metadata || {} + }); + } + } + } + + return { + cell_type: 'code', + execution_count: cell.executionSummary?.executionOrder ?? null, + id: cell.metadata?.id || `cell-${idx + 1}`, + metadata: { + polyglot_notebook: { kernelName: cell.languageId } + }, + outputs, + source: sourceLines + }; + }); + + const ipynb = { + cells, + metadata: data.metadata?.rawMetadata || { + kernelspec: { display_name: '.NET (C#)', language: 'C#', name: '.net-csharp' }, + language_info: { name: 'csharp' } + }, + nbformat: data.metadata?.nbformat || 4, + nbformat_minor: data.metadata?.nbformat_minor || 5 + }; + + return new TextEncoder().encode(JSON.stringify(ipynb, null, 2)); + } + + // 2. Native FrySharp (.frynb / .csnb) format matching C# NotebookDocumentItem & NotebookCellItem + const defaultLanguage = data.metadata?.kernel || 'csharp'; + const cells = data.cells.map((cell, idx) => { + const isMarkdown = cell.kind === vscode.NotebookCellKind.Markup; + let outputText = ''; + let imageBytes: string | null = null; + let imageFormat: string | null = null; + let htmlContent: string | null = null; + + for (const out of cell.outputs || []) { + for (const item of out.items || []) { + if (item.mime === 'text/plain') { + outputText += new TextDecoder().decode(item.data); + } else if (item.mime.startsWith('image/')) { + imageFormat = item.mime.replace('image/', '').toUpperCase(); + imageBytes = Buffer.from(item.data).toString('base64'); + } else if (item.mime === 'text/html') { + htmlContent = new TextDecoder().decode(item.data); + } + } + } + + return { + Id: cell.metadata?.id || `cell-${idx + 1}`, + Type: isMarkdown ? 1 : 0, + Language: isMarkdown ? undefined : (cell.languageId !== defaultLanguage ? cell.languageId : undefined), + Source: cell.value, + OutputText: outputText, + ExecutionCount: cell.executionSummary?.executionOrder ?? null, + IsExecuting: false, + ExecutionTimeText: '', + HasError: false, + IsMarkdownPreviewMode: isMarkdown, + IsInputCollapsed: false, + IsOutputCollapsed: false, + IsOutputScrolled: false, + ImageBytes: imageBytes, + ImageFormat: imageFormat, + ImageWidth: null, + ImageHeight: null, + HtmlContent: htmlContent, + TableSnapshot: null, + InspectorSnapshot: null, + HadInteractiveControl: false, + HasImage: !!imageBytes, + HasHtml: !!htmlContent, + HasOutput: !!(outputText || imageBytes || htmlContent), + IsCodeCell: !isMarkdown, + IsMarkdownCell: isMarkdown + }; + }); + + const frynb = { + SchemaVersion: data.metadata?.schemaVersion || '1.0', + Type: 'notebook', + Id: data.metadata?.id || 'notebook-1', + Title: data.metadata?.title || 'Notebook', + Description: data.metadata?.description || '', + Category: data.metadata?.category || 'Interactive', + Kernel: defaultLanguage, + Cells: cells + }; + + return new TextEncoder().encode(JSON.stringify(frynb, null, 2)); + } + + private deserializeJupyter(ipynb: any): vscode.NotebookData { + const defaultLanguage = ipynb.metadata?.language_info?.name || 'csharp'; + const cells: vscode.NotebookCellData[] = (ipynb.cells || []).map((cell: any, idx: number) => { + const isMarkdown = cell.cell_type === 'markdown'; + const kind = isMarkdown ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code; + const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); + const cellLang = cell.metadata?.polyglot_notebook?.kernelName || cell.metadata?.dotnet_interactive?.language || defaultLanguage; + const languageId = isMarkdown ? 'markdown' : cellLang; + + const cellData = new vscode.NotebookCellData(kind, source, languageId); + cellData.metadata = { id: cell.id || `cell-${idx + 1}`, ...cell.metadata }; + + if (cell.execution_count != null) { + cellData.executionSummary = { executionOrder: cell.execution_count }; + } + + if (cell.outputs && Array.isArray(cell.outputs)) { + const outputs: vscode.NotebookCellOutput[] = []; + for (const out of cell.outputs) { + if (out.output_type === 'stream') { + const text = Array.isArray(out.text) ? out.text.join('') : (out.text || ''); + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.text(text, 'text/plain') + ])); + } else if (out.output_type === 'error') { + const traceback = Array.isArray(out.traceback) ? out.traceback.join('\n') : (out.traceback || out.evalue || ''); + outputs.push(new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.error({ + name: out.ename || 'Error', + message: out.evalue || '', + stack: traceback + }) + ])); + } else if (out.data && typeof out.data === 'object') { + const items = Object.entries(out.data).map(([mime, val]) => { + let bytes: Uint8Array; + const str = Array.isArray(val) ? val.join('') : (typeof val === 'string' ? val : JSON.stringify(val)); + if (typeof str === 'string' && mime.startsWith('image/') && mime !== 'image/svg+xml') { + bytes = Buffer.from(str, 'base64'); + } else { + bytes = new TextEncoder().encode(str); + } + return new vscode.NotebookCellOutputItem(bytes, mime); + }); + outputs.push(new vscode.NotebookCellOutput(items, out.metadata)); + } + } + if (outputs.length > 0) { + cellData.outputs = outputs; + } + } + + return cellData; + }); + + const notebookData = new vscode.NotebookData(cells); + notebookData.metadata = { + isJupyter: true, + rawMetadata: ipynb.metadata, + nbformat: ipynb.nbformat || 4, + nbformat_minor: ipynb.nbformat_minor || 5 + }; + return notebookData; + } + + public static formatTableJsonToHtml(tableData: any): string { + if (!tableData) return ''; + const cols = (tableData.columns || tableData.Columns || []).map((c: any, i: number) => ({ + Header: typeof c === 'string' ? c : (c.Header || c.header || `Col ${i + 1}`), + IsNumeric: !!(tableData.numeric?.[i] || c.IsNumeric || c.isNumeric) + })); + const rows = (tableData.rows || tableData.Rows || []).map((r: any) => ({ + Cells: Array.isArray(r) + ? r.map((cellVal: any) => ({ DisplayText: cellVal == null ? '' : String(cellVal), IsNull: cellVal == null })) + : (r.Cells || r.cells || []) + })); + return FryNotebookSerializer.formatTableSnapshotToHtml({ + Title: tableData.title || tableData.Title, + Columns: cols, + Rows: rows + }); + } + + public static formatTableSnapshotToHtml(table: any): string { + const cols = table.Columns || table.columns; + const rows = table.Rows || table.rows; + if (!Array.isArray(cols) || !Array.isArray(rows)) return ''; + + const uid = 'fry_tbl_' + Math.random().toString(36).slice(2, 9); + const title = table.Title || table.title || table.Label || table.label || 'Data Table'; + const escape = (s: string) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const rowCount = rows.length; + const colCount = cols.length; + + const formatCell = (val: any, isNum: boolean): { html: string; css: string } => { + if (val == null || val?.IsNull || val?.isNull) { + return { html: 'null', css: '' }; + } + const text = String(val?.DisplayText ?? val?.displayText ?? val); + const lower = text.trim().toLowerCase(); + if (['online', 'active', 'healthy', 'optimal', 'passed', 'success', 'true', 'running', 'ready', 'connected'].includes(lower)) { + return { html: `${escape(text)}`, css: '' }; + } + if (['warning', 'degraded', 'under load', 'pending', 'slow', 'paused', 'retrying'].includes(lower)) { + return { html: `${escape(text)}`, css: '' }; + } + if (['error', 'failed', 'critical', 'offline', 'down', 'false', 'stopped'].includes(lower)) { + return { html: `${escape(text)}`, css: '' }; + } + if (isNum) { + return { html: escape(text), css: 'fry-num' }; + } + return { html: escape(text), css: '' }; + }; + + let headerHtml = ''; + cols.forEach((col: any, cIdx: number) => { + const isNum = !!(col.IsNumeric ?? col.isNumeric); + const align = isNum ? 'right' : 'left'; + const colHeader = escape(col.Header || col.header || `Col ${cIdx + 1}`); + headerHtml += `${colHeader} ↕`; + }); + + let rowsHtml = ''; + rows.forEach((row: any, rIdx: number) => { + const bg = (rIdx % 2 === 0) ? 'background:rgba(255,255,255,0.015);' : 'background:transparent;'; + const cells = row.Cells || row.cells || []; + let cellsHtml = ''; + let propCardsHtml = ''; + + cols.forEach((col: any, cIdx: number) => { + const isNum = !!(col.IsNumeric ?? col.isNumeric); + const align = isNum ? 'right' : 'left'; + const cell = cells[cIdx]; + const { html, css } = formatCell(cell, isNum); + cellsHtml += `${html}`; + propCardsHtml += `
${escape(col.Header || col.header || '')}
${html}
`; + }); + + rowsHtml += ` + + + + ${cellsHtml} + + + +
+
+
🔍 Row #${rIdx + 1} Inspector
+
${colCount} properties
+
+
${propCardsHtml}
+
+ + `; + }); + + return `
+ +
+ +
+
+ 🔍 + +
+ + +
+
+
+ + ${headerHtml} + ${rowsHtml} +
+
+ + +
`; + } + + private formatVisualToHtml(visual: any): string { + const data = visual?.Data || visual?.data; + if (!data) return ''; + const mime = visual?.MimeType || visual?.mimeType || ''; + const escape = (s: string) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const title = escape(data.title || data.Title || (mime.includes('plot3d') ? '3D Plot' : 'Chart')); + const palette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#2dd4bf']; + + if (mime.includes('chart') && Array.isArray(data.series)) { + const isBar = data.kind === 'bar'; + const isPie = data.kind === 'pie' || data.kind === 'donut'; + if (isPie) { + const s = data.series[0] || {}; + const yVals = (s.y || []).filter((v: any) => typeof v === 'number' && v > 0); + const total = yVals.reduce((a: number, b: number) => a + b, 0) || 1; + let angle = -Math.PI / 2; + let paths = ''; + let leg = ''; + (s.y || []).forEach((v: number, idx: number) => { + if (typeof v !== 'number' || v <= 0) return; + const slice = (v / total) * 2 * Math.PI; + const next = angle + slice; + const col = palette[idx % palette.length]; + const x1 = 260 + 110 * Math.cos(angle); + const y1 = 150 + 110 * Math.sin(angle); + const x2 = 260 + 110 * Math.cos(next); + const y2 = 150 + 110 * Math.sin(next); + const lbl = s.labels?.[idx] || `Item ${idx + 1}`; + const pct = ((v / total) * 100).toFixed(1); + const large = slice > Math.PI ? 1 : 0; + paths += `${escape(lbl)}: ${v} (${pct}%)`; + leg += `
${escape(lbl)} (${pct}%)
`; + angle = next; + }); + const uid = 'fry_pie_' + Math.random().toString(36).slice(2, 9); + return `
+ +
📊 ${title}
+
+ ${paths} +
${leg}
+
+
`; + } + + let minY = Infinity, maxY = -Infinity; + let maxLen = 0; + data.series.forEach((s: any) => { + const y = s.y || []; + maxLen = Math.max(maxLen, y.length); + y.forEach((v: any) => { + if (typeof v === 'number' && Number.isFinite(v)) { + minY = Math.min(minY, v); + maxY = Math.max(maxY, v); + } + }); + }); + if (minY > maxY) { minY = 0; maxY = 100; } + if (Math.abs(maxY - minY) < 1e-9) { maxY += 10; minY -= 10; } + if (minY > 0 && isBar) minY = 0; + + let seriesSvg = ''; + if (isBar && maxLen > 0) { + const gw = 640 / maxLen; + const bw = Math.max(4, (gw * 0.7) / data.series.length); + data.series.forEach((s: any, sIdx: number) => { + const col = s.color || palette[sIdx % palette.length]; + (s.y || []).forEach((y: any, pIdx: number) => { + if (typeof y !== 'number') return; + const bx = 60 + pIdx * gw + (gw * 0.15) + sIdx * bw; + const zy = 30 + ((maxY - 0) / (maxY - minY)) * 260; + const by = 30 + ((maxY - y) / (maxY - minY)) * 260; + const top = Math.min(zy, by); + const h = Math.abs(zy - by); + seriesSvg += `${escape(s.name || '')}: ${y}`; + }); + }); + } else if (maxLen > 1) { + data.series.forEach((s: any, sIdx: number) => { + const col = s.color || palette[sIdx % palette.length]; + const y = s.y || []; + const pts: string[] = []; + y.forEach((val: any, pIdx: number) => { + if (typeof val !== 'number') return; + const px = 60 + (pIdx / (maxLen - 1)) * 640; + const py = 30 + ((maxY - val) / (maxY - minY)) * 260; + pts.push(`${px.toFixed(1)},${py.toFixed(1)}`); + }); + if (pts.length > 1) { + seriesSvg += ``; + } + }); + } + + return `
+
📊 ${title}
+ + + ${seriesSvg} + +
`; + } + + if (mime.includes('plot3d') && data.surface?.z) { + const zGrid = data.surface.z; + const rows = zGrid.length; + const cols = zGrid[0]?.length || 0; + let minZ = Infinity, maxZ = -Infinity; + zGrid.forEach((row: any[]) => row.forEach(v => { + if (typeof v === 'number') { minZ = Math.min(minZ, v); maxZ = Math.max(maxZ, v); } + })); + if (minZ >= maxZ) { minZ = -1; maxZ = 1; } + + const uid = 'fry_p3d_' + Math.random().toString(36).slice(2, 9); + const gridData = JSON.stringify(zGrid.map((row: any[], r: number) => + row.map((zv: any, c: number) => { + const u = (c / (cols - 1)) * 2.0 - 1.0; + const v = (r / (rows - 1)) * 2.0 - 1.0; + const zNorm = ((typeof zv === 'number' ? zv : 0) - minZ) / (maxZ - minZ); + return [Number(u.toFixed(3)), Number(v.toFixed(3)), Number(zNorm.toFixed(3))]; + }) + )); + + return `
+
+
🏔️ ${title}
+
+
+ 🔄 Rotate + +
+
+ 📐 Tilt + +
+
+
+ + + + +
`; + } + + return ''; + } +} + diff --git a/vscode-frysharp/src/notebook/variablesView.ts b/vscode-frysharp/src/notebook/variablesView.ts new file mode 100644 index 00000000..b367a6aa --- /dev/null +++ b/vscode-frysharp/src/notebook/variablesView.ts @@ -0,0 +1,305 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { VariableDto } from '../sidecar/protocol'; + +let currentPanel: vscode.WebviewPanel | undefined; + +export function showVariablesWebview( + context: vscode.ExtensionContext, + notebookPath: string, + initialVariables: VariableDto[], + fetchVariables: () => Promise +): void { + const title = `Variables: ${path.basename(notebookPath)}`; + + if (currentPanel) { + currentPanel.title = title; + currentPanel.reveal(vscode.ViewColumn.Beside, true); + currentPanel.webview.html = renderVariablesHtml(notebookPath, initialVariables); + return; + } + + currentPanel = vscode.window.createWebviewPanel( + 'frysharpVariables', + title, + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + { + enableScripts: true, + retainContextWhenHidden: true + } + ); + + currentPanel.webview.html = renderVariablesHtml(notebookPath, initialVariables); + + currentPanel.webview.onDidReceiveMessage(async (message) => { + if (message.command === 'refresh') { + try { + const vars = await fetchVariables(); + if (currentPanel) { + currentPanel.webview.html = renderVariablesHtml(notebookPath, vars); + } + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to refresh variables: ${err.message}`); + } + } else if (message.command === 'copyShare') { + await vscode.env.clipboard.writeText(message.snippet); + vscode.window.showInformationMessage(`Copied to clipboard: ${message.snippet}`); + } + }); + + currentPanel.onDidDispose(() => { + currentPanel = undefined; + }); +} + +function renderVariablesHtml(notebookPath: string, variables: VariableDto[]): string { + const fileName = path.basename(notebookPath); + + const rows = variables.map((v) => { + const kernelLower = (v.kernel || '').toLowerCase(); + let badgeClass = 'badge-csharp'; + let kernelName = 'C#'; + + if (kernelLower.includes('python')) { + badgeClass = 'badge-python'; + kernelName = 'Python'; + } else if (kernelLower.includes('node') || kernelLower.includes('js') || kernelLower.includes('javascript')) { + badgeClass = 'badge-js'; + kernelName = 'Node.js'; + } + + const shareDirective = `#!share --from ${kernelName.toLowerCase() === 'python' ? 'python' : kernelName.toLowerCase() === 'node.js' ? 'javascript' : 'csharp'} ${v.name}`; + + return ` + + ${escapeHtml(v.name)} + ${escapeHtml(kernelName)} + ${escapeHtml(v.type)} + ${escapeHtml(v.value)} + + + + + `; + }).join(''); + + const emptyState = ` +
+
🪐
+
No Active Variables in Memory
+
Execute code cells in C#, Python, or JavaScript to populate session variables.
+
+ `; + + return ` + + + + + FrySharp Variables + + + +
+

🪐 Polyglot Variables • ${escapeHtml(fileName)}

+ +
+ + ${variables.length > 0 ? ` +
+ + + + + + + + + + + + ${rows} + +
NameKernelTypeValueShare
+
+ ` : emptyState} + + + +`; +} + +function escapeHtml(text: string): string { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function escapeAttr(text: string): string { + return String(text || '') + .replace(/"/g, '"') + .replace(/'/g, "\\'"); +} diff --git a/vscode-frysharp/src/sidecar/protocol.ts b/vscode-frysharp/src/sidecar/protocol.ts new file mode 100644 index 00000000..892fe5e1 --- /dev/null +++ b/vscode-frysharp/src/sidecar/protocol.ts @@ -0,0 +1,125 @@ +// Types matching FrySharp.Sidecar.Protocol + +export interface InitializeParams { + clientId: string; + clientName?: string; + protocolVersion: number; +} + +export interface LanguageInfo { + id: string; + displayName: string; + shortName: string; + extensions: string[]; + aliases: string[]; + lineComment: string; + icon: string; + accent: string; + canRun: boolean; + canRunCells: boolean; + hasCompletion: boolean; + newFileTemplate: string; +} + +export interface InitializeResult { + protocolVersion: number; + serverVersion: string; + workspaceRoot: string; + workspaceName: string; + languages: LanguageInfo[]; + resumed: boolean; + maxOpenFileBytes: number; +} + +export interface CellOutput { + kind: 'stream' | 'display' | 'error'; + text?: string; + data?: Record; + metadata?: Record; +} + +export interface NotebookCell { + id: string; + type: 'code' | 'markdown'; + language?: string; + source: string; + executionCount?: number; + hasError: boolean; + outputs: CellOutput[]; +} + +export interface NotebookContent { + path: string; + format: 'frynb' | 'ipynb'; + title: string; + defaultLanguage: string; + diskVersion: string; + cells: NotebookCell[]; +} + +export interface ExecuteCellParams { + path: string; + cellId: string; + source: string; + language?: string; +} + +export interface ExecuteCellResult { + executionCount: number; + success: boolean; + durationMs: number; +} + +export interface NotebookOutputNotification { + path: string; + cellId: string; + output: CellOutput; +} + +export interface CellStateNotification { + path: string; + cellId: string; + running: boolean; + executionCount?: number; + success?: boolean; + durationMs?: number; +} + +export interface RunStartParams { + runId: string; + path?: string; + code?: string; + languageId?: string; + arguments?: string[]; +} + +export interface RunStartResult { + runId: string; + title: string; +} + +export interface RunOutputNotification { + runId: string; + text: string; +} + +export interface RunExitedNotification { + runId: string; + exitCode: number; + durationMs: number; + problems: Array<{ + path?: string; + severity: string; + code: string; + message: string; + line: number; + column: number; + }>; +} + +export interface VariableDto { + name: string; + type: string; + value: string; + kernel: string; +} diff --git a/vscode-frysharp/src/sidecar/sidecarClient.ts b/vscode-frysharp/src/sidecar/sidecarClient.ts new file mode 100644 index 00000000..ba059409 --- /dev/null +++ b/vscode-frysharp/src/sidecar/sidecarClient.ts @@ -0,0 +1,192 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as rpc from 'vscode-jsonrpc/node'; +import { + ExecuteCellParams, + ExecuteCellResult, + InitializeParams, + InitializeResult, + NotebookContent, + NotebookOutputNotification, + CellStateNotification, + RunOutputNotification, + RunExitedNotification, + RunStartParams, + RunStartResult, + VariableDto +} from './protocol'; + +export class SidecarClient implements vscode.Disposable { + private process?: cp.ChildProcess; + private connection?: rpc.MessageConnection; + private outputChannel: vscode.OutputChannel; + private onNotebookOutputEmitter = new vscode.EventEmitter(); + private onCellStateEmitter = new vscode.EventEmitter(); + private onRunOutputEmitter = new vscode.EventEmitter(); + private onRunExitedEmitter = new vscode.EventEmitter(); + + public readonly onNotebookOutput = this.onNotebookOutputEmitter.event; + public readonly onCellState = this.onCellStateEmitter.event; + public readonly onRunOutput = this.onRunOutputEmitter.event; + public readonly onRunExited = this.onRunExitedEmitter.event; + + constructor(private context: vscode.ExtensionContext) { + this.outputChannel = vscode.window.createOutputChannel('FrySharp Sidecar'); + } + + public async start(): Promise { + if (this.connection) { + return; + } + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + this.outputChannel.appendLine(`Starting FrySharp sidecar in: ${workspaceRoot}`); + + // Resolve sidecar executable: custom setting -> bundled binary -> repo build -> dotnet run + const config = vscode.workspace.getConfiguration('frysharp'); + const customSidecarPath = config.get('sidecarPath'); + + const bundledDll = path.join(this.context.extensionPath, 'bin', 'sidecar', 'frysharp-sidecar.dll'); + const repoRoot = path.resolve(this.context.extensionPath, '..'); + const repoDevDll = path.join(repoRoot, 'FrySharp.Sidecar', 'bin', 'Debug', 'net10.0', 'frysharp-sidecar.dll'); + const repoCsproj = path.join(repoRoot, 'FrySharp.Sidecar', 'FrySharp.Sidecar.csproj'); + + let command = 'dotnet'; + let args: string[] = []; + + if (this.process) { + try { this.process.kill(); } catch { } + this.process = undefined; + } + + if (customSidecarPath && fs.existsSync(customSidecarPath)) { + this.outputChannel.appendLine(`Using custom sidecar: ${customSidecarPath}`); + args = [customSidecarPath, '--workspace', workspaceRoot]; + } else if (fs.existsSync(repoDevDll) && (!fs.existsSync(bundledDll) || fs.statSync(repoDevDll).mtimeMs > fs.statSync(bundledDll).mtimeMs)) { + this.outputChannel.appendLine(`Using latest local dev sidecar: ${repoDevDll}`); + args = [repoDevDll, '--workspace', workspaceRoot]; + } else if (fs.existsSync(bundledDll)) { + this.outputChannel.appendLine(`Using bundled sidecar: ${bundledDll}`); + args = [bundledDll, '--workspace', workspaceRoot]; + } else if (fs.existsSync(repoDevDll)) { + this.outputChannel.appendLine(`Using local dev sidecar: ${repoDevDll}`); + args = [repoDevDll, '--workspace', workspaceRoot]; + } else if (fs.existsSync(repoCsproj)) { + this.outputChannel.appendLine(`Running sidecar via dotnet run: ${repoCsproj}`); + args = ['run', '--project', repoCsproj, '--', '--workspace', workspaceRoot]; + } else { + throw new Error('FrySharp sidecar binary not found. Please ensure .NET 10 is installed.'); + } + + this.process = cp.spawn(command, args, { + cwd: workspaceRoot, + env: { ...process.env, DOTNET_NOLOGO: '1' } + }); + + this.process.stderr?.on('data', (data) => { + this.outputChannel.append(`[Sidecar Log] ${data.toString()}`); + }); + + this.process.on('exit', (code, signal) => { + this.outputChannel.appendLine(`Sidecar process exited with code ${code}, signal ${signal}`); + this.connection = undefined; + }); + + this.connection = rpc.createMessageConnection( + new rpc.StreamMessageReader(this.process.stdout!), + new rpc.StreamMessageWriter(this.process.stdin!) + ); + + // Register notifications + this.connection.onNotification('fry/notebook/output', (n: NotebookOutputNotification) => { + this.onNotebookOutputEmitter.fire(n); + }); + + this.connection.onNotification('fry/notebook/cellState', (n: CellStateNotification) => { + this.onCellStateEmitter.fire(n); + }); + + this.connection.onNotification('fry/run/output', (n: RunOutputNotification) => { + this.onRunOutputEmitter.fire(n); + }); + + this.connection.onNotification('fry/run/exited', (n: RunExitedNotification) => { + this.onRunExitedEmitter.fire(n); + }); + + this.connection.listen(); + + const initResult = await this.connection.sendRequest('fry/initialize', { + clientId: 'vscode-client', + clientName: 'VS Code Extension', + protocolVersion: 1 + } as InitializeParams); + + this.outputChannel.appendLine(`Connected to FrySharp Sidecar v${initResult.serverVersion}`); + } + + public async openNotebook(relativePath: string): Promise { + if (!this.connection) await this.start(); + return this.connection!.sendRequest('fry/notebook/open', { path: relativePath }); + } + + public async executeCell(relativePath: string, cellId: string, source: string, language?: string): Promise { + if (!this.connection) await this.start(); + return this.connection!.sendRequest('fry/notebook/execute', { + path: relativePath, + cellId, + source, + language + } as ExecuteCellParams); + } + + public async interruptNotebook(relativePath: string): Promise { + if (!this.connection) return; + await this.connection.sendRequest('fry/notebook/interrupt', { path: relativePath }); + } + + public async restartNotebook(relativePath: string): Promise { + if (!this.connection) return; + await this.connection.sendRequest('fry/notebook/restart', { path: relativePath }); + } + + public async getVariables(relativePath: string): Promise { + if (!this.connection) await this.start(); + return this.connection!.sendRequest('fry/notebook/variables', { path: relativePath }); + } + + public async saveNotebook(relativePath: string): Promise { + if (!this.connection) return; + await this.connection.sendRequest('fry/notebook/save', { path: relativePath }); + } + + public async startRun(params: RunStartParams): Promise { + if (!this.connection) await this.start(); + return this.connection!.sendRequest('fry/run/start', params); + } + + public async stopRun(runId: string): Promise { + if (!this.connection) return false; + return this.connection.sendRequest('fry/run/stop', { runId }); + } + + public async restart(): Promise { + this.dispose(); + await this.start(); + } + + public dispose(): void { + try { + this.connection?.dispose(); + } catch { } + this.connection = undefined; + if (this.process) { + try { + this.process.kill(); + } catch { } + this.process = undefined; + } + } +} diff --git a/vscode-frysharp/tsconfig.json b/vscode-frysharp/tsconfig.json new file mode 100644 index 00000000..fa4c18ce --- /dev/null +++ b/vscode-frysharp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "outDir": "out", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} From 0ccc68e9eb20b6591125ab2a94accbe7e346e616 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Tue, 13 Oct 2026 20:51:56 +0530 Subject: [PATCH 2/3] refactor: update notebook demo sample with markdown preview flags and modified cell content --- samples/demo.frynb | 1962 ++------------------------------------------ 1 file changed, 58 insertions(+), 1904 deletions(-) diff --git a/samples/demo.frynb b/samples/demo.frynb index 885f57cf..9120b152 100644 --- a/samples/demo.frynb +++ b/samples/demo.frynb @@ -10,13 +10,13 @@ { "Id": "cell-intro", "Type": 1, - "Source": "# \uD83E\uDE90 FrySharp Polyglot Showcase\nInteractive .NET 10 Roslyn scripting, polyglot kernels (C#, Python, Node.js), cross-language variable sharing, and rich interactive visual outputs.", + "Source": "# 🪐 FrySharp Polyglot Showcase\nInteractive .NET 10 Roslyn scripting, polyglot kernels (C#, Python, Node.js), cross-language variable sharing, and rich interactive visual outputs.", "OutputText": "", "ExecutionCount": null, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -43,7 +43,7 @@ "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -64,12 +64,11 @@ { "Id": "cell-table-code", "Type": 0, - "Language": "csharp", - "Source": "// Strongly-typed enterprise cluster telemetry record\npublic record ClusterNode(\n string Region,\n string NodeId,\n string Role,\n double CpuPercent,\n double MemoryGb,\n double LatencyMs,\n string Status);\n\n// Global production infrastructure nodes\nvar clusterNodes = new[]\n{\n new ClusterNode(\u0022us-east-1\u0022, \u0022node-core-01\u0022, \u0022Primary Router\u0022, 24.5, 64.0, 12.4, \u0022Optimal\u0022),\n new ClusterNode(\u0022us-west-2\u0022, \u0022node-worker-02\u0022, \u0022Compute Shard\u0022, 78.2, 128.0, 28.1, \u0022Under Load\u0022),\n new ClusterNode(\u0022eu-west-1\u0022, \u0022node-db-03\u0022, \u0022Postgres Leader\u0022, 45.0, 256.0, 18.7, \u0022Optimal\u0022),\n new ClusterNode(\u0022ap-southeast-1\u0022, \u0022node-edge-04\u0022, \u0022Ingress Proxy\u0022, 15.8, 32.0, 42.0, \u0022Optimal\u0022),\n new ClusterNode(\u0022sa-east-1\u0022, \u0022node-cache-05\u0022, \u0022Redis In-Memory\u0022, 88.9, 64.0, 68.5, \u0022Degraded\u0022),\n new ClusterNode(\u0022ap-northeast-1\u0022, \u0022node-ai-06\u0022, \u0022Tensor Pipeline\u0022, 92.1, 192.0, 31.2, \u0022Heavy Compute\u0022)\n};\n\n// Render rich interactive dark-mode table\nDisplay.Table(clusterNodes, \u0022Global Cluster Infrastructure \u0026 Edge Telemetry\u0022);\n\n// LINQ Aggregations\ndouble avgLatency = clusterNodes.Average(n =\u003E n.LatencyMs);\ndouble totalRam = clusterNodes.Sum(n =\u003E n.MemoryGb);\nint healthyCount = clusterNodes.Count(n =\u003E n.Status == \u0022Optimal\u0022);\n\nConsole.WriteLine($\u0022\u2713 Telemetry Snapshot: {clusterNodes.Length} clusters monitored\u0022);\nConsole.WriteLine($\u0022 - Average Edge Latency: {avgLatency:F1} ms\u0022);\nConsole.WriteLine($\u0022 - Total Provisioned RAM: {totalRam:N0} GB\u0022);\nConsole.WriteLine($\u0022 - Healthy SLA Quorum: {healthyCount}/{clusterNodes.Length} nodes\u0022);", - "OutputText": "\u2713 Telemetry Snapshot: 6 clusters monitored\n - Average Edge Latency: 33.5 ms\n - Total Provisioned RAM: 736 GB\n - Healthy SLA Quorum: 3/6 nodes\n", - "ExecutionCount": 1, + "Source": "// Strongly-typed enterprise cluster telemetry record\npublic record ClusterNode(\n string Region,\n string NodeId,\n string Role,\n double CpuPercent,\n double MemoryGb,\n double LatencyMs,\n string Status);\n\n// Global production infrastructure nodes\nvar clusterNodes = new[]\n{\n new ClusterNode(\"us-east-1\", \"node-core-01\", \"Primary Router\", 24.5, 64.0, 12.4, \"Optimal\"),\n new ClusterNode(\"us-west-2\", \"node-worker-02\", \"Compute Shard\", 78.2, 128.0, 28.1, \"Under Load\"),\n new ClusterNode(\"eu-west-1\", \"node-db-03\", \"Postgres Leader\", 45.0, 256.0, 18.7, \"Optimal\"),\n new ClusterNode(\"ap-southeast-1\", \"node-edge-04\", \"Ingress Proxy\", 15.8, 32.0, 42.0, \"Optimal\"),\n new ClusterNode(\"sa-east-1\", \"node-cache-05\", \"Redis In-Memory\", 88.9, 64.0, 68.5, \"Degraded\"),\n new ClusterNode(\"ap-northeast-1\", \"node-ai-06\", \"Tensor Pipeline\", 92.1, 192.0, 31.2, \"Heavy Compute\")\n};\n\n// Render rich interactive dark-mode table\nDisplay.Table(clusterNodes, \"Global Cluster Infrastructure & Edge Telemetry\");\n\n// LINQ Aggregations\ndouble avgLatency = clusterNodes.Average(n => n.LatencyMs);\ndouble totalRam = clusterNodes.Sum(n => n.MemoryGb);\nint healthyCount = clusterNodes.Count(n => n.Status == \"Optimal\");\n\nConsole.WriteLine($\"✓ Telemetry Snapshot: {clusterNodes.Length} clusters monitored\");\nConsole.WriteLine($\" - Average Edge Latency: {avgLatency:F1} ms\");\nConsole.WriteLine($\" - Total Provisioned RAM: {totalRam:N0} GB\");\nConsole.WriteLine($\" - Healthy SLA Quorum: {healthyCount}/{clusterNodes.Length} nodes\");", + "OutputText": "Global Cluster Infrastructure & Edge Telemetry: ClusterNode[6]✓ Telemetry Snapshot: 6 clusters monitored\n - Average Edge Latency: 33.5 ms\n - Total Provisioned RAM: 736 GB\n - Healthy SLA Quorum: 3/6 nodes\n", + "ExecutionCount": 9, "IsExecuting": false, - "ExecutionTimeText": "995 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, @@ -79,319 +78,8 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "\u003Cdiv class=\u0022fry-tbl-container\u0022 id=\u0022fry_tbl_be473415\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;margin:10px 0;max-width:960px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow:hidden;\u0022\u003E\n \u003Cstyle\u003E\n #fry_tbl_be473415 .fry-tbl-header {\n display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 8px 12px; background: var(--vscode-sideBar-background, #161b22);\n border-bottom: 1px solid var(--vscode-widget-border, #30363d);\n }\n #fry_tbl_be473415 .fry-tbl-toggle-btn {\n display: flex; align-items: center; gap: 8px; background: transparent;\n border: none; color: var(--vscode-foreground, #c9d1d9); cursor: pointer;\n padding: 0; font-size: 13px; font-weight: 600; text-align: left;\n }\n #fry_tbl_be473415 .fry-tbl-pill {\n font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-badge-background, #21262d); border: 1px solid var(--vscode-widget-border, #30363d);\n padding: 2px 8px; border-radius: 12px;\n }\n #fry_tbl_be473415 .fry-tbl-actions {\n display: flex; align-items: center; gap: 8px;\n }\n #fry_tbl_be473415 .fry-tbl-search-wrap {\n position: relative; display: flex; align-items: center;\n }\n #fry_tbl_be473415 .fry-tbl-search-input {\n background: var(--vscode-input-background, #0d1117);\n border: 1px solid var(--vscode-input-border, #30363d);\n color: var(--vscode-input-foreground, #c9d1d9);\n padding: 3px 8px 3px 24px; border-radius: 4px; font-size: 11.5px;\n width: 140px; transition: border-color 0.2s, width 0.2s;\n }\n #fry_tbl_be473415 .fry-tbl-search-input:focus {\n outline: none; border-color: var(--vscode-focusBorder, #58a6ff); width: 180px;\n }\n #fry_tbl_be473415 .fry-tbl-search-icon {\n position: absolute; left: 7px; font-size: 11px; opacity: 0.6; pointer-events: none;\n }\n #fry_tbl_be473415 .fry-tbl-btn {\n background: var(--vscode-button-secondaryBackground, #21262d);\n color: var(--vscode-button-secondaryForeground, #c9d1d9);\n border: 1px solid var(--vscode-widget-border, #30363d);\n border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500;\n cursor: pointer; transition: background 0.15s;\n }\n #fry_tbl_be473415 .fry-tbl-btn:hover {\n background: var(--vscode-button-secondaryHoverBackground, #30363d);\n }\n #fry_tbl_be473415 .fry-tbl-scroll {\n max-height: 420px; overflow-y: auto; overflow-x: auto;\n }\n #fry_tbl_be473415 table.fry-tbl-grid {\n width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;\n }\n #fry_tbl_be473415 thead th {\n position: sticky; top: 0; z-index: 5; background: var(--vscode-sideBar-background, #161b22);\n padding: 8px 12px; border-bottom: 2px solid var(--vscode-widget-border, #30363d);\n font-weight: 600; color: var(--vscode-foreground, #c9d1d9); user-select: none;\n }\n #fry_tbl_be473415 thead th.sortable:hover {\n background: var(--vscode-list-hoverBackground, #21262d);\n }\n #fry_tbl_be473415 tbody tr.fry-data-row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.06);\n transition: background 0.12s;\n }\n #fry_tbl_be473415 tbody tr.fry-data-row:hover {\n background: var(--vscode-list-hoverBackground, rgba(56, 139, 253, 0.08)) !important;\n }\n #fry_tbl_be473415 td {\n padding: 7px 12px; vertical-align: middle;\n }\n #fry_tbl_be473415 .fry-num {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right; color: #79c0ff;\n }\n #fry_tbl_be473415 .fry-badge {\n display: inline-block; padding: 2px 7px; border-radius: 12px;\n font-size: 11px; font-weight: 500; letter-spacing: 0.2px;\n }\n #fry_tbl_be473415 .fry-badge-green {\n background: rgba(35, 134, 54, 0.2); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.4);\n }\n #fry_tbl_be473415 .fry-badge-amber {\n background: rgba(187, 128, 9, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4);\n }\n #fry_tbl_be473415 .fry-badge-red {\n background: rgba(218, 54, 51, 0.2); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.4);\n }\n #fry_tbl_be473415 .fry-badge-blue {\n background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.4);\n }\n #fry_tbl_be473415 .fry-expander-btn {\n background: transparent; border: none; cursor: pointer;\n color: var(--vscode-textLink-foreground, #58a6ff); font-size: 10px;\n padding: 3px 5px; border-radius: 4px; transition: transform 0.15s;\n }\n #fry_tbl_be473415 .fry-expander-btn:hover {\n background: rgba(255,255,255,0.1);\n }\n #fry_tbl_be473415 tr.fry-detail-row {\n background: rgba(0, 0, 0, 0.28);\n }\n #fry_tbl_be473415 .fry-prop-grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; margin-top: 6px;\n }\n #fry_tbl_be473415 .fry-prop-card {\n background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);\n border-radius: 4px; padding: 6px 10px;\n }\n #fry_tbl_be473415 .fry-prop-key {\n font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px;\n }\n #fry_tbl_be473415 .fry-prop-val {\n font-size: 12px; color: #c9d1d9; margin-top: 2px; word-break: break-word;\n }\n #fry_tbl_be473415 .fry-tbl-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px 12px; font-size: 11px; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-sideBar-background, #161b22);\n border-top: 1px solid var(--vscode-widget-border, #30363d);\n }\n \u003C/style\u003E\n\n \u003Cdiv class=\u0022fry-tbl-header\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-toggle-btn\u0022 onclick=\u0022fryTblToggle(\u0027fry_tbl_be473415\u0027)\u0022 title=\u0022Click to collapse/expand table\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-chevron\u0022 style=\u0022color:#58a6ff;\u0022\u003E\u25BC\u003C/span\u003E\n \u003Cspan\u003E\uD83D\uDCCA ClusterNode[6]\u003C/span\u003E\n \u003Cspan class=\u0022fry-tbl-pill\u0022\u003E6 rows \u00D7 7 cols\u003C/span\u003E\n \u003C/button\u003E\n \u003Cdiv class=\u0022fry-tbl-actions\u0022\u003E\n \u003Cdiv class=\u0022fry-tbl-search-wrap\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-search-icon\u0022\u003E\uD83D\uDD0D\u003C/span\u003E\n \u003Cinput type=\u0022text\u0022 class=\u0022fry-tbl-search-input\u0022 placeholder=\u0022Filter rows...\u0022 oninput=\u0022fryTblFilter(\u0027fry_tbl_be473415\u0027, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_be473415\u0027, \u0027csv\u0027)\u0022 title=\u0022Copy as CSV\u0022\u003ECSV\u003C/button\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_be473415\u0027, \u0027json\u0027)\u0022 title=\u0022Copy as JSON\u0022\u003EJSON\u003C/button\u003E\n \u003C/div\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-scroll\u0022\u003E\n \u003Ctable class=\u0022fry-tbl-grid\u0022\u003E\n \u003Cthead\u003E\n \u003Ctr\u003E\n \u003Cth style=\u0022width:36px;text-align:center;padding:8px 4px;\u0022\u003E\u003C/th\u003E\n \u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 0, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Region\u0022\u003E\n Region \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 1, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by NodeId\u0022\u003E\n NodeId \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 2, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Role\u0022\u003E\n Role \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 3, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by CpuPercent\u0022\u003E\n CpuPercent \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 4, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by MemoryGb\u0022\u003E\n MemoryGb \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 5, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by LatencyMs\u0022\u003E\n LatencyMs \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_be473415\u0027, 6, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Status\u0022\u003E\n Status \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\n \u003C/tr\u003E\n \u003C/thead\u003E\n \u003Ctbody\u003E\n \u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00220\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 0)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eus-east-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-core-01\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EPrimary Router\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E24.5\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E64\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E12.4\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_0\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #1 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eus-east-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-core-01\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EPrimary Router\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E24.5\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E64\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E12.4\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00221\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 1)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eus-west-2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-worker-02\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ECompute Shard\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E78.2\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E128\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E28.1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_1\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #2 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eus-west-2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-worker-02\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ECompute Shard\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E78.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E128\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E28.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00222\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 2)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eeu-west-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-db-03\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EPostgres Leader\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E45\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E256\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E18.7\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_2\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #3 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eeu-west-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-db-03\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EPostgres Leader\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E45\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E256\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E18.7\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00223\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 3)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eap-southeast-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-edge-04\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EIngress Proxy\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E15.8\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E32\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E42\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_3\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #4 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eap-southeast-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-edge-04\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EIngress Proxy\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E15.8\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E32\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E42\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00224\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 4)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Esa-east-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-cache-05\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ERedis In-Memory\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E88.9\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E64\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E68.5\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_4\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #5 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Esa-east-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-cache-05\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ERedis In-Memory\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E88.9\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E64\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E68.5\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00225\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_be473415\u0027, 5)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eap-northeast-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-ai-06\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ETensor Pipeline\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E92.1\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E192\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E31.2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EHeavy Compute\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_be473415_detail_5\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #6 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eap-northeast-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-ai-06\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ETensor Pipeline\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E92.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E192\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E31.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EHeavy Compute\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\n \u003C/tbody\u003E\n \u003C/table\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-footer\u0022\u003E\n \u003Cspan id=\u0022fry_tbl_be473415_counter\u0022\u003EShowing 6 of 6 rows\u003C/span\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10.5px;\u0022\u003EFrySharp Polyglot Data\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Cscript\u003E\n (function() {\n if (window.__fryTblInit) return;\n window.__fryTblInit = true;\n\n window.fryTblToggle = function(id) {\n var el = document.getElementById(id);\n if (!el) return;\n var body = el.querySelector(\u0027.fry-tbl-scroll\u0027);\n var footer = el.querySelector(\u0027.fry-tbl-footer\u0027);\n var chevron = el.querySelector(\u0027.fry-tbl-chevron\u0027);\n var isHidden = body.style.display === \u0027none\u0027;\n body.style.display = isHidden ? \u0027block\u0027 : \u0027none\u0027;\n footer.style.display = isHidden ? \u0027flex\u0027 : \u0027none\u0027;\n if (chevron) chevron.textContent = isHidden ? \u0027\u25BC\u0027 : \u0027\u25B6\u0027;\n };\n\n window.fryTblToggleRow = function(id, rowIdx) {\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rowIdx);\n var el = document.getElementById(id);\n if (!drawer || !el) return;\n var btn = el.querySelector(\u0027tr[data-row=\u0022\u0027 \u002B rowIdx \u002B \u0027\u0022] .fry-expander-btn\u0027);\n var isOpen = drawer.style.display !== \u0027none\u0027;\n drawer.style.display = isOpen ? \u0027none\u0027 : \u0027table-row\u0027;\n if (btn) btn.textContent = isOpen ? \u0027\u25B6\u0027 : \u0027\u25BC\u0027;\n };\n\n window.fryTblFilter = function(id, query) {\n var el = document.getElementById(id);\n if (!el) return;\n var q = (query || \u0027\u0027).toLowerCase().trim();\n var rows = el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027);\n var count = 0;\n rows.forEach(function(row) {\n var rIdx = row.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n var match = !q || row.innerText.toLowerCase().indexOf(q) !== -1;\n row.style.display = match ? \u0027\u0027 : \u0027none\u0027;\n if (!match \u0026\u0026 drawer) drawer.style.display = \u0027none\u0027;\n if (match) count\u002B\u002B;\n });\n var cnt = document.getElementById(id \u002B \u0027_counter\u0027);\n if (cnt) {\n cnt.textContent = q ? (\u0027Showing \u0027 \u002B count \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows (filtered)\u0027) : (\u0027Showing \u0027 \u002B rows.length \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows\u0027);\n }\n };\n\n window.fryTblSort = function(id, colIdx, isNum) {\n var el = document.getElementById(id);\n if (!el) return;\n var tbody = el.querySelector(\u0027tbody\u0027);\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var th = el.querySelectorAll(\u0027thead th\u0027)[colIdx \u002B 1];\n var dir = th.getAttribute(\u0027data-sort-dir\u0027) === \u0027asc\u0027 ? \u0027desc\u0027 : \u0027asc\u0027;\n\n el.querySelectorAll(\u0027thead th\u0027).forEach(function(h) {\n h.removeAttribute(\u0027data-sort-dir\u0027);\n var s = h.querySelector(\u0027.fry-sort-ind\u0027);\n if (s) s.textContent = \u0027\u2195\u0027;\n });\n\n th.setAttribute(\u0027data-sort-dir\u0027, dir);\n var ind = th.querySelector(\u0027.fry-sort-ind\u0027);\n if (ind) ind.textContent = dir === \u0027asc\u0027 ? \u0027\u25B2\u0027 : \u0027\u25BC\u0027;\n\n rows.sort(function(a, b) {\n var vA = a.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n var vB = b.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n if (isNum) {\n var nA = parseFloat(vA.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n var nB = parseFloat(vB.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n return dir === \u0027asc\u0027 ? nA - nB : nB - nA;\n }\n return dir === \u0027asc\u0027 ? vA.localeCompare(vB) : vB.localeCompare(vA);\n });\n\n rows.forEach(function(r) {\n var rIdx = r.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n tbody.appendChild(r);\n if (drawer) tbody.appendChild(drawer);\n });\n };\n\n window.fryTblCopy = function(id, format) {\n var el = document.getElementById(id);\n if (!el) return;\n var ths = Array.from(el.querySelectorAll(\u0027thead th\u0027)).slice(1);\n var headers = ths.map(function(h) { return h.innerText.replace(/[\u2195\u25B2\u25BC]/g, \u0027\u0027).trim(); });\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var data = rows.map(function(r) {\n return Array.from(r.children).slice(1).map(function(c) { return c.innerText.trim(); });\n });\n\n var payload = \u0027\u0027;\n if (format === \u0027json\u0027) {\n var list = data.map(function(r) {\n var o = {};\n headers.forEach(function(h, idx) { o[h] = r[idx]; });\n return o;\n });\n payload = JSON.stringify(list, null, 2);\n } else {\n payload = headers.join(\u0027,\u0027) \u002B \u0027\\n\u0027 \u002B data.map(function(r) {\n return r.map(function(val) { return \u0027\u0022\u0027 \u002B val.replace(/\u0022/g, \u0027\u0022\u0022\u0027) \u002B \u0027\u0022\u0027; }).join(\u0027,\u0027);\n }).join(\u0027\\n\u0027);\n }\n\n navigator.clipboard.writeText(payload).then(function() {\n var btn = el.querySelector(\u0027.fry-tbl-btn[onclick*=\u0022\u0027 \u002B format \u002B \u0027\u0022]\u0027);\n if (btn) {\n var prev = btn.textContent;\n btn.textContent = \u0027\u2713 Copied!\u0027;\n setTimeout(function() { btn.textContent = prev; }, 1500);\n }\n });\n };\n })();\n \u003C/script\u003E\n\u003C/div\u003E", - "TableSnapshot": { - "Title": "ClusterNode[6]", - "Label": "Global Cluster Infrastructure \u0026 Edge Telemetry", - "Columns": [ - { - "Header": "Region", - "IsNumeric": false - }, - { - "Header": "NodeId", - "IsNumeric": false - }, - { - "Header": "Role", - "IsNumeric": false - }, - { - "Header": "CpuPercent", - "IsNumeric": true - }, - { - "Header": "MemoryGb", - "IsNumeric": true - }, - { - "Header": "LatencyMs", - "IsNumeric": true - }, - { - "Header": "Status", - "IsNumeric": false - } - ], - "Rows": [ - { - "Cells": [ - { - "DisplayText": "us-east-1", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-core-01", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Primary Router", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "24.5", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "64", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "12.4", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Optimal", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "us-west-2", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-worker-02", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Compute Shard", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "78.2", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "128", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "28.1", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Under Load", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "eu-west-1", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-db-03", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Postgres Leader", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "45", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "256", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "18.7", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Optimal", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "ap-southeast-1", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-edge-04", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Ingress Proxy", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "15.8", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "32", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "42", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Optimal", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "sa-east-1", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-cache-05", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Redis In-Memory", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "88.9", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "64", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "68.5", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Degraded", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "ap-northeast-1", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-ai-06", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Tensor Pipeline", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "92.1", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "192", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "31.2", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Heavy Compute", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - } - ] - }, + "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Region ↕\n\n NodeId ↕\n\n Role ↕\n\n CpuPercent ↕\n\n MemoryGb ↕\n\n LatencyMs ↕\n\n Status ↕\n
\n \n us-east-1node-core-01Primary Router24.56412.4Optimal
\n
\n
\n
🔍 Row #1 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-east-1
\n
\n
NodeId
\n
node-core-01
\n
\n
Role
\n
Primary Router
\n
\n
CpuPercent
\n
24.5
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
12.4
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n us-west-2node-worker-02Compute Shard78.212828.1Under Load
\n
\n
\n
🔍 Row #2 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-west-2
\n
\n
NodeId
\n
node-worker-02
\n
\n
Role
\n
Compute Shard
\n
\n
CpuPercent
\n
78.2
\n
\n
MemoryGb
\n
128
\n
\n
LatencyMs
\n
28.1
\n
\n
Status
\n
Under Load
\n
\n
\n \n
\n
\n \n eu-west-1node-db-03Postgres Leader4525618.7Optimal
\n
\n
\n
🔍 Row #3 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
eu-west-1
\n
\n
NodeId
\n
node-db-03
\n
\n
Role
\n
Postgres Leader
\n
\n
CpuPercent
\n
45
\n
\n
MemoryGb
\n
256
\n
\n
LatencyMs
\n
18.7
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n ap-southeast-1node-edge-04Ingress Proxy15.83242Optimal
\n
\n
\n
🔍 Row #4 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-southeast-1
\n
\n
NodeId
\n
node-edge-04
\n
\n
Role
\n
Ingress Proxy
\n
\n
CpuPercent
\n
15.8
\n
\n
MemoryGb
\n
32
\n
\n
LatencyMs
\n
42
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n sa-east-1node-cache-05Redis In-Memory88.96468.5Degraded
\n
\n
\n
🔍 Row #5 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
sa-east-1
\n
\n
NodeId
\n
node-cache-05
\n
\n
Role
\n
Redis In-Memory
\n
\n
CpuPercent
\n
88.9
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
68.5
\n
\n
Status
\n
Degraded
\n
\n
\n \n
\n
\n \n ap-northeast-1node-ai-06Tensor Pipeline92.119231.2Heavy Compute
\n
\n
\n
🔍 Row #6 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-northeast-1
\n
\n
NodeId
\n
node-ai-06
\n
\n
Role
\n
Tensor Pipeline
\n
\n
CpuPercent
\n
92.1
\n
\n
MemoryGb
\n
192
\n
\n
LatencyMs
\n
31.2
\n
\n
Status
\n
Heavy Compute
\n
\n
\n \n
\n
\n
\n\n
\n Showing 6 of 6 rows\n FrySharp Polyglot Data\n
\n\n \n
", + "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, "HasImage": false, @@ -409,7 +97,7 @@ "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -430,12 +118,11 @@ { "Id": "cell-chart2d-code", "Type": 0, - "Language": "csharp", - "Source": "// Multi-series Fourier harmonic wave decomposition\nvar t = Enumerable.Range(0, 100).Select(i =\u003E i * 2.0 * Math.PI / 100.0).ToArray();\nvar fundamental = t.Select(Math.Sin).ToArray();\nvar harmonic3 = t.Select(x =\u003E (1.0 / 3.0) * Math.Sin(3 * x)).ToArray();\nvar composite = t.Select(x =\u003E Math.Sin(x) \u002B (1.0 / 3.0) * Math.Sin(3 * x) \u002B (1.0 / 5.0) * Math.Sin(5 * x)).ToArray();\n\nDisplay.LineChart(\n (\u0022Fundamental (sin t)\u0022, fundamental),\n (\u00223rd Harmonic (1/3 sin 3t)\u0022, harmonic3),\n (\u0022Composite Square Wave\u0022, composite));\n\nConsole.WriteLine(\u0022\u2713 2D Multi-series harmonic decomposition rendered.\u0022);", - "OutputText": "\u2713 2D Multi-series harmonic decomposition rendered.\n", - "ExecutionCount": 2, + "Source": "// Multi-series Fourier harmonic wave decomposition\nvar t = Enumerable.Range(0, 100).Select(i => i * 2.0 * Math.PI / 100.0).ToArray();\nvar fundamental = t.Select(Math.Sin).ToArray();\nvar harmonic3 = t.Select(x => (1.0 / 3.0) * Math.Sin(3 * x)).ToArray();\nvar composite = t.Select(x => Math.Sin(x) + (1.0 / 3.0) * Math.Sin(3 * x) + (1.0 / 5.0) * Math.Sin(5 * x)).ToArray();\n\nDisplay.LineChart(\n (\"Fundamental (sin t)\", fundamental),\n (\"3rd Harmonic (1/3 sin 3t)\", harmonic3),\n (\"Composite Square Wave\", composite));\n\nConsole.WriteLine(\"✓ 2D Multi-series harmonic decomposition rendered.\");", + "OutputText": "✓ 2D Multi-series harmonic decomposition rendered.\n", + "ExecutionCount": 10, "IsExecuting": false, - "ExecutionTimeText": "156 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, @@ -445,335 +132,9 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "\u003Cdiv class=\u0022fry-visual-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;margin:8px 0;max-width:840px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow-x:auto;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;\u0022\u003E\u003Cdiv\u003E\u003Cdiv style=\u0022font-weight:600;font-size:13.5px;color:#58a6ff;letter-spacing:0.2px;\u0022\u003E\uD83D\uDCCA Chart\u003C/div\u003E\u003C/div\u003E\u003Cdiv style=\u0022font-size:10.5px;font-weight:600;color:#8b949e;background:#161b22;padding:3px 9px;border-radius:12px;border:1px solid #30363d;letter-spacing:0.5px;\u0022\u003ELINE\u003C/div\u003E\u003C/div\u003E\u003Csvg viewBox=\u00220 0 760 360\u0022 width=\u0022100%\u0022 height=\u0022auto\u0022 style=\u0022display:block;border-radius:6px;background:#161b22;\u0022 xmlns=\u0022http://www.w3.org/2000/svg\u0022\u003E\u003Cdefs\u003E\u003ClinearGradient id=\u0022area-grad-0\u0022 x1=\u00220%\u0022 y1=\u00220%\u0022 x2=\u00220%\u0022 y2=\u0022100%\u0022\u003E\u003Cstop offset=\u00220%\u0022 stop-color=\u0022#58a6ff\u0022 stop-opacity=\u00220.35\u0022/\u003E\u003Cstop offset=\u0022100%\u0022 stop-color=\u0022#58a6ff\u0022 stop-opacity=\u00220.02\u0022/\u003E\u003C/linearGradient\u003E\u003ClinearGradient id=\u0022area-grad-1\u0022 x1=\u00220%\u0022 y1=\u00220%\u0022 x2=\u00220%\u0022 y2=\u0022100%\u0022\u003E\u003Cstop offset=\u00220%\u0022 stop-color=\u0022#3fb950\u0022 stop-opacity=\u00220.35\u0022/\u003E\u003Cstop offset=\u0022100%\u0022 stop-color=\u0022#3fb950\u0022 stop-opacity=\u00220.02\u0022/\u003E\u003C/linearGradient\u003E\u003ClinearGradient id=\u0022area-grad-2\u0022 x1=\u00220%\u0022 y1=\u00220%\u0022 x2=\u00220%\u0022 y2=\u0022100%\u0022\u003E\u003Cstop offset=\u00220%\u0022 stop-color=\u0022#d29922\u0022 stop-opacity=\u00220.35\u0022/\u003E\u003Cstop offset=\u0022100%\u0022 stop-color=\u0022#d29922\u0022 stop-opacity=\u00220.02\u0022/\u003E\u003C/linearGradient\u003E\u003C/defs\u003E\u003Cline x1=\u002260\u0022 y1=\u002230.0\u0022 x2=\u0022720\u0022 y2=\u002230.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u002234.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E1\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u002284.0\u0022 x2=\u0022720\u0022 y2=\u002284.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u002288.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E0.6\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022138.0\u0022 x2=\u0022720\u0022 y2=\u0022138.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022142.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E0.2\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022192.0\u0022 x2=\u0022720\u0022 y2=\u0022192.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022196.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E-0.2\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022246.0\u0022 x2=\u0022720\u0022 y2=\u0022246.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022250.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E-0.6\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022300.0\u0022 x2=\u0022720\u0022 y2=\u0022300.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022304.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E-1\u003C/text\u003E\u003Cpath d=\u0022M 60.0 165.0 L 66.7 156.5 L 73.3 148.1 L 80.0 139.7 L 86.7 131.4 L 93.3 123.3 L 100.0 115.3 L 106.7 107.5 L 113.3 100.0 L 120.0 92.7 L 126.7 85.6 L 133.3 78.9 L 140.0 72.6 L 146.7 66.6 L 153.3 61.0 L 160.0 55.8 L 166.7 51.0 L 173.3 46.7 L 180.0 42.8 L 186.7 39.5 L 193.3 36.6 L 200.0 34.2 L 206.7 32.4 L 213.3 31.1 L 220.0 30.3 L 226.7 30.0 L 233.3 30.3 L 240.0 31.1 L 246.7 32.4 L 253.3 34.2 L 260.0 36.6 L 266.7 39.5 L 273.3 42.8 L 280.0 46.7 L 286.7 51.0 L 293.3 55.8 L 300.0 61.0 L 306.7 66.6 L 313.3 72.6 L 320.0 78.9 L 326.7 85.6 L 333.3 92.7 L 340.0 100.0 L 346.7 107.5 L 353.3 115.3 L 360.0 123.3 L 366.7 131.4 L 373.3 139.7 L 380.0 148.1 L 386.7 156.5 L 393.3 165.0 L 400.0 173.5 L 406.7 181.9 L 413.3 190.3 L 420.0 198.6 L 426.7 206.7 L 433.3 214.7 L 440.0 222.5 L 446.7 230.0 L 453.3 237.3 L 460.0 244.4 L 466.7 251.1 L 473.3 257.4 L 480.0 263.4 L 486.7 269.0 L 493.3 274.2 L 500.0 279.0 L 506.7 283.3 L 513.3 287.2 L 520.0 290.5 L 526.7 293.4 L 533.3 295.8 L 540.0 297.6 L 546.7 298.9 L 553.3 299.7 L 560.0 300.0 L 566.7 299.7 L 573.3 298.9 L 580.0 297.6 L 586.7 295.8 L 593.3 293.4 L 600.0 290.5 L 606.7 287.2 L 613.3 283.3 L 620.0 279.0 L 626.7 274.2 L 633.3 269.0 L 640.0 263.4 L 646.7 257.4 L 653.3 251.1 L 660.0 244.4 L 666.7 237.3 L 673.3 230.0 L 680.0 222.5 L 686.7 214.7 L 693.3 206.7 L 700.0 198.6 L 706.7 190.3 L 713.3 181.9 L 720.0 173.5 \u0022 fill=\u0022none\u0022 stroke=\u0022#58a6ff\u0022 stroke-width=\u00222.5\u0022 stroke-linejoin=\u0022round\u0022 stroke-linecap=\u0022round\u0022/\u003E\u003Ccircle cx=\u002260.0\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.00 (#1)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002266.7\u0022 cy=\u0022156.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.06 (#2)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002273.3\u0022 cy=\u0022148.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.13 (#3)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002280.0\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.19 (#4)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002286.7\u0022 cy=\u0022131.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.25 (#5)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002293.3\u0022 cy=\u0022123.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.31 (#6)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022100.0\u0022 cy=\u0022115.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.37 (#7)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022106.7\u0022 cy=\u0022107.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.43 (#8)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022113.3\u0022 cy=\u0022100.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.48 (#9)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022120.0\u0022 cy=\u002292.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.54 (#10)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022126.7\u0022 cy=\u002285.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.59 (#11)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022133.3\u0022 cy=\u002278.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.64 (#12)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022140.0\u0022 cy=\u002272.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.68 (#13)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022146.7\u0022 cy=\u002266.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.73 (#14)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022153.3\u0022 cy=\u002261.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.77 (#15)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022160.0\u0022 cy=\u002255.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.81 (#16)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022166.7\u0022 cy=\u002251.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.84 (#17)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022173.3\u0022 cy=\u002246.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.88 (#18)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022180.0\u0022 cy=\u002242.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.90 (#19)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022186.7\u0022 cy=\u002239.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.93 (#20)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022193.3\u0022 cy=\u002236.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.95 (#21)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022200.0\u0022 cy=\u002234.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.97 (#22)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022206.7\u0022 cy=\u002232.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.98 (#23)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022213.3\u0022 cy=\u002231.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.99 (#24)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022220.0\u0022 cy=\u002230.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 1.00 (#25)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022226.7\u0022 cy=\u002230.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 1.00 (#26)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022233.3\u0022 cy=\u002230.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 1.00 (#27)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022240.0\u0022 cy=\u002231.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.99 (#28)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022246.7\u0022 cy=\u002232.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.98 (#29)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022253.3\u0022 cy=\u002234.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.97 (#30)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022260.0\u0022 cy=\u002236.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.95 (#31)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022266.7\u0022 cy=\u002239.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.93 (#32)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022273.3\u0022 cy=\u002242.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.90 (#33)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022280.0\u0022 cy=\u002246.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.88 (#34)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022286.7\u0022 cy=\u002251.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.84 (#35)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022293.3\u0022 cy=\u002255.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.81 (#36)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022300.0\u0022 cy=\u002261.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.77 (#37)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022306.7\u0022 cy=\u002266.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.73 (#38)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022313.3\u0022 cy=\u002272.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.68 (#39)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022320.0\u0022 cy=\u002278.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.64 (#40)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022326.7\u0022 cy=\u002285.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.59 (#41)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022333.3\u0022 cy=\u002292.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.54 (#42)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022340.0\u0022 cy=\u0022100.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.48 (#43)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022346.7\u0022 cy=\u0022107.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.43 (#44)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022353.3\u0022 cy=\u0022115.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.37 (#45)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022360.0\u0022 cy=\u0022123.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.31 (#46)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022366.7\u0022 cy=\u0022131.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.25 (#47)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022373.3\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.19 (#48)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022380.0\u0022 cy=\u0022148.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.13 (#49)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022386.7\u0022 cy=\u0022156.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.06 (#50)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022393.3\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): 0.00 (#51)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022400.0\u0022 cy=\u0022173.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.06 (#52)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022406.7\u0022 cy=\u0022181.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.13 (#53)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022413.3\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.19 (#54)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022420.0\u0022 cy=\u0022198.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.25 (#55)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022426.7\u0022 cy=\u0022206.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.31 (#56)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022433.3\u0022 cy=\u0022214.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.37 (#57)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022440.0\u0022 cy=\u0022222.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.43 (#58)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022446.7\u0022 cy=\u0022230.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.48 (#59)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022453.3\u0022 cy=\u0022237.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.54 (#60)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022460.0\u0022 cy=\u0022244.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.59 (#61)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022466.7\u0022 cy=\u0022251.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.64 (#62)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022473.3\u0022 cy=\u0022257.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.68 (#63)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022480.0\u0022 cy=\u0022263.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.73 (#64)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022486.7\u0022 cy=\u0022269.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.77 (#65)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022493.3\u0022 cy=\u0022274.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.81 (#66)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022500.0\u0022 cy=\u0022279.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.84 (#67)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022506.7\u0022 cy=\u0022283.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.88 (#68)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022513.3\u0022 cy=\u0022287.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.90 (#69)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022520.0\u0022 cy=\u0022290.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.93 (#70)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022526.7\u0022 cy=\u0022293.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.95 (#71)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022533.3\u0022 cy=\u0022295.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.97 (#72)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022540.0\u0022 cy=\u0022297.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.98 (#73)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022546.7\u0022 cy=\u0022298.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.99 (#74)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022553.3\u0022 cy=\u0022299.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -1.00 (#75)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022560.0\u0022 cy=\u0022300.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -1.00 (#76)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022566.7\u0022 cy=\u0022299.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -1.00 (#77)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022573.3\u0022 cy=\u0022298.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.99 (#78)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022580.0\u0022 cy=\u0022297.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.98 (#79)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022586.7\u0022 cy=\u0022295.8\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.97 (#80)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022593.3\u0022 cy=\u0022293.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.95 (#81)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022600.0\u0022 cy=\u0022290.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.93 (#82)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022606.7\u0022 cy=\u0022287.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.90 (#83)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022613.3\u0022 cy=\u0022283.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.88 (#84)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022620.0\u0022 cy=\u0022279.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.84 (#85)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022626.7\u0022 cy=\u0022274.2\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.81 (#86)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022633.3\u0022 cy=\u0022269.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.77 (#87)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022640.0\u0022 cy=\u0022263.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.73 (#88)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022646.7\u0022 cy=\u0022257.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.68 (#89)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022653.3\u0022 cy=\u0022251.1\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.64 (#90)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022660.0\u0022 cy=\u0022244.4\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.59 (#91)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022666.7\u0022 cy=\u0022237.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.54 (#92)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022673.3\u0022 cy=\u0022230.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.48 (#93)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022680.0\u0022 cy=\u0022222.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.43 (#94)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022686.7\u0022 cy=\u0022214.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.37 (#95)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022693.3\u0022 cy=\u0022206.7\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.31 (#96)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022700.0\u0022 cy=\u0022198.6\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.25 (#97)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022706.7\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.19 (#98)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022713.3\u0022 cy=\u0022181.9\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.13 (#99)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022720.0\u0022 cy=\u0022173.5\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EFundamental (sin t): -0.06 (#100)\u003C/title\u003E\u003C/circle\u003E\u003Cpath d=\u0022M 60.0 165.0 L 66.7 156.6 L 73.3 148.4 L 80.0 140.9 L 86.7 134.2 L 93.3 128.6 L 100.0 124.3 L 106.7 121.4 L 113.3 120.1 L 120.0 120.4 L 126.7 122.2 L 133.3 125.6 L 140.0 130.3 L 146.7 136.3 L 153.3 143.3 L 160.0 151.1 L 166.7 159.4 L 173.3 167.8 L 180.0 176.2 L 186.7 184.2 L 193.3 191.5 L 200.0 197.8 L 206.7 203.0 L 213.3 206.8 L 220.0 209.2 L 226.7 210.0 L 233.3 209.2 L 240.0 206.8 L 246.7 203.0 L 253.3 197.8 L 260.0 191.5 L 266.7 184.2 L 273.3 176.2 L 280.0 167.8 L 286.7 159.4 L 293.3 151.1 L 300.0 143.3 L 306.7 136.3 L 313.3 130.3 L 320.0 125.6 L 326.7 122.2 L 333.3 120.4 L 340.0 120.1 L 346.7 121.4 L 353.3 124.3 L 360.0 128.6 L 366.7 134.2 L 373.3 140.9 L 380.0 148.4 L 386.7 156.6 L 393.3 165.0 L 400.0 173.4 L 406.7 181.6 L 413.3 189.1 L 420.0 195.8 L 426.7 201.4 L 433.3 205.7 L 440.0 208.6 L 446.7 209.9 L 453.3 209.6 L 460.0 207.8 L 466.7 204.4 L 473.3 199.7 L 480.0 193.7 L 486.7 186.7 L 493.3 178.9 L 500.0 170.6 L 506.7 162.2 L 513.3 153.8 L 520.0 145.8 L 526.7 138.5 L 533.3 132.2 L 540.0 127.0 L 546.7 123.2 L 553.3 120.8 L 560.0 120.0 L 566.7 120.8 L 573.3 123.2 L 580.0 127.0 L 586.7 132.2 L 593.3 138.5 L 600.0 145.8 L 606.7 153.8 L 613.3 162.2 L 620.0 170.6 L 626.7 178.9 L 633.3 186.7 L 640.0 193.7 L 646.7 199.7 L 653.3 204.4 L 660.0 207.8 L 666.7 209.6 L 673.3 209.9 L 680.0 208.6 L 686.7 205.7 L 693.3 201.4 L 700.0 195.8 L 706.7 189.1 L 713.3 181.6 L 720.0 173.4 \u0022 fill=\u0022none\u0022 stroke=\u0022#3fb950\u0022 stroke-width=\u00222.5\u0022 stroke-linejoin=\u0022round\u0022 stroke-linecap=\u0022round\u0022/\u003E\u003Ccircle cx=\u002260.0\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.00 (#1)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002266.7\u0022 cy=\u0022156.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.06 (#2)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002273.3\u0022 cy=\u0022148.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.12 (#3)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002280.0\u0022 cy=\u0022140.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.18 (#4)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002286.7\u0022 cy=\u0022134.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.23 (#5)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002293.3\u0022 cy=\u0022128.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.27 (#6)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022100.0\u0022 cy=\u0022124.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.30 (#7)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022106.7\u0022 cy=\u0022121.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#8)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022113.3\u0022 cy=\u0022120.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#9)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022120.0\u0022 cy=\u0022120.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#10)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022126.7\u0022 cy=\u0022122.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#11)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022133.3\u0022 cy=\u0022125.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.29 (#12)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022140.0\u0022 cy=\u0022130.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.26 (#13)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022146.7\u0022 cy=\u0022136.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.21 (#14)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022153.3\u0022 cy=\u0022143.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.16 (#15)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022160.0\u0022 cy=\u0022151.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.10 (#16)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022166.7\u0022 cy=\u0022159.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.04 (#17)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022173.3\u0022 cy=\u0022167.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.02 (#18)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022180.0\u0022 cy=\u0022176.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.08 (#19)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022186.7\u0022 cy=\u0022184.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.14 (#20)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022193.3\u0022 cy=\u0022191.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.20 (#21)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022200.0\u0022 cy=\u0022197.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.24 (#22)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022206.7\u0022 cy=\u0022203.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.28 (#23)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022213.3\u0022 cy=\u0022206.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.31 (#24)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022220.0\u0022 cy=\u0022209.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#25)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022226.7\u0022 cy=\u0022210.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#26)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022233.3\u0022 cy=\u0022209.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#27)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022240.0\u0022 cy=\u0022206.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.31 (#28)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022246.7\u0022 cy=\u0022203.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.28 (#29)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022253.3\u0022 cy=\u0022197.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.24 (#30)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022260.0\u0022 cy=\u0022191.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.20 (#31)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022266.7\u0022 cy=\u0022184.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.14 (#32)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022273.3\u0022 cy=\u0022176.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.08 (#33)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022280.0\u0022 cy=\u0022167.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.02 (#34)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022286.7\u0022 cy=\u0022159.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.04 (#35)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022293.3\u0022 cy=\u0022151.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.10 (#36)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022300.0\u0022 cy=\u0022143.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.16 (#37)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022306.7\u0022 cy=\u0022136.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.21 (#38)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022313.3\u0022 cy=\u0022130.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.26 (#39)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022320.0\u0022 cy=\u0022125.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.29 (#40)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022326.7\u0022 cy=\u0022122.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#41)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022333.3\u0022 cy=\u0022120.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#42)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022340.0\u0022 cy=\u0022120.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#43)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022346.7\u0022 cy=\u0022121.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.32 (#44)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022353.3\u0022 cy=\u0022124.3\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.30 (#45)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022360.0\u0022 cy=\u0022128.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.27 (#46)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022366.7\u0022 cy=\u0022134.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.23 (#47)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022373.3\u0022 cy=\u0022140.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.18 (#48)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022380.0\u0022 cy=\u0022148.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.12 (#49)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022386.7\u0022 cy=\u0022156.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.06 (#50)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022393.3\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.00 (#51)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022400.0\u0022 cy=\u0022173.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.06 (#52)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022406.7\u0022 cy=\u0022181.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.12 (#53)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022413.3\u0022 cy=\u0022189.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.18 (#54)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022420.0\u0022 cy=\u0022195.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.23 (#55)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022426.7\u0022 cy=\u0022201.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.27 (#56)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022433.3\u0022 cy=\u0022205.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.30 (#57)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022440.0\u0022 cy=\u0022208.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#58)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022446.7\u0022 cy=\u0022209.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#59)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022453.3\u0022 cy=\u0022209.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#60)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022460.0\u0022 cy=\u0022207.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#61)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022466.7\u0022 cy=\u0022204.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.29 (#62)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022473.3\u0022 cy=\u0022199.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.26 (#63)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022480.0\u0022 cy=\u0022193.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.21 (#64)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022486.7\u0022 cy=\u0022186.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.16 (#65)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022493.3\u0022 cy=\u0022178.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.10 (#66)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022500.0\u0022 cy=\u0022170.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.04 (#67)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022506.7\u0022 cy=\u0022162.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.02 (#68)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022513.3\u0022 cy=\u0022153.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.08 (#69)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022520.0\u0022 cy=\u0022145.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.14 (#70)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022526.7\u0022 cy=\u0022138.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.20 (#71)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022533.3\u0022 cy=\u0022132.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.24 (#72)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022540.0\u0022 cy=\u0022127.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.28 (#73)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022546.7\u0022 cy=\u0022123.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.31 (#74)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022553.3\u0022 cy=\u0022120.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#75)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022560.0\u0022 cy=\u0022120.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#76)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022566.7\u0022 cy=\u0022120.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.33 (#77)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022573.3\u0022 cy=\u0022123.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.31 (#78)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022580.0\u0022 cy=\u0022127.0\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.28 (#79)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022586.7\u0022 cy=\u0022132.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.24 (#80)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022593.3\u0022 cy=\u0022138.5\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.20 (#81)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022600.0\u0022 cy=\u0022145.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.14 (#82)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022606.7\u0022 cy=\u0022153.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.08 (#83)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022613.3\u0022 cy=\u0022162.2\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): 0.02 (#84)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022620.0\u0022 cy=\u0022170.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.04 (#85)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022626.7\u0022 cy=\u0022178.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.10 (#86)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022633.3\u0022 cy=\u0022186.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.16 (#87)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022640.0\u0022 cy=\u0022193.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.21 (#88)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022646.7\u0022 cy=\u0022199.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.26 (#89)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022653.3\u0022 cy=\u0022204.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.29 (#90)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022660.0\u0022 cy=\u0022207.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#91)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022666.7\u0022 cy=\u0022209.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#92)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022673.3\u0022 cy=\u0022209.9\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.33 (#93)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022680.0\u0022 cy=\u0022208.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.32 (#94)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022686.7\u0022 cy=\u0022205.7\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.30 (#95)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022693.3\u0022 cy=\u0022201.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.27 (#96)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022700.0\u0022 cy=\u0022195.8\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.23 (#97)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022706.7\u0022 cy=\u0022189.1\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.18 (#98)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022713.3\u0022 cy=\u0022181.6\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.12 (#99)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022720.0\u0022 cy=\u0022173.4\u0022 r=\u00223.5\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003E3rd Harmonic (1/3 sin 3t): -0.06 (#100)\u003C/title\u003E\u003C/circle\u003E\u003Cpath d=\u0022M 60.0 165.0 L 66.7 139.7 L 73.3 115.6 L 80.0 93.7 L 86.7 74.9 L 93.3 59.9 L 100.0 48.9 L 106.7 42.1 L 113.3 39.2 L 120.0 39.7 L 126.7 42.9 L 133.3 47.9 L 140.0 53.8 L 146.7 59.7 L 153.3 65.0 L 160.0 68.9 L 166.7 71.1 L 173.3 71.4 L 180.0 69.9 L 186.7 67.0 L 193.3 63.1 L 200.0 58.7 L 206.7 54.5 L 213.3 51.1 L 220.0 48.8 L 226.7 48.0 L 233.3 48.8 L 240.0 51.1 L 246.7 54.5 L 253.3 58.7 L 260.0 63.1 L 266.7 67.0 L 273.3 69.9 L 280.0 71.4 L 286.7 71.1 L 293.3 68.9 L 300.0 65.0 L 306.7 59.7 L 313.3 53.8 L 320.0 47.9 L 326.7 42.9 L 333.3 39.7 L 340.0 39.2 L 346.7 42.1 L 353.3 48.9 L 360.0 59.9 L 366.7 74.9 L 373.3 93.7 L 380.0 115.6 L 386.7 139.7 L 393.3 165.0 L 400.0 190.3 L 406.7 214.4 L 413.3 236.3 L 420.0 255.1 L 426.7 270.1 L 433.3 281.1 L 440.0 287.9 L 446.7 290.8 L 453.3 290.3 L 460.0 287.1 L 466.7 282.1 L 473.3 276.2 L 480.0 270.3 L 486.7 265.0 L 493.3 261.1 L 500.0 258.9 L 506.7 258.6 L 513.3 260.1 L 520.0 263.0 L 526.7 266.9 L 533.3 271.3 L 540.0 275.5 L 546.7 278.9 L 553.3 281.2 L 560.0 282.0 L 566.7 281.2 L 573.3 278.9 L 580.0 275.5 L 586.7 271.3 L 593.3 266.9 L 600.0 263.0 L 606.7 260.1 L 613.3 258.6 L 620.0 258.9 L 626.7 261.1 L 633.3 265.0 L 640.0 270.3 L 646.7 276.2 L 653.3 282.1 L 660.0 287.1 L 666.7 290.3 L 673.3 290.8 L 680.0 287.9 L 686.7 281.1 L 693.3 270.1 L 700.0 255.1 L 706.7 236.3 L 713.3 214.4 L 720.0 190.3 \u0022 fill=\u0022none\u0022 stroke=\u0022#d29922\u0022 stroke-width=\u00222.5\u0022 stroke-linejoin=\u0022round\u0022 stroke-linecap=\u0022round\u0022/\u003E\u003Ccircle cx=\u002260.0\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.00 (#1)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002266.7\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.19 (#2)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002273.3\u0022 cy=\u0022115.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.37 (#3)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002280.0\u0022 cy=\u002293.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.53 (#4)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002286.7\u0022 cy=\u002274.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.67 (#5)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u002293.3\u0022 cy=\u002259.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#6)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022100.0\u0022 cy=\u002248.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#7)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022106.7\u0022 cy=\u002242.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.91 (#8)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022113.3\u0022 cy=\u002239.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#9)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022120.0\u0022 cy=\u002239.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#10)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022126.7\u0022 cy=\u002242.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.90 (#11)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022133.3\u0022 cy=\u002247.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.87 (#12)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022140.0\u0022 cy=\u002253.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#13)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022146.7\u0022 cy=\u002259.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#14)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022153.3\u0022 cy=\u002265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.74 (#15)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022160.0\u0022 cy=\u002268.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.71 (#16)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022166.7\u0022 cy=\u002271.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#17)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022173.3\u0022 cy=\u002271.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.69 (#18)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022180.0\u0022 cy=\u002269.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#19)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022186.7\u0022 cy=\u002267.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.73 (#20)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022193.3\u0022 cy=\u002263.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.76 (#21)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022200.0\u0022 cy=\u002258.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.79 (#22)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022206.7\u0022 cy=\u002254.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#23)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022213.3\u0022 cy=\u002251.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.84 (#24)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022220.0\u0022 cy=\u002248.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#25)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022226.7\u0022 cy=\u002248.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.87 (#26)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022233.3\u0022 cy=\u002248.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#27)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022240.0\u0022 cy=\u002251.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.84 (#28)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022246.7\u0022 cy=\u002254.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#29)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022253.3\u0022 cy=\u002258.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.79 (#30)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022260.0\u0022 cy=\u002263.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.76 (#31)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022266.7\u0022 cy=\u002267.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.73 (#32)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022273.3\u0022 cy=\u002269.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#33)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022280.0\u0022 cy=\u002271.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.69 (#34)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022286.7\u0022 cy=\u002271.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.70 (#35)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022293.3\u0022 cy=\u002268.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.71 (#36)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022300.0\u0022 cy=\u002265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.74 (#37)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022306.7\u0022 cy=\u002259.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#38)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022313.3\u0022 cy=\u002253.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.82 (#39)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022320.0\u0022 cy=\u002247.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.87 (#40)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022326.7\u0022 cy=\u002242.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.90 (#41)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022333.3\u0022 cy=\u002239.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#42)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022340.0\u0022 cy=\u002239.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.93 (#43)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022346.7\u0022 cy=\u002242.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.91 (#44)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022353.3\u0022 cy=\u002248.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.86 (#45)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022360.0\u0022 cy=\u002259.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.78 (#46)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022366.7\u0022 cy=\u002274.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.67 (#47)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022373.3\u0022 cy=\u002293.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.53 (#48)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022380.0\u0022 cy=\u0022115.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.37 (#49)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022386.7\u0022 cy=\u0022139.7\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.19 (#50)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022393.3\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: 0.00 (#51)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022400.0\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.19 (#52)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022406.7\u0022 cy=\u0022214.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.37 (#53)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022413.3\u0022 cy=\u0022236.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.53 (#54)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022420.0\u0022 cy=\u0022255.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.67 (#55)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022426.7\u0022 cy=\u0022270.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#56)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022433.3\u0022 cy=\u0022281.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#57)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022440.0\u0022 cy=\u0022287.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.91 (#58)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022446.7\u0022 cy=\u0022290.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#59)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022453.3\u0022 cy=\u0022290.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#60)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022460.0\u0022 cy=\u0022287.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.90 (#61)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022466.7\u0022 cy=\u0022282.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.87 (#62)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022473.3\u0022 cy=\u0022276.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#63)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022480.0\u0022 cy=\u0022270.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#64)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022486.7\u0022 cy=\u0022265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.74 (#65)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022493.3\u0022 cy=\u0022261.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.71 (#66)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022500.0\u0022 cy=\u0022258.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#67)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022506.7\u0022 cy=\u0022258.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.69 (#68)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022513.3\u0022 cy=\u0022260.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#69)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022520.0\u0022 cy=\u0022263.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.73 (#70)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022526.7\u0022 cy=\u0022266.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.76 (#71)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022533.3\u0022 cy=\u0022271.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.79 (#72)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022540.0\u0022 cy=\u0022275.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#73)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022546.7\u0022 cy=\u0022278.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.84 (#74)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022553.3\u0022 cy=\u0022281.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#75)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022560.0\u0022 cy=\u0022282.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.87 (#76)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022566.7\u0022 cy=\u0022281.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#77)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022573.3\u0022 cy=\u0022278.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.84 (#78)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022580.0\u0022 cy=\u0022275.5\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#79)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022586.7\u0022 cy=\u0022271.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.79 (#80)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022593.3\u0022 cy=\u0022266.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.76 (#81)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022600.0\u0022 cy=\u0022263.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.73 (#82)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022606.7\u0022 cy=\u0022260.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#83)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022613.3\u0022 cy=\u0022258.6\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.69 (#84)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022620.0\u0022 cy=\u0022258.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.70 (#85)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022626.7\u0022 cy=\u0022261.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.71 (#86)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022633.3\u0022 cy=\u0022265.0\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.74 (#87)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022640.0\u0022 cy=\u0022270.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#88)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022646.7\u0022 cy=\u0022276.2\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.82 (#89)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022653.3\u0022 cy=\u0022282.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.87 (#90)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022660.0\u0022 cy=\u0022287.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.90 (#91)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022666.7\u0022 cy=\u0022290.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#92)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022673.3\u0022 cy=\u0022290.8\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.93 (#93)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022680.0\u0022 cy=\u0022287.9\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.91 (#94)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022686.7\u0022 cy=\u0022281.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.86 (#95)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022693.3\u0022 cy=\u0022270.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.78 (#96)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022700.0\u0022 cy=\u0022255.1\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.67 (#97)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022706.7\u0022 cy=\u0022236.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.53 (#98)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022713.3\u0022 cy=\u0022214.4\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.37 (#99)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022720.0\u0022 cy=\u0022190.3\u0022 r=\u00223.5\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003EComposite Square Wave: -0.19 (#100)\u003C/title\u003E\u003C/circle\u003E\u003Cline x1=\u002260\u0022 y1=\u0022300\u0022 x2=\u0022720\u0022 y2=\u0022300\u0022 stroke=\u0022#30363d\u0022 stroke-width=\u00221.5\u0022/\u003E\u003Ccircle cx=\u002265.0\u0022 cy=\u0022335\u0022 r=\u00224.5\u0022 fill=\u0022#58a6ff\u0022/\u003E\u003Ctext x=\u002274.0\u0022 y=\u0022338.5\u0022 fill=\u0022#8b949e\u0022 font-size=\u002211\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EFundamental (sin t)\u003C/text\u003E\u003Ccircle cx=\u0022233.0\u0022 cy=\u0022335\u0022 r=\u00224.5\u0022 fill=\u0022#3fb950\u0022/\u003E\u003Ctext x=\u0022242.0\u0022 y=\u0022338.5\u0022 fill=\u0022#8b949e\u0022 font-size=\u002211\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003E3rd Harmonic (1/3 sin 3t)\u003C/text\u003E\u003Ccircle cx=\u0022443.0\u0022 cy=\u0022335\u0022 r=\u00224.5\u0022 fill=\u0022#d29922\u0022/\u003E\u003Ctext x=\u0022452.0\u0022 y=\u0022338.5\u0022 fill=\u0022#8b949e\u0022 font-size=\u002211\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EComposite Square Wave\u003C/text\u003E\u003C/svg\u003E\u003C/div\u003E", + "HtmlContent": "
📊 Chart
LINE
10.60.2-0.2-0.6-1Fundamental (sin t): 0.00 (#1)Fundamental (sin t): 0.06 (#2)Fundamental (sin t): 0.13 (#3)Fundamental (sin t): 0.19 (#4)Fundamental (sin t): 0.25 (#5)Fundamental (sin t): 0.31 (#6)Fundamental (sin t): 0.37 (#7)Fundamental (sin t): 0.43 (#8)Fundamental (sin t): 0.48 (#9)Fundamental (sin t): 0.54 (#10)Fundamental (sin t): 0.59 (#11)Fundamental (sin t): 0.64 (#12)Fundamental (sin t): 0.68 (#13)Fundamental (sin t): 0.73 (#14)Fundamental (sin t): 0.77 (#15)Fundamental (sin t): 0.81 (#16)Fundamental (sin t): 0.84 (#17)Fundamental (sin t): 0.88 (#18)Fundamental (sin t): 0.90 (#19)Fundamental (sin t): 0.93 (#20)Fundamental (sin t): 0.95 (#21)Fundamental (sin t): 0.97 (#22)Fundamental (sin t): 0.98 (#23)Fundamental (sin t): 0.99 (#24)Fundamental (sin t): 1.00 (#25)Fundamental (sin t): 1.00 (#26)Fundamental (sin t): 1.00 (#27)Fundamental (sin t): 0.99 (#28)Fundamental (sin t): 0.98 (#29)Fundamental (sin t): 0.97 (#30)Fundamental (sin t): 0.95 (#31)Fundamental (sin t): 0.93 (#32)Fundamental (sin t): 0.90 (#33)Fundamental (sin t): 0.88 (#34)Fundamental (sin t): 0.84 (#35)Fundamental (sin t): 0.81 (#36)Fundamental (sin t): 0.77 (#37)Fundamental (sin t): 0.73 (#38)Fundamental (sin t): 0.68 (#39)Fundamental (sin t): 0.64 (#40)Fundamental (sin t): 0.59 (#41)Fundamental (sin t): 0.54 (#42)Fundamental (sin t): 0.48 (#43)Fundamental (sin t): 0.43 (#44)Fundamental (sin t): 0.37 (#45)Fundamental (sin t): 0.31 (#46)Fundamental (sin t): 0.25 (#47)Fundamental (sin t): 0.19 (#48)Fundamental (sin t): 0.13 (#49)Fundamental (sin t): 0.06 (#50)Fundamental (sin t): 0.00 (#51)Fundamental (sin t): -0.06 (#52)Fundamental (sin t): -0.13 (#53)Fundamental (sin t): -0.19 (#54)Fundamental (sin t): -0.25 (#55)Fundamental (sin t): -0.31 (#56)Fundamental (sin t): -0.37 (#57)Fundamental (sin t): -0.43 (#58)Fundamental (sin t): -0.48 (#59)Fundamental (sin t): -0.54 (#60)Fundamental (sin t): -0.59 (#61)Fundamental (sin t): -0.64 (#62)Fundamental (sin t): -0.68 (#63)Fundamental (sin t): -0.73 (#64)Fundamental (sin t): -0.77 (#65)Fundamental (sin t): -0.81 (#66)Fundamental (sin t): -0.84 (#67)Fundamental (sin t): -0.88 (#68)Fundamental (sin t): -0.90 (#69)Fundamental (sin t): -0.93 (#70)Fundamental (sin t): -0.95 (#71)Fundamental (sin t): -0.97 (#72)Fundamental (sin t): -0.98 (#73)Fundamental (sin t): -0.99 (#74)Fundamental (sin t): -1.00 (#75)Fundamental (sin t): -1.00 (#76)Fundamental (sin t): -1.00 (#77)Fundamental (sin t): -0.99 (#78)Fundamental (sin t): -0.98 (#79)Fundamental (sin t): -0.97 (#80)Fundamental (sin t): -0.95 (#81)Fundamental (sin t): -0.93 (#82)Fundamental (sin t): -0.90 (#83)Fundamental (sin t): -0.88 (#84)Fundamental (sin t): -0.84 (#85)Fundamental (sin t): -0.81 (#86)Fundamental (sin t): -0.77 (#87)Fundamental (sin t): -0.73 (#88)Fundamental (sin t): -0.68 (#89)Fundamental (sin t): -0.64 (#90)Fundamental (sin t): -0.59 (#91)Fundamental (sin t): -0.54 (#92)Fundamental (sin t): -0.48 (#93)Fundamental (sin t): -0.43 (#94)Fundamental (sin t): -0.37 (#95)Fundamental (sin t): -0.31 (#96)Fundamental (sin t): -0.25 (#97)Fundamental (sin t): -0.19 (#98)Fundamental (sin t): -0.13 (#99)Fundamental (sin t): -0.06 (#100)3rd Harmonic (1/3 sin 3t): 0.00 (#1)3rd Harmonic (1/3 sin 3t): 0.06 (#2)3rd Harmonic (1/3 sin 3t): 0.12 (#3)3rd Harmonic (1/3 sin 3t): 0.18 (#4)3rd Harmonic (1/3 sin 3t): 0.23 (#5)3rd Harmonic (1/3 sin 3t): 0.27 (#6)3rd Harmonic (1/3 sin 3t): 0.30 (#7)3rd Harmonic (1/3 sin 3t): 0.32 (#8)3rd Harmonic (1/3 sin 3t): 0.33 (#9)3rd Harmonic (1/3 sin 3t): 0.33 (#10)3rd Harmonic (1/3 sin 3t): 0.32 (#11)3rd Harmonic (1/3 sin 3t): 0.29 (#12)3rd Harmonic (1/3 sin 3t): 0.26 (#13)3rd Harmonic (1/3 sin 3t): 0.21 (#14)3rd Harmonic (1/3 sin 3t): 0.16 (#15)3rd Harmonic (1/3 sin 3t): 0.10 (#16)3rd Harmonic (1/3 sin 3t): 0.04 (#17)3rd Harmonic (1/3 sin 3t): -0.02 (#18)3rd Harmonic (1/3 sin 3t): -0.08 (#19)3rd Harmonic (1/3 sin 3t): -0.14 (#20)3rd Harmonic (1/3 sin 3t): -0.20 (#21)3rd Harmonic (1/3 sin 3t): -0.24 (#22)3rd Harmonic (1/3 sin 3t): -0.28 (#23)3rd Harmonic (1/3 sin 3t): -0.31 (#24)3rd Harmonic (1/3 sin 3t): -0.33 (#25)3rd Harmonic (1/3 sin 3t): -0.33 (#26)3rd Harmonic (1/3 sin 3t): -0.33 (#27)3rd Harmonic (1/3 sin 3t): -0.31 (#28)3rd Harmonic (1/3 sin 3t): -0.28 (#29)3rd Harmonic (1/3 sin 3t): -0.24 (#30)3rd Harmonic (1/3 sin 3t): -0.20 (#31)3rd Harmonic (1/3 sin 3t): -0.14 (#32)3rd Harmonic (1/3 sin 3t): -0.08 (#33)3rd Harmonic (1/3 sin 3t): -0.02 (#34)3rd Harmonic (1/3 sin 3t): 0.04 (#35)3rd Harmonic (1/3 sin 3t): 0.10 (#36)3rd Harmonic (1/3 sin 3t): 0.16 (#37)3rd Harmonic (1/3 sin 3t): 0.21 (#38)3rd Harmonic (1/3 sin 3t): 0.26 (#39)3rd Harmonic (1/3 sin 3t): 0.29 (#40)3rd Harmonic (1/3 sin 3t): 0.32 (#41)3rd Harmonic (1/3 sin 3t): 0.33 (#42)3rd Harmonic (1/3 sin 3t): 0.33 (#43)3rd Harmonic (1/3 sin 3t): 0.32 (#44)3rd Harmonic (1/3 sin 3t): 0.30 (#45)3rd Harmonic (1/3 sin 3t): 0.27 (#46)3rd Harmonic (1/3 sin 3t): 0.23 (#47)3rd Harmonic (1/3 sin 3t): 0.18 (#48)3rd Harmonic (1/3 sin 3t): 0.12 (#49)3rd Harmonic (1/3 sin 3t): 0.06 (#50)3rd Harmonic (1/3 sin 3t): 0.00 (#51)3rd Harmonic (1/3 sin 3t): -0.06 (#52)3rd Harmonic (1/3 sin 3t): -0.12 (#53)3rd Harmonic (1/3 sin 3t): -0.18 (#54)3rd Harmonic (1/3 sin 3t): -0.23 (#55)3rd Harmonic (1/3 sin 3t): -0.27 (#56)3rd Harmonic (1/3 sin 3t): -0.30 (#57)3rd Harmonic (1/3 sin 3t): -0.32 (#58)3rd Harmonic (1/3 sin 3t): -0.33 (#59)3rd Harmonic (1/3 sin 3t): -0.33 (#60)3rd Harmonic (1/3 sin 3t): -0.32 (#61)3rd Harmonic (1/3 sin 3t): -0.29 (#62)3rd Harmonic (1/3 sin 3t): -0.26 (#63)3rd Harmonic (1/3 sin 3t): -0.21 (#64)3rd Harmonic (1/3 sin 3t): -0.16 (#65)3rd Harmonic (1/3 sin 3t): -0.10 (#66)3rd Harmonic (1/3 sin 3t): -0.04 (#67)3rd Harmonic (1/3 sin 3t): 0.02 (#68)3rd Harmonic (1/3 sin 3t): 0.08 (#69)3rd Harmonic (1/3 sin 3t): 0.14 (#70)3rd Harmonic (1/3 sin 3t): 0.20 (#71)3rd Harmonic (1/3 sin 3t): 0.24 (#72)3rd Harmonic (1/3 sin 3t): 0.28 (#73)3rd Harmonic (1/3 sin 3t): 0.31 (#74)3rd Harmonic (1/3 sin 3t): 0.33 (#75)3rd Harmonic (1/3 sin 3t): 0.33 (#76)3rd Harmonic (1/3 sin 3t): 0.33 (#77)3rd Harmonic (1/3 sin 3t): 0.31 (#78)3rd Harmonic (1/3 sin 3t): 0.28 (#79)3rd Harmonic (1/3 sin 3t): 0.24 (#80)3rd Harmonic (1/3 sin 3t): 0.20 (#81)3rd Harmonic (1/3 sin 3t): 0.14 (#82)3rd Harmonic (1/3 sin 3t): 0.08 (#83)3rd Harmonic (1/3 sin 3t): 0.02 (#84)3rd Harmonic (1/3 sin 3t): -0.04 (#85)3rd Harmonic (1/3 sin 3t): -0.10 (#86)3rd Harmonic (1/3 sin 3t): -0.16 (#87)3rd Harmonic (1/3 sin 3t): -0.21 (#88)3rd Harmonic (1/3 sin 3t): -0.26 (#89)3rd Harmonic (1/3 sin 3t): -0.29 (#90)3rd Harmonic (1/3 sin 3t): -0.32 (#91)3rd Harmonic (1/3 sin 3t): -0.33 (#92)3rd Harmonic (1/3 sin 3t): -0.33 (#93)3rd Harmonic (1/3 sin 3t): -0.32 (#94)3rd Harmonic (1/3 sin 3t): -0.30 (#95)3rd Harmonic (1/3 sin 3t): -0.27 (#96)3rd Harmonic (1/3 sin 3t): -0.23 (#97)3rd Harmonic (1/3 sin 3t): -0.18 (#98)3rd Harmonic (1/3 sin 3t): -0.12 (#99)3rd Harmonic (1/3 sin 3t): -0.06 (#100)Composite Square Wave: 0.00 (#1)Composite Square Wave: 0.19 (#2)Composite Square Wave: 0.37 (#3)Composite Square Wave: 0.53 (#4)Composite Square Wave: 0.67 (#5)Composite Square Wave: 0.78 (#6)Composite Square Wave: 0.86 (#7)Composite Square Wave: 0.91 (#8)Composite Square Wave: 0.93 (#9)Composite Square Wave: 0.93 (#10)Composite Square Wave: 0.90 (#11)Composite Square Wave: 0.87 (#12)Composite Square Wave: 0.82 (#13)Composite Square Wave: 0.78 (#14)Composite Square Wave: 0.74 (#15)Composite Square Wave: 0.71 (#16)Composite Square Wave: 0.70 (#17)Composite Square Wave: 0.69 (#18)Composite Square Wave: 0.70 (#19)Composite Square Wave: 0.73 (#20)Composite Square Wave: 0.76 (#21)Composite Square Wave: 0.79 (#22)Composite Square Wave: 0.82 (#23)Composite Square Wave: 0.84 (#24)Composite Square Wave: 0.86 (#25)Composite Square Wave: 0.87 (#26)Composite Square Wave: 0.86 (#27)Composite Square Wave: 0.84 (#28)Composite Square Wave: 0.82 (#29)Composite Square Wave: 0.79 (#30)Composite Square Wave: 0.76 (#31)Composite Square Wave: 0.73 (#32)Composite Square Wave: 0.70 (#33)Composite Square Wave: 0.69 (#34)Composite Square Wave: 0.70 (#35)Composite Square Wave: 0.71 (#36)Composite Square Wave: 0.74 (#37)Composite Square Wave: 0.78 (#38)Composite Square Wave: 0.82 (#39)Composite Square Wave: 0.87 (#40)Composite Square Wave: 0.90 (#41)Composite Square Wave: 0.93 (#42)Composite Square Wave: 0.93 (#43)Composite Square Wave: 0.91 (#44)Composite Square Wave: 0.86 (#45)Composite Square Wave: 0.78 (#46)Composite Square Wave: 0.67 (#47)Composite Square Wave: 0.53 (#48)Composite Square Wave: 0.37 (#49)Composite Square Wave: 0.19 (#50)Composite Square Wave: 0.00 (#51)Composite Square Wave: -0.19 (#52)Composite Square Wave: -0.37 (#53)Composite Square Wave: -0.53 (#54)Composite Square Wave: -0.67 (#55)Composite Square Wave: -0.78 (#56)Composite Square Wave: -0.86 (#57)Composite Square Wave: -0.91 (#58)Composite Square Wave: -0.93 (#59)Composite Square Wave: -0.93 (#60)Composite Square Wave: -0.90 (#61)Composite Square Wave: -0.87 (#62)Composite Square Wave: -0.82 (#63)Composite Square Wave: -0.78 (#64)Composite Square Wave: -0.74 (#65)Composite Square Wave: -0.71 (#66)Composite Square Wave: -0.70 (#67)Composite Square Wave: -0.69 (#68)Composite Square Wave: -0.70 (#69)Composite Square Wave: -0.73 (#70)Composite Square Wave: -0.76 (#71)Composite Square Wave: -0.79 (#72)Composite Square Wave: -0.82 (#73)Composite Square Wave: -0.84 (#74)Composite Square Wave: -0.86 (#75)Composite Square Wave: -0.87 (#76)Composite Square Wave: -0.86 (#77)Composite Square Wave: -0.84 (#78)Composite Square Wave: -0.82 (#79)Composite Square Wave: -0.79 (#80)Composite Square Wave: -0.76 (#81)Composite Square Wave: -0.73 (#82)Composite Square Wave: -0.70 (#83)Composite Square Wave: -0.69 (#84)Composite Square Wave: -0.70 (#85)Composite Square Wave: -0.71 (#86)Composite Square Wave: -0.74 (#87)Composite Square Wave: -0.78 (#88)Composite Square Wave: -0.82 (#89)Composite Square Wave: -0.87 (#90)Composite Square Wave: -0.90 (#91)Composite Square Wave: -0.93 (#92)Composite Square Wave: -0.93 (#93)Composite Square Wave: -0.91 (#94)Composite Square Wave: -0.86 (#95)Composite Square Wave: -0.78 (#96)Composite Square Wave: -0.67 (#97)Composite Square Wave: -0.53 (#98)Composite Square Wave: -0.37 (#99)Composite Square Wave: -0.19 (#100)Fundamental (sin t)3rd Harmonic (1/3 sin 3t)Composite Square Wave
", "TableSnapshot": null, "InspectorSnapshot": null, - "Visuals": [ - { - "MimeType": "application/vnd.fry.chart.v1\u002Bjson", - "Data": { - "kind": "line", - "series": [ - { - "name": "Fundamental (sin t)", - "y": [ - 0, - 0.06279051952931337, - 0.12533323356430426, - 0.1873813145857246, - 0.2486898871648548, - 0.3090169943749474, - 0.3681245526846779, - 0.42577929156507266, - 0.4817536741017153, - 0.5358267949789967, - 0.5877852522924731, - 0.6374239897486896, - 0.6845471059286886, - 0.7289686274214116, - 0.7705132427757893, - 0.8090169943749473, - 0.8443279255020151, - 0.8763066800438637, - 0.9048270524660196, - 0.9297764858882513, - 0.9510565162951535, - 0.9685831611286311, - 0.9822872507286886, - 0.9921147013144778, - 0.9980267284282716, - 1, - 0.9980267284282716, - 0.9921147013144779, - 0.9822872507286887, - 0.9685831611286311, - 0.9510565162951536, - 0.9297764858882515, - 0.9048270524660195, - 0.8763066800438635, - 0.844327925502015, - 0.8090169943749475, - 0.7705132427757893, - 0.7289686274214114, - 0.6845471059286888, - 0.6374239897486899, - 0.5877852522924732, - 0.535826794978997, - 0.4817536741017152, - 0.4257792915650729, - 0.36812455268467814, - 0.3090169943749475, - 0.24868988716485524, - 0.18738131458572457, - 0.12533323356430454, - 0.06279051952931358, - 1.2246467991473532E-16, - -0.06279051952931335, - -0.12533323356430429, - -0.18738131458572477, - -0.24868988716485457, - -0.3090169943749473, - -0.3681245526846779, - -0.42577929156507227, - -0.4817536741017154, - -0.5358267949789964, - -0.5877852522924727, - -0.6374239897486896, - -0.6845471059286884, - -0.7289686274214117, - -0.7705132427757894, - -0.8090169943749473, - -0.8443279255020153, - -0.8763066800438636, - -0.9048270524660198, - -0.9297764858882515, - -0.9510565162951535, - -0.9685831611286312, - -0.9822872507286887, - -0.9921147013144778, - -0.9980267284282716, - -1, - -0.9980267284282716, - -0.9921147013144779, - -0.9822872507286887, - -0.9685831611286311, - -0.9510565162951536, - -0.9297764858882516, - -0.9048270524660199, - -0.8763066800438634, - -0.844327925502015, - -0.8090169943749476, - -0.7705132427757896, - -0.7289686274214122, - -0.684547105928689, - -0.6374239897486896, - -0.5877852522924734, - -0.5358267949789971, - -0.4817536741017161, - -0.425779291565073, - -0.36812455268467786, - -0.3090169943749477, - -0.24868988716485535, - -0.18738131458572468, - -0.12533323356430465, - -0.06279051952931326 - ] - }, - { - "name": "3rd Harmonic (1/3 sin 3t)", - "y": [ - 0, - 0.06246043819524154, - 0.12270818422822599, - 0.17860893165966552, - 0.22818236864289623, - 0.2696723314583158, - 0.30160901748867314, - 0.32286105370954366, - 0.33267557614275717, - 0.33070490043815925, - 0.3170188387650512, - 0.2921022266812879, - 0.2568377475919298, - 0.21247466324956316, - 0.16058455803390506, - 0.10300566479164931, - 0.04177774452143469, - -0.02093017317643793, - -0.08289662905495167, - -0.14192643052169074, - -0.19592841743082434, - -0.2429895424738039, - -0.2814426418340049, - -0.3099254952960837, - -0.32742908357622946, - -0.3333333333333333, - -0.3274290835762295, - -0.3099254952960838, - -0.28144264183400497, - -0.24298954247380383, - -0.19592841743082468, - -0.141926430521691, - -0.08289662905495149, - -0.020930173176437753, - 0.04177774452143502, - 0.10300566479164905, - 0.1605845580339052, - 0.21247466324956327, - 0.25683774759192957, - 0.2921022266812877, - 0.31701883876505116, - 0.3307049004381592, - 0.33267557614275717, - 0.32286105370954366, - 0.3016090174886733, - 0.2696723314583159, - 0.22818236864289657, - 0.17860893165966574, - 0.12270818422822652, - 0.06246043819524189, - 1.224646799147353E-16, - -0.06246043819524165, - -0.1227081842282263, - -0.17860893165966552, - -0.22818236864289593, - -0.2696723314583157, - -0.3016090174886732, - -0.32286105370954366, - -0.33267557614275717, - -0.3307049004381593, - -0.31701883876505144, - -0.2921022266812878, - -0.2568377475919299, - -0.21247466324956324, - -0.16058455803390492, - -0.1030056647916493, - -0.041777744521434665, - 0.020930173176437507, - 0.08289662905495213, - 0.14192643052169104, - 0.19592841743082423, - 0.24298954247380428, - 0.28144264183400514, - 0.30992549529608376, - 0.3274290835762296, - 0.3333333333333333, - 0.32742908357622963, - 0.30992549529608393, - 0.28144264183400536, - 0.24298954247380372, - 0.1959284174308245, - 0.14192643052169138, - 0.08289662905495247, - 0.020930173176437285, - -0.0417777445214349, - -0.10300566479164837, - -0.16058455803390512, - -0.21247466324956205, - -0.2568377475919293, - -0.29210222668128794, - -0.3170188387650511, - -0.3307049004381592, - -0.3326755761427572, - -0.3228610537095437, - -0.30160901748867336, - -0.2696723314583156, - -0.2281823686428971, - -0.17860893165966585, - -0.12270818422822666, - -0.06246043819524143 - ] - }, - { - "name": "Composite Square Wave", - "y": [ - 0, - 0.1870543565995444, - 0.3655984682510249, - 0.5277936451203796, - 0.6670835590667817, - 0.7786893258332632, - 0.8599448734323818, - 0.9104437441496058, - 0.9319863007029672, - 0.9283350942921454, - 0.9048040910575244, - 0.8677228175549881, - 0.8238278030621238, - 0.7796398917959851, - 0.7408864975506636, - 0.7120226591665966, - 0.6958943667644191, - 0.6935731079924363, - 0.7043733729525732, - 0.7260466564915711, - 0.7551280988643292, - 0.7873970175298166, - 0.8184016593531783, - 0.8439926048933835, - 0.8608089481110728, - 0.8666666666666667, - 0.8608089481110728, - 0.8439926048933836, - 0.8184016593531784, - 0.7873970175298168, - 0.7551280988643294, - 0.7260466564915711, - 0.7043733729525734, - 0.6935731079924361, - 0.6958943667644192, - 0.7120226591665966, - 0.7408864975506636, - 0.7796398917959853, - 0.8238278030621237, - 0.867722817554988, - 0.9048040910575242, - 0.9283350942921453, - 0.9319863007029668, - 0.9104437441496058, - 0.8599448734323821, - 0.7786893258332634, - 0.6670835590667827, - 0.5277936451203797, - 0.36559846825102577, - 0.18705435659954506, - 3.6739403974420594E-16, - -0.1870543565995447, - -0.3655984682510251, - -0.52779364512038, - -0.6670835590667812, - -0.778689325833263, - -0.8599448734323819, - -0.9104437441496058, - -0.9319863007029673, - -0.9283350942921458, - -0.904804091057525, - -0.8677228175549878, - -0.8238278030621238, - -0.7796398917959853, - -0.7408864975506636, - -0.7120226591665966, - -0.6958943667644194, - -0.6935731079924367, - -0.7043733729525735, - -0.7260466564915711, - -0.7551280988643291, - -0.7873970175298165, - -0.8184016593531781, - -0.8439926048933832, - -0.8608089481110729, - -0.8666666666666667, - -0.8608089481110727, - -0.8439926048933833, - -0.8184016593531782, - -0.7873970175298166, - -0.7551280988643294, - -0.7260466564915712, - -0.7043733729525736, - -0.6935731079924361, - -0.6958943667644192, - -0.7120226591665959, - -0.7408864975506637, - -0.7796398917959844, - -0.8238278030621233, - -0.8677228175549883, - -0.9048040910575242, - -0.9283350942921452, - -0.9319863007029672, - -0.9104437441496058, - -0.8599448734323821, - -0.7786893258332632, - -0.6670835590667832, - -0.52779364512038, - -0.36559846825102615, - -0.18705435659954406 - ] - } - ] - }, - "Title": null - } - ], "HadInteractiveControl": false, "HasImage": false, "HasHtml": true, @@ -784,13 +145,13 @@ { "Id": "cell-barchart-md", "Type": 1, - "Source": "### 3. Categorical Donut \u0026 Bar Charts", + "Source": "### 3. Categorical Donut & Bar Charts", "OutputText": "", "ExecutionCount": null, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -811,12 +172,11 @@ { "Id": "cell-barchart-code", "Type": 0, - "Language": "csharp", - "Source": "// 1. Regional CPU load bar chart\nvar loads = clusterNodes.ToDictionary(n =\u003E n.Region, n =\u003E n.CpuPercent);\nDisplay.BarChart(loads, \u0022Regional Compute Load (%)\u0022);\n\n// 2. Resource memory allocation donut chart\nvar ramAllocation = clusterNodes.ToDictionary(n =\u003E $\u0022{n.Role} ({n.Region})\u0022, n =\u003E n.MemoryGb);\nDisplay.Chart(ramAllocation, \u0022Cluster Memory Distribution (GB)\u0022, chartType: \u0022donut\u0022);\n\nConsole.WriteLine(\u0022\u2713 Categorical bar \u0026 donut charts rendered.\u0022);", - "OutputText": "\u2713 Categorical bar \u0026 donut charts rendered.\n", - "ExecutionCount": 3, + "Source": "// 1. Regional CPU load bar chart\nvar loads = clusterNodes.ToDictionary(n => n.Region, n => n.CpuPercent);\nDisplay.BarChart(loads, \"Regional Compute Load (%)\");\n\n// 2. Resource memory allocation donut chart\nvar ramAllocation = clusterNodes.ToDictionary(n => $\"{n.Role} ({n.Region})\", n => n.MemoryGb);\nDisplay.Chart(ramAllocation, \"Cluster Memory Distribution (GB)\", chartType: \"donut\");\n\nConsole.WriteLine(\"✓ Categorical bar & donut charts rendered.\");", + "OutputText": "✓ Categorical bar & donut charts rendered.\n", + "ExecutionCount": 11, "IsExecuting": false, - "ExecutionTimeText": "41 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, @@ -826,67 +186,9 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "\u003Cdiv class=\u0022fry-visual-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;margin:8px 0;max-width:840px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow-x:auto;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;\u0022\u003E\u003Cdiv\u003E\u003Cdiv style=\u0022font-weight:600;font-size:13.5px;color:#58a6ff;letter-spacing:0.2px;\u0022\u003E\uD83D\uDCCA Cluster Memory Distribution (GB)\u003C/div\u003E\u003C/div\u003E\u003Cdiv style=\u0022font-size:10.5px;font-weight:600;color:#8b949e;background:#161b22;padding:3px 9px;border-radius:12px;border:1px solid #30363d;letter-spacing:0.5px;\u0022\u003EDONUT\u003C/div\u003E\u003C/div\u003E\u003Cdiv id=\u0022fry_pie_ed845486\u0022 style=\u0022position:relative;\u0022\u003E\n \u003Cstyle\u003E\n #fry_pie_ed845486 path.fry-pie-slice {\n transform-origin: 280px 180px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s;\n cursor: pointer;\n }\n #fry_pie_ed845486 path.fry-pie-slice:hover {\n transform: scale(1.05);\n filter: drop-shadow(0 2px 8px rgba(0,0,0,0.5));\n }\n #fry_pie_ed845486 .fry-pie-legend-item {\n cursor: pointer; transition: opacity 0.15s;\n }\n #fry_pie_ed845486 .fry-pie-legend-item:hover {\n opacity: 0.75;\n }\n \u003C/style\u003E\n \u003Csvg viewBox=\u00220 0 760 360\u0022 width=\u0022100%\u0022 height=\u0022auto\u0022 style=\u0022display:block;border-radius:6px;background:#161b22;\u0022 xmlns=\u0022http://www.w3.org/2000/svg\u0022\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00220\u0022 d=\u0022M 280.0 50.0 A 130 130 0 0 1 347.5 68.9 L 313.8 124.5 A 65 65 0 0 0 280.0 115.0 Z\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003EPrimary Router (us-east-1): 64.0 (8.7%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00221\u0022 d=\u0022M 347.5 68.9 A 130 130 0 0 1 409.7 188.9 L 344.8 184.4 A 65 65 0 0 0 313.8 124.5 Z\u0022 fill=\u0022#3fb950\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003ECompute Shard (us-west-2): 128.0 (17.4%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00222\u0022 d=\u0022M 409.7 188.9 A 130 130 0 0 1 198.0 280.8 L 239.0 230.4 A 65 65 0 0 0 344.8 184.4 Z\u0022 fill=\u0022#d29922\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003EPostgres Leader (eu-west-1): 256.0 (34.8%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00223\u0022 d=\u0022M 198.0 280.8 A 130 130 0 0 1 173.8 255.0 L 226.9 217.5 A 65 65 0 0 0 239.0 230.4 Z\u0022 fill=\u0022#f85149\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003EIngress Proxy (ap-southeast-1): 32.0 (4.3%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00224\u0022 d=\u0022M 173.8 255.0 A 130 130 0 0 1 150.3 188.9 L 215.2 184.4 A 65 65 0 0 0 226.9 217.5 Z\u0022 fill=\u0022#bc8cff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003ERedis In-Memory (sa-east-1): 64.0 (8.7%)\u003C/title\u003E\n\u003C/path\u003E\u003Cpath class=\u0022fry-pie-slice\u0022 data-slice=\u00225\u0022 d=\u0022M 150.3 188.9 A 130 130 0 0 1 280.0 50.0 L 280.0 115.0 A 65 65 0 0 0 215.2 184.4 Z\u0022 fill=\u0022#2dd4bf\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00222\u0022\u003E\n \u003Ctitle\u003ETensor Pipeline (ap-northeast-1): 192.0 (26.1%)\u003C/title\u003E\n\u003C/path\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00220\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u002260\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#58a6ff\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u002270\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EPrimary Router (us-east-1): 8.7%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00221\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u002284\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#3fb950\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u002294\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003ECompute Shard (us-west-2): 17.4%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00222\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022108\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#d29922\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022118\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EPostgres Leader (eu-west-1): 34.8%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00223\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022132\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#f85149\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022142\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003EIngress Proxy (ap-southeast-1): 4.3%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00224\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022156\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#bc8cff\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022166\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003ERedis In-Memory (sa-east-1): 8.7%\u003C/text\u003E\n\u003C/g\u003E\u003Cg class=\u0022fry-pie-legend-item\u0022 data-slice=\u00225\u0022\u003E\n \u003Crect x=\u0022470\u0022 y=\u0022180\u0022 width=\u002212\u0022 height=\u002212\u0022 rx=\u00222\u0022 fill=\u0022#2dd4bf\u0022/\u003E\n \u003Ctext x=\u0022490\u0022 y=\u0022190\u0022 fill=\u0022#c9d1d9\u0022 font-size=\u002211.5\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003ETensor Pipeline (ap-northeast-1): 26.1%\u003C/text\u003E\n\u003C/g\u003E\u003C/svg\u003E\u003C/div\u003E\u003C/div\u003E", + "HtmlContent": "
📊 Cluster Memory Distribution (GB)
DONUT
\n \n \n Primary Router (us-east-1): 64.0 (8.7%)\n\n Compute Shard (us-west-2): 128.0 (17.4%)\n\n Postgres Leader (eu-west-1): 256.0 (34.8%)\n\n Ingress Proxy (ap-southeast-1): 32.0 (4.3%)\n\n Redis In-Memory (sa-east-1): 64.0 (8.7%)\n\n Tensor Pipeline (ap-northeast-1): 192.0 (26.1%)\n\n \n Primary Router (us-east-1): 8.7%\n\n \n Compute Shard (us-west-2): 17.4%\n\n \n Postgres Leader (eu-west-1): 34.8%\n\n \n Ingress Proxy (ap-southeast-1): 4.3%\n\n \n Redis In-Memory (sa-east-1): 8.7%\n\n \n Tensor Pipeline (ap-northeast-1): 26.1%\n
", "TableSnapshot": null, "InspectorSnapshot": null, - "Visuals": [ - { - "MimeType": "application/vnd.fry.chart.v1\u002Bjson", - "Data": { - "kind": "bar", - "series": [ - { - "y": [ - 24.5, - 78.2, - 45, - 15.8, - 88.9, - 92.1 - ], - "labels": [ - "us-east-1", - "us-west-2", - "eu-west-1", - "ap-southeast-1", - "sa-east-1", - "ap-northeast-1" - ] - } - ], - "title": "Regional Compute Load (%)" - }, - "Title": "Regional Compute Load (%)" - }, - { - "MimeType": "application/vnd.fry.chart.v1\u002Bjson", - "Data": { - "kind": "donut", - "series": [ - { - "y": [ - 64, - 128, - 256, - 32, - 64, - 192 - ], - "labels": [ - "Primary Router (us-east-1)", - "Compute Shard (us-west-2)", - "Postgres Leader (eu-west-1)", - "Ingress Proxy (ap-southeast-1)", - "Redis In-Memory (sa-east-1)", - "Tensor Pipeline (ap-northeast-1)" - ] - } - ], - "title": "Cluster Memory Distribution (GB)" - }, - "Title": "Cluster Memory Distribution (GB)" - } - ], "HadInteractiveControl": false, "HasImage": false, "HasHtml": true, @@ -903,7 +205,7 @@ "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -924,12 +226,11 @@ { "Id": "cell-plot3d-code", "Type": 0, - "Language": "csharp", - "Source": "// 3D Sombrero surface wave: z = sin(r) / r\nDisplay.Plot3D((x, y) =\u003E {\n double r = Math.Sqrt(x * x \u002B y * y);\n return r == 0 ? 1.0 : Math.Sin(r) / r;\n}, (-6.0, 6.0), (-6.0, 6.0), resolution: 28, title: \u0022Sombrero Wave Surface (z = sin(r) / r)\u0022);\n\nConsole.WriteLine(\u0022\u2713 3D Parametric surface mesh rendered.\u0022);", - "OutputText": "\u2713 3D Parametric surface mesh rendered.\n", - "ExecutionCount": 4, + "Source": "// 3D Sombrero surface wave: z = sin(r) / r\nDisplay.Plot3D((x, y) => {\n double r = Math.Sqrt(x * x + y * y);\n return r == 0 ? 1.0 : Math.Sin(r) / r;\n}, (-6.0, 6.0), (-6.0, 6.0), resolution: 28, title: \"Sombrero Wave Surface (z = sin(r) / r)\");\n\nConsole.WriteLine(\"✓ 3D Parametric surface mesh rendered.\");", + "OutputText": "✓ 3D Parametric surface mesh rendered.\n", + "ExecutionCount": 12, "IsExecuting": false, - "ExecutionTimeText": "74 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, @@ -939,871 +240,9 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "\u003Cdiv class=\u0022fry-visual-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;margin:8px 0;max-width:840px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow-x:auto;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;\u0022\u003E\u003Cdiv\u003E\u003Cdiv style=\u0022font-weight:600;font-size:13.5px;color:#58a6ff;letter-spacing:0.2px;\u0022\u003E\uD83D\uDCCA Sombrero Wave Surface (z = sin(r) / r)\u003C/div\u003E\u003C/div\u003E\u003Cdiv style=\u0022font-size:10.5px;font-weight:600;color:#8b949e;background:#161b22;padding:3px 9px;border-radius:12px;border:1px solid #30363d;letter-spacing:0.5px;\u0022\u003ESURFACE\u003C/div\u003E\u003C/div\u003E\u003Cdiv id=\u0022fry_3d_968ce822\u0022 style=\u0022position:relative;user-select:none;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-bottom:8px;font-size:11px;color:#8b949e;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;gap:6px;\u0022\u003E\n \u003Cspan\u003E\uD83D\uDD04 Rotate\u003C/span\u003E\n \u003Cinput type=\u0022range\u0022 class=\u0022fry-3d-az-slider\u0022 min=\u00220\u0022 max=\u0022360\u0022 value=\u002230\u0022 style=\u0022width:90px;height:4px;accent-color:#58a6ff;cursor:pointer;\u0022 oninput=\u0022window.fryRotate3D \u0026\u0026 window.fryRotate3D(\u0027fry_3d_968ce822\u0027, this.value, null)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;gap:6px;\u0022\u003E\n \u003Cspan\u003E\uD83D\uDCD0 Tilt\u003C/span\u003E\n \u003Cinput type=\u0022range\u0022 class=\u0022fry-3d-el-slider\u0022 min=\u002215\u0022 max=\u002280\u0022 value=\u002235\u0022 style=\u0022width:70px;height:4px;accent-color:#58a6ff;cursor:pointer;\u0022 oninput=\u0022window.fryRotate3D \u0026\u0026 window.fryRotate3D(\u0027fry_3d_968ce822\u0027, null, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10px;\u0022\u003E(Drag on canvas to rotate)\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Csvg class=\u0022fry-3d-canvas\u0022 viewBox=\u00220 0 760 380\u0022 width=\u0022100%\u0022 height=\u0022auto\u0022 style=\u0022display:block;border-radius:6px;background:#161b22;cursor:grab;\u0022 xmlns=\u0022http://www.w3.org/2000/svg\u0022\u003E\n \u003Cg class=\u0022fry-3d-mesh\u0022\u003E\u003C/g\u003E\n \u003C/svg\u003E\u003Cscript\u003E\n(function() {\n const root = document.getElementById(\u0027fry_3d_968ce822\u0027);\n if (!root) return;\n const grid = [[[-1.000,-1.000,0.260],[-0.926,-1.000,0.277],[-0.852,-1.000,0.287],[-0.778,-1.000,0.287],[-0.704,-1.000,0.280],[-0.630,-1.000,0.266],[-0.556,-1.000,0.247],[-0.481,-1.000,0.227],[-0.407,-1.000,0.206],[-0.333,-1.000,0.186],[-0.259,-1.000,0.170],[-0.185,-1.000,0.156],[-0.111,-1.000,0.147],[-0.037,-1.000,0.143],[0.037,-1.000,0.143],[0.111,-1.000,0.147],[0.185,-1.000,0.156],[0.259,-1.000,0.170],[0.333,-1.000,0.186],[0.407,-1.000,0.206],[0.481,-1.000,0.227],[0.556,-1.000,0.247],[0.630,-1.000,0.266],[0.704,-1.000,0.280],[0.778,-1.000,0.287],[0.852,-1.000,0.287],[0.926,-1.000,0.277],[1.000,-1.000,0.260],],[[-1.000,-0.926,0.277],[-0.926,-0.926,0.287],[-0.852,-0.926,0.286],[-0.778,-0.926,0.276],[-0.704,-0.926,0.257],[-0.630,-0.926,0.233],[-0.556,-0.926,0.206],[-0.481,-0.926,0.178],[-0.407,-0.926,0.152],[-0.333,-0.926,0.129],[-0.259,-0.926,0.110],[-0.185,-0.926,0.096],[-0.111,-0.926,0.086],[-0.037,-0.926,0.082],[0.037,-0.926,0.082],[0.111,-0.926,0.086],[0.185,-0.926,0.096],[0.259,-0.926,0.110],[0.333,-0.926,0.129],[0.407,-0.926,0.152],[0.481,-0.926,0.178],[0.556,-0.926,0.206],[0.630,-0.926,0.233],[0.704,-0.926,0.257],[0.778,-0.926,0.276],[0.852,-0.926,0.286],[0.926,-0.926,0.287],[1.000,-0.926,0.277],],[[-1.000,-0.852,0.287],[-0.926,-0.852,0.286],[-0.852,-0.852,0.274],[-0.778,-0.852,0.253],[-0.704,-0.852,0.224],[-0.630,-0.852,0.190],[-0.556,-0.852,0.156],[-0.481,-0.852,0.124],[-0.407,-0.852,0.096],[-0.333,-0.852,0.073],[-0.259,-0.852,0.055],[-0.185,-0.852,0.043],[-0.111,-0.852,0.035],[-0.037,-0.852,0.031],[0.037,-0.852,0.031],[0.111,-0.852,0.035],[0.185,-0.852,0.043],[0.259,-0.852,0.055],[0.333,-0.852,0.073],[0.407,-0.852,0.096],[0.481,-0.852,0.124],[0.556,-0.852,0.156],[0.630,-0.852,0.190],[0.704,-0.852,0.224],[0.778,-0.852,0.253],[0.852,-0.852,0.274],[0.926,-0.852,0.286],[1.000,-0.852,0.287],],[[-1.000,-0.778,0.287],[-0.926,-0.778,0.276],[-0.852,-0.778,0.253],[-0.778,-0.778,0.220],[-0.704,-0.778,0.182],[-0.630,-0.778,0.143],[-0.556,-0.778,0.105],[-0.481,-0.778,0.073],[-0.407,-0.778,0.047],[-0.333,-0.778,0.028],[-0.259,-0.778,0.015],[-0.185,-0.778,0.008],[-0.111,-0.778,0.004],[-0.037,-0.778,0.003],[0.037,-0.778,0.003],[0.111,-0.778,0.004],[0.185,-0.778,0.008],[0.259,-0.778,0.015],[0.333,-0.778,0.028],[0.407,-0.778,0.047],[0.481,-0.778,0.073],[0.556,-0.778,0.105],[0.630,-0.778,0.143],[0.704,-0.778,0.182],[0.778,-0.778,0.220],[0.852,-0.778,0.253],[0.926,-0.778,0.276],[1.000,-0.778,0.287],],[[-1.000,-0.704,0.280],[-0.926,-0.704,0.257],[-0.852,-0.704,0.224],[-0.778,-0.704,0.182],[-0.704,-0.704,0.138],[-0.630,-0.704,0.096],[-0.556,-0.704,0.059],[-0.481,-0.704,0.031],[-0.407,-0.704,0.013],[-0.333,-0.704,0.003],[-0.259,-0.704,0.000],[-0.185,-0.704,0.001],[-0.111,-0.704,0.004],[-0.037,-0.704,0.007],[0.037,-0.704,0.007],[0.111,-0.704,0.004],[0.185,-0.704,0.001],[0.259,-0.704,0.000],[0.333,-0.704,0.003],[0.407,-0.704,0.013],[0.481,-0.704,0.031],[0.556,-0.704,0.059],[0.630,-0.704,0.096],[0.704,-0.704,0.138],[0.778,-0.704,0.182],[0.852,-0.704,0.224],[0.926,-0.704,0.257],[1.000,-0.704,0.280],],[[-1.000,-0.630,0.266],[-0.926,-0.630,0.233],[-0.852,-0.630,0.190],[-0.778,-0.630,0.143],[-0.704,-0.630,0.096],[-0.630,-0.630,0.055],[-0.556,-0.630,0.024],[-0.481,-0.630,0.006],[-0.407,-0.630,0.000],[-0.333,-0.630,0.004],[-0.259,-0.630,0.016],[-0.185,-0.630,0.030],[-0.111,-0.630,0.042],[-0.037,-0.630,0.049],[0.037,-0.630,0.049],[0.111,-0.630,0.042],[0.185,-0.630,0.030],[0.259,-0.630,0.016],[0.333,-0.630,0.004],[0.407,-0.630,0.000],[0.481,-0.630,0.006],[0.556,-0.630,0.024],[0.630,-0.630,0.055],[0.704,-0.630,0.096],[0.778,-0.630,0.143],[0.852,-0.630,0.190],[0.926,-0.630,0.233],[1.000,-0.630,0.266],],[[-1.000,-0.556,0.247],[-0.926,-0.556,0.206],[-0.852,-0.556,0.156],[-0.778,-0.556,0.105],[-0.704,-0.556,0.059],[-0.630,-0.556,0.024],[-0.556,-0.556,0.004],[-0.481,-0.556,0.001],[-0.407,-0.556,0.012],[-0.333,-0.556,0.036],[-0.259,-0.556,0.065],[-0.185,-0.556,0.095],[-0.111,-0.556,0.118],[-0.037,-0.556,0.132],[0.037,-0.556,0.132],[0.111,-0.556,0.118],[0.185,-0.556,0.095],[0.259,-0.556,0.065],[0.333,-0.556,0.036],[0.407,-0.556,0.012],[0.481,-0.556,0.001],[0.556,-0.556,0.004],[0.630,-0.556,0.024],[0.704,-0.556,0.059],[0.778,-0.556,0.105],[0.852,-0.556,0.156],[0.926,-0.556,0.206],[1.000,-0.556,0.247],],[[-1.000,-0.481,0.227],[-0.926,-0.481,0.178],[-0.852,-0.481,0.124],[-0.778,-0.481,0.073],[-0.704,-0.481,0.031],[-0.630,-0.481,0.006],[-0.556,-0.481,0.001],[-0.481,-0.481,0.016],[-0.407,-0.481,0.049],[-0.333,-0.481,0.095],[-0.259,-0.481,0.146],[-0.185,-0.481,0.193],[-0.111,-0.481,0.230],[-0.037,-0.481,0.250],[0.037,-0.481,0.250],[0.111,-0.481,0.230],[0.185,-0.481,0.193],[0.259,-0.481,0.146],[0.333,-0.481,0.095],[0.407,-0.481,0.049],[0.481,-0.481,0.016],[0.556,-0.481,0.001],[0.630,-0.481,0.006],[0.704,-0.481,0.031],[0.778,-0.481,0.073],[0.852,-0.481,0.124],[0.926,-0.481,0.178],[1.000,-0.481,0.227],],[[-1.000,-0.407,0.206],[-0.926,-0.407,0.152],[-0.852,-0.407,0.096],[-0.778,-0.407,0.047],[-0.704,-0.407,0.013],[-0.630,-0.407,0.000],[-0.556,-0.407,0.012],[-0.481,-0.407,0.049],[-0.407,-0.407,0.106],[-0.333,-0.407,0.176],[-0.259,-0.407,0.250],[-0.185,-0.407,0.318],[-0.111,-0.407,0.369],[-0.037,-0.407,0.396],[0.037,-0.407,0.396],[0.111,-0.407,0.369],[0.185,-0.407,0.318],[0.259,-0.407,0.250],[0.333,-0.407,0.176],[0.407,-0.407,0.106],[0.481,-0.407,0.049],[0.556,-0.407,0.012],[0.630,-0.407,0.000],[0.704,-0.407,0.013],[0.778,-0.407,0.047],[0.852,-0.407,0.096],[0.926,-0.407,0.152],[1.000,-0.407,0.206],],[[-1.000,-0.333,0.186],[-0.926,-0.333,0.129],[-0.852,-0.333,0.073],[-0.778,-0.333,0.028],[-0.704,-0.333,0.003],[-0.630,-0.333,0.004],[-0.556,-0.333,0.036],[-0.481,-0.333,0.095],[-0.407,-0.333,0.176],[-0.333,-0.333,0.272],[-0.259,-0.333,0.369],[-0.185,-0.333,0.455],[-0.111,-0.333,0.520],[-0.037,-0.333,0.555],[0.037,-0.333,0.555],[0.111,-0.333,0.520],[0.185,-0.333,0.455],[0.259,-0.333,0.369],[0.333,-0.333,0.272],[0.407,-0.333,0.176],[0.481,-0.333,0.095],[0.556,-0.333,0.036],[0.630,-0.333,0.004],[0.704,-0.333,0.003],[0.778,-0.333,0.028],[0.852,-0.333,0.073],[0.926,-0.333,0.129],[1.000,-0.333,0.186],],[[-1.000,-0.259,0.170],[-0.926,-0.259,0.110],[-0.852,-0.259,0.055],[-0.778,-0.259,0.015],[-0.704,-0.259,0.000],[-0.630,-0.259,0.016],[-0.556,-0.259,0.065],[-0.481,-0.259,0.146],[-0.407,-0.259,0.250],[-0.333,-0.259,0.369],[-0.259,-0.259,0.487],[-0.185,-0.259,0.591],[-0.111,-0.259,0.669],[-0.037,-0.259,0.711],[0.037,-0.259,0.711],[0.111,-0.259,0.669],[0.185,-0.259,0.591],[0.259,-0.259,0.487],[0.333,-0.259,0.369],[0.407,-0.259,0.250],[0.481,-0.259,0.146],[0.556,-0.259,0.065],[0.630,-0.259,0.016],[0.704,-0.259,0.000],[0.778,-0.259,0.015],[0.852,-0.259,0.055],[0.926,-0.259,0.110],[1.000,-0.259,0.170],],[[-1.000,-0.185,0.156],[-0.926,-0.185,0.096],[-0.852,-0.185,0.043],[-0.778,-0.185,0.008],[-0.704,-0.185,0.001],[-0.630,-0.185,0.030],[-0.556,-0.185,0.095],[-0.481,-0.185,0.193],[-0.407,-0.185,0.318],[-0.333,-0.185,0.455],[-0.259,-0.185,0.591],[-0.185,-0.185,0.711],[-0.111,-0.185,0.799],[-0.037,-0.185,0.847],[0.037,-0.185,0.847],[0.111,-0.185,0.799],[0.185,-0.185,0.711],[0.259,-0.185,0.591],[0.333,-0.185,0.455],[0.407,-0.185,0.318],[0.481,-0.185,0.193],[0.556,-0.185,0.095],[0.630,-0.185,0.030],[0.704,-0.185,0.001],[0.778,-0.185,0.008],[0.852,-0.185,0.043],[0.926,-0.185,0.096],[1.000,-0.185,0.156],],[[-1.000,-0.111,0.147],[-0.926,-0.111,0.086],[-0.852,-0.111,0.035],[-0.778,-0.111,0.004],[-0.704,-0.111,0.004],[-0.630,-0.111,0.042],[-0.556,-0.111,0.118],[-0.481,-0.111,0.230],[-0.407,-0.111,0.369],[-0.333,-0.111,0.520],[-0.259,-0.111,0.669],[-0.185,-0.111,0.799],[-0.111,-0.111,0.896],[-0.037,-0.111,0.947],[0.037,-0.111,0.947],[0.111,-0.111,0.896],[0.185,-0.111,0.799],[0.259,-0.111,0.669],[0.333,-0.111,0.520],[0.407,-0.111,0.369],[0.481,-0.111,0.230],[0.556,-0.111,0.118],[0.630,-0.111,0.042],[0.704,-0.111,0.004],[0.778,-0.111,0.004],[0.852,-0.111,0.035],[0.926,-0.111,0.086],[1.000,-0.111,0.147],],[[-1.000,-0.037,0.143],[-0.926,-0.037,0.082],[-0.852,-0.037,0.031],[-0.778,-0.037,0.003],[-0.704,-0.037,0.007],[-0.630,-0.037,0.049],[-0.556,-0.037,0.132],[-0.481,-0.037,0.250],[-0.407,-0.037,0.396],[-0.333,-0.037,0.555],[-0.259,-0.037,0.711],[-0.185,-0.037,0.847],[-0.111,-0.037,0.947],[-0.037,-0.037,1.000],[0.037,-0.037,1.000],[0.111,-0.037,0.947],[0.185,-0.037,0.847],[0.259,-0.037,0.711],[0.333,-0.037,0.555],[0.407,-0.037,0.396],[0.481,-0.037,0.250],[0.556,-0.037,0.132],[0.630,-0.037,0.049],[0.704,-0.037,0.007],[0.778,-0.037,0.003],[0.852,-0.037,0.031],[0.926,-0.037,0.082],[1.000,-0.037,0.143],],[[-1.000,0.037,0.143],[-0.926,0.037,0.082],[-0.852,0.037,0.031],[-0.778,0.037,0.003],[-0.704,0.037,0.007],[-0.630,0.037,0.049],[-0.556,0.037,0.132],[-0.481,0.037,0.250],[-0.407,0.037,0.396],[-0.333,0.037,0.555],[-0.259,0.037,0.711],[-0.185,0.037,0.847],[-0.111,0.037,0.947],[-0.037,0.037,1.000],[0.037,0.037,1.000],[0.111,0.037,0.947],[0.185,0.037,0.847],[0.259,0.037,0.711],[0.333,0.037,0.555],[0.407,0.037,0.396],[0.481,0.037,0.250],[0.556,0.037,0.132],[0.630,0.037,0.049],[0.704,0.037,0.007],[0.778,0.037,0.003],[0.852,0.037,0.031],[0.926,0.037,0.082],[1.000,0.037,0.143],],[[-1.000,0.111,0.147],[-0.926,0.111,0.086],[-0.852,0.111,0.035],[-0.778,0.111,0.004],[-0.704,0.111,0.004],[-0.630,0.111,0.042],[-0.556,0.111,0.118],[-0.481,0.111,0.230],[-0.407,0.111,0.369],[-0.333,0.111,0.520],[-0.259,0.111,0.669],[-0.185,0.111,0.799],[-0.111,0.111,0.896],[-0.037,0.111,0.947],[0.037,0.111,0.947],[0.111,0.111,0.896],[0.185,0.111,0.799],[0.259,0.111,0.669],[0.333,0.111,0.520],[0.407,0.111,0.369],[0.481,0.111,0.230],[0.556,0.111,0.118],[0.630,0.111,0.042],[0.704,0.111,0.004],[0.778,0.111,0.004],[0.852,0.111,0.035],[0.926,0.111,0.086],[1.000,0.111,0.147],],[[-1.000,0.185,0.156],[-0.926,0.185,0.096],[-0.852,0.185,0.043],[-0.778,0.185,0.008],[-0.704,0.185,0.001],[-0.630,0.185,0.030],[-0.556,0.185,0.095],[-0.481,0.185,0.193],[-0.407,0.185,0.318],[-0.333,0.185,0.455],[-0.259,0.185,0.591],[-0.185,0.185,0.711],[-0.111,0.185,0.799],[-0.037,0.185,0.847],[0.037,0.185,0.847],[0.111,0.185,0.799],[0.185,0.185,0.711],[0.259,0.185,0.591],[0.333,0.185,0.455],[0.407,0.185,0.318],[0.481,0.185,0.193],[0.556,0.185,0.095],[0.630,0.185,0.030],[0.704,0.185,0.001],[0.778,0.185,0.008],[0.852,0.185,0.043],[0.926,0.185,0.096],[1.000,0.185,0.156],],[[-1.000,0.259,0.170],[-0.926,0.259,0.110],[-0.852,0.259,0.055],[-0.778,0.259,0.015],[-0.704,0.259,0.000],[-0.630,0.259,0.016],[-0.556,0.259,0.065],[-0.481,0.259,0.146],[-0.407,0.259,0.250],[-0.333,0.259,0.369],[-0.259,0.259,0.487],[-0.185,0.259,0.591],[-0.111,0.259,0.669],[-0.037,0.259,0.711],[0.037,0.259,0.711],[0.111,0.259,0.669],[0.185,0.259,0.591],[0.259,0.259,0.487],[0.333,0.259,0.369],[0.407,0.259,0.250],[0.481,0.259,0.146],[0.556,0.259,0.065],[0.630,0.259,0.016],[0.704,0.259,0.000],[0.778,0.259,0.015],[0.852,0.259,0.055],[0.926,0.259,0.110],[1.000,0.259,0.170],],[[-1.000,0.333,0.186],[-0.926,0.333,0.129],[-0.852,0.333,0.073],[-0.778,0.333,0.028],[-0.704,0.333,0.003],[-0.630,0.333,0.004],[-0.556,0.333,0.036],[-0.481,0.333,0.095],[-0.407,0.333,0.176],[-0.333,0.333,0.272],[-0.259,0.333,0.369],[-0.185,0.333,0.455],[-0.111,0.333,0.520],[-0.037,0.333,0.555],[0.037,0.333,0.555],[0.111,0.333,0.520],[0.185,0.333,0.455],[0.259,0.333,0.369],[0.333,0.333,0.272],[0.407,0.333,0.176],[0.481,0.333,0.095],[0.556,0.333,0.036],[0.630,0.333,0.004],[0.704,0.333,0.003],[0.778,0.333,0.028],[0.852,0.333,0.073],[0.926,0.333,0.129],[1.000,0.333,0.186],],[[-1.000,0.407,0.206],[-0.926,0.407,0.152],[-0.852,0.407,0.096],[-0.778,0.407,0.047],[-0.704,0.407,0.013],[-0.630,0.407,0.000],[-0.556,0.407,0.012],[-0.481,0.407,0.049],[-0.407,0.407,0.106],[-0.333,0.407,0.176],[-0.259,0.407,0.250],[-0.185,0.407,0.318],[-0.111,0.407,0.369],[-0.037,0.407,0.396],[0.037,0.407,0.396],[0.111,0.407,0.369],[0.185,0.407,0.318],[0.259,0.407,0.250],[0.333,0.407,0.176],[0.407,0.407,0.106],[0.481,0.407,0.049],[0.556,0.407,0.012],[0.630,0.407,0.000],[0.704,0.407,0.013],[0.778,0.407,0.047],[0.852,0.407,0.096],[0.926,0.407,0.152],[1.000,0.407,0.206],],[[-1.000,0.481,0.227],[-0.926,0.481,0.178],[-0.852,0.481,0.124],[-0.778,0.481,0.073],[-0.704,0.481,0.031],[-0.630,0.481,0.006],[-0.556,0.481,0.001],[-0.481,0.481,0.016],[-0.407,0.481,0.049],[-0.333,0.481,0.095],[-0.259,0.481,0.146],[-0.185,0.481,0.193],[-0.111,0.481,0.230],[-0.037,0.481,0.250],[0.037,0.481,0.250],[0.111,0.481,0.230],[0.185,0.481,0.193],[0.259,0.481,0.146],[0.333,0.481,0.095],[0.407,0.481,0.049],[0.481,0.481,0.016],[0.556,0.481,0.001],[0.630,0.481,0.006],[0.704,0.481,0.031],[0.778,0.481,0.073],[0.852,0.481,0.124],[0.926,0.481,0.178],[1.000,0.481,0.227],],[[-1.000,0.556,0.247],[-0.926,0.556,0.206],[-0.852,0.556,0.156],[-0.778,0.556,0.105],[-0.704,0.556,0.059],[-0.630,0.556,0.024],[-0.556,0.556,0.004],[-0.481,0.556,0.001],[-0.407,0.556,0.012],[-0.333,0.556,0.036],[-0.259,0.556,0.065],[-0.185,0.556,0.095],[-0.111,0.556,0.118],[-0.037,0.556,0.132],[0.037,0.556,0.132],[0.111,0.556,0.118],[0.185,0.556,0.095],[0.259,0.556,0.065],[0.333,0.556,0.036],[0.407,0.556,0.012],[0.481,0.556,0.001],[0.556,0.556,0.004],[0.630,0.556,0.024],[0.704,0.556,0.059],[0.778,0.556,0.105],[0.852,0.556,0.156],[0.926,0.556,0.206],[1.000,0.556,0.247],],[[-1.000,0.630,0.266],[-0.926,0.630,0.233],[-0.852,0.630,0.190],[-0.778,0.630,0.143],[-0.704,0.630,0.096],[-0.630,0.630,0.055],[-0.556,0.630,0.024],[-0.481,0.630,0.006],[-0.407,0.630,0.000],[-0.333,0.630,0.004],[-0.259,0.630,0.016],[-0.185,0.630,0.030],[-0.111,0.630,0.042],[-0.037,0.630,0.049],[0.037,0.630,0.049],[0.111,0.630,0.042],[0.185,0.630,0.030],[0.259,0.630,0.016],[0.333,0.630,0.004],[0.407,0.630,0.000],[0.481,0.630,0.006],[0.556,0.630,0.024],[0.630,0.630,0.055],[0.704,0.630,0.096],[0.778,0.630,0.143],[0.852,0.630,0.190],[0.926,0.630,0.233],[1.000,0.630,0.266],],[[-1.000,0.704,0.280],[-0.926,0.704,0.257],[-0.852,0.704,0.224],[-0.778,0.704,0.182],[-0.704,0.704,0.138],[-0.630,0.704,0.096],[-0.556,0.704,0.059],[-0.481,0.704,0.031],[-0.407,0.704,0.013],[-0.333,0.704,0.003],[-0.259,0.704,0.000],[-0.185,0.704,0.001],[-0.111,0.704,0.004],[-0.037,0.704,0.007],[0.037,0.704,0.007],[0.111,0.704,0.004],[0.185,0.704,0.001],[0.259,0.704,0.000],[0.333,0.704,0.003],[0.407,0.704,0.013],[0.481,0.704,0.031],[0.556,0.704,0.059],[0.630,0.704,0.096],[0.704,0.704,0.138],[0.778,0.704,0.182],[0.852,0.704,0.224],[0.926,0.704,0.257],[1.000,0.704,0.280],],[[-1.000,0.778,0.287],[-0.926,0.778,0.276],[-0.852,0.778,0.253],[-0.778,0.778,0.220],[-0.704,0.778,0.182],[-0.630,0.778,0.143],[-0.556,0.778,0.105],[-0.481,0.778,0.073],[-0.407,0.778,0.047],[-0.333,0.778,0.028],[-0.259,0.778,0.015],[-0.185,0.778,0.008],[-0.111,0.778,0.004],[-0.037,0.778,0.003],[0.037,0.778,0.003],[0.111,0.778,0.004],[0.185,0.778,0.008],[0.259,0.778,0.015],[0.333,0.778,0.028],[0.407,0.778,0.047],[0.481,0.778,0.073],[0.556,0.778,0.105],[0.630,0.778,0.143],[0.704,0.778,0.182],[0.778,0.778,0.220],[0.852,0.778,0.253],[0.926,0.778,0.276],[1.000,0.778,0.287],],[[-1.000,0.852,0.287],[-0.926,0.852,0.286],[-0.852,0.852,0.274],[-0.778,0.852,0.253],[-0.704,0.852,0.224],[-0.630,0.852,0.190],[-0.556,0.852,0.156],[-0.481,0.852,0.124],[-0.407,0.852,0.096],[-0.333,0.852,0.073],[-0.259,0.852,0.055],[-0.185,0.852,0.043],[-0.111,0.852,0.035],[-0.037,0.852,0.031],[0.037,0.852,0.031],[0.111,0.852,0.035],[0.185,0.852,0.043],[0.259,0.852,0.055],[0.333,0.852,0.073],[0.407,0.852,0.096],[0.481,0.852,0.124],[0.556,0.852,0.156],[0.630,0.852,0.190],[0.704,0.852,0.224],[0.778,0.852,0.253],[0.852,0.852,0.274],[0.926,0.852,0.286],[1.000,0.852,0.287],],[[-1.000,0.926,0.277],[-0.926,0.926,0.287],[-0.852,0.926,0.286],[-0.778,0.926,0.276],[-0.704,0.926,0.257],[-0.630,0.926,0.233],[-0.556,0.926,0.206],[-0.481,0.926,0.178],[-0.407,0.926,0.152],[-0.333,0.926,0.129],[-0.259,0.926,0.110],[-0.185,0.926,0.096],[-0.111,0.926,0.086],[-0.037,0.926,0.082],[0.037,0.926,0.082],[0.111,0.926,0.086],[0.185,0.926,0.096],[0.259,0.926,0.110],[0.333,0.926,0.129],[0.407,0.926,0.152],[0.481,0.926,0.178],[0.556,0.926,0.206],[0.630,0.926,0.233],[0.704,0.926,0.257],[0.778,0.926,0.276],[0.852,0.926,0.286],[0.926,0.926,0.287],[1.000,0.926,0.277],],[[-1.000,1.000,0.260],[-0.926,1.000,0.277],[-0.852,1.000,0.287],[-0.778,1.000,0.287],[-0.704,1.000,0.280],[-0.630,1.000,0.266],[-0.556,1.000,0.247],[-0.481,1.000,0.227],[-0.407,1.000,0.206],[-0.333,1.000,0.186],[-0.259,1.000,0.170],[-0.185,1.000,0.156],[-0.111,1.000,0.147],[-0.037,1.000,0.143],[0.037,1.000,0.143],[0.111,1.000,0.147],[0.185,1.000,0.156],[0.259,1.000,0.170],[0.333,1.000,0.186],[0.407,1.000,0.206],[0.481,1.000,0.227],[0.556,1.000,0.247],[0.630,1.000,0.266],[0.704,1.000,0.280],[0.778,1.000,0.287],[0.852,1.000,0.287],[0.926,1.000,0.277],[1.000,1.000,0.260],],];\n const rows = grid.length;\n const cols = grid[0].length;\n const svgMesh = root.querySelector(\u0027.fry-3d-mesh\u0027);\n const canvas = root.querySelector(\u0027.fry-3d-canvas\u0027);\n\n let azDeg = 30;\n let elDeg = 35;\n const cx = 380;\n const cy = 190;\n const scaleX = 180.0 * 0.7;\n const scaleY = 90.0 * 0.7;\n const scaleZ = 120.0 * 0.6;\n\n function colorMap(t) {\n const hue = 240.0 - (Math.max(0, Math.min(1, t)) * 195.0);\n return \u0027hsl(\u0027 \u002B Math.round(hue) \u002B \u0027, 85%, 55%)\u0027;\n }\n\n function render(az, el) {\n const azRad = az * Math.PI / 180.0;\n const elRad = el * Math.PI / 180.0;\n const cosAz = Math.cos(azRad);\n const sinAz = Math.sin(azRad);\n const sinEl = Math.sin(elRad);\n\n function project(u, v, z) {\n const zScaled = (z * 1.8) - 0.9;\n const xRot = (u * cosAz - v * sinAz);\n const yRot = (u * sinAz \u002B v * cosAz);\n const px = cx \u002B xRot * scaleX;\n const py = cy \u002B (yRot * sinEl * scaleY) - (zScaled * scaleZ);\n return [px, py];\n }\n\n let svgContent = \u0027\u0027;\n\n // Row curves\n for (let r = 0; r \u003C rows; r\u002B\u002B) {\n let path = \u0027\u0027;\n let rowZSum = 0;\n for (let c = 0; c \u003C cols; c\u002B\u002B) {\n const pt = grid[r][c];\n rowZSum \u002B= pt[2];\n const p = project(pt[0], pt[1], pt[2]);\n path \u002B= (c === 0 ? \u0027M \u0027 : \u0027L \u0027) \u002B p[0].toFixed(1) \u002B \u0027 \u0027 \u002B p[1].toFixed(1) \u002B \u0027 \u0027;\n }\n const col = colorMap(rowZSum / cols);\n svgContent \u002B= \u0027\u003Cpath d=\u0022\u0027 \u002B path \u002B \u0027\u0022 fill=\u0022none\u0022 stroke=\u0022\u0027 \u002B col \u002B \u0027\u0022 stroke-width=\u00221.4\u0022 opacity=\u00220.85\u0022 /\u003E\u0027;\n }\n\n // Col curves (mesh cross lines)\n const colStep = Math.max(1, Math.floor(cols / 25));\n for (let c = 0; c \u003C cols; c \u002B= colStep) {\n let path = \u0027\u0027;\n for (let r = 0; r \u003C rows; r\u002B\u002B) {\n const pt = grid[r][c];\n const p = project(pt[0], pt[1], pt[2]);\n path \u002B= (r === 0 ? \u0027M \u0027 : \u0027L \u0027) \u002B p[0].toFixed(1) \u002B \u0027 \u0027 \u002B p[1].toFixed(1) \u002B \u0027 \u0027;\n }\n svgContent \u002B= \u0027\u003Cpath d=\u0022\u0027 \u002B path \u002B \u0027\u0022 fill=\u0022none\u0022 stroke=\u0022#58a6ff\u0022 stroke-width=\u00220.8\u0022 opacity=\u00220.35\u0022 /\u003E\u0027;\n }\n\n svgMesh.innerHTML = svgContent;\n }\n\n render(azDeg, elDeg);\n\n window.fryRotate3D = function(id, az, el) {\n if (id !== \u0027fry_3d_968ce822\u0027) return;\n if (az !== null) azDeg = parseFloat(az);\n if (el !== null) elDeg = parseFloat(el);\n render(azDeg, elDeg);\n };\n\n // Drag to rotate interaction\n let isDragging = false;\n let startX = 0, startY = 0;\n\n canvas.addEventListener(\u0027mousedown\u0027, function(e) {\n isDragging = true;\n startX = e.clientX;\n startY = e.clientY;\n canvas.style.cursor = \u0027grabbing\u0027;\n });\n\n window.addEventListener(\u0027mousemove\u0027, function(e) {\n if (!isDragging) return;\n const dx = e.clientX - startX;\n const dy = e.clientY - startY;\n startX = e.clientX;\n startY = e.clientY;\n azDeg = (azDeg \u002B dx * 0.6 \u002B 360) % 360;\n elDeg = Math.max(15, Math.min(80, elDeg - dy * 0.4));\n const azSlider = root.querySelector(\u0027.fry-3d-az-slider\u0027);\n if (azSlider) azSlider.value = Math.round(azDeg);\n const elSlider = root.querySelector(\u0027.fry-3d-el-slider\u0027);\n if (elSlider) elSlider.value = Math.round(elDeg);\n render(azDeg, elDeg);\n });\n\n window.addEventListener(\u0027mouseup\u0027, function() {\n if (isDragging) {\n isDragging = false;\n canvas.style.cursor = \u0027grab\u0027;\n }\n });\n})();\n\u003C/script\u003E\u003C/div\u003E\u003C/div\u003E", + "HtmlContent": "
📊 Sombrero Wave Surface (z = sin(r) / r)
SURFACE
\n
\n
\n 🔄 Rotate\n \n
\n
\n 📐 Tilt\n \n
\n (Drag on canvas to rotate)\n
\n\n \n \n
", "TableSnapshot": null, "InspectorSnapshot": null, - "Visuals": [ - { - "MimeType": "application/vnd.fry.plot3d.v1\u002Bjson", - "Data": { - "kind": "surface", - "surface": { - "x": { - "min": -6, - "max": 6 - }, - "y": { - "min": -6, - "max": 6 - }, - "z": [ - [ - 0.09513663192183475, - 0.11596656479351104, - 0.12682462991954654, - 0.12737682780328718, - 0.11846831815380296, - 0.10186643610630686, - 0.0799124073170399, - 0.05515148446540677, - 0.030006056773055224, - 0.006539307734329662, - -0.013667579789292917, - -0.029528350954929623, - -0.040381550792932826, - -0.04587907383305402, - -0.04587907383305402, - -0.040381550792932826, - -0.029528350954929623, - -0.013667579789292917, - 0.006539307734329662, - 0.030006056773055224, - 0.05515148446540677, - 0.0799124073170398, - 0.10186643610630679, - 0.11846831815380293, - 0.12737682780328718, - 0.12682462991954654, - 0.11596656479351104, - 0.09513663192183475 - ], - [ - 0.11596656479351104, - 0.12727873569956913, - 0.12635519745351942, - 0.11386982985895412, - 0.09174384146839097, - 0.06274354564982379, - 0.030006056773055224, - -0.003418301489264026, - -0.03492980639601228, - -0.06259780962122471, - -0.08521600094933486, - -0.1022114315231641, - -0.11345326509316117, - -0.11902474787051427, - -0.11902474787051427, - -0.11345326509316131, - -0.1022114315231641, - -0.08521600094933486, - -0.06259780962122471, - -0.03492980639601228, - -0.003418301489264026, - 0.030006056773055095, - 0.06274354564982379, - 0.09174384146839097, - 0.11386982985895407, - 0.12635519745351942, - 0.12727873569956913, - 0.11596656479351104 - ], - [ - 0.12682462991954654, - 0.12635519745351942, - 0.11213120776405484, - 0.08604142186417321, - 0.051203995619134676, - 0.011401097914993538, - -0.029528350954929776, - -0.06822876507074507, - -0.10221143152316423, - -0.1300275977953364, - -0.15122123991517392, - -0.1660955742179871, - -0.17536272731412633, - -0.17976608439012923, - -0.17976608439012923, - -0.17536272731412633, - -0.1660955742179871, - -0.15122123991517392, - -0.1300275977953364, - -0.10221143152316423, - -0.06822876507074507, - -0.02952835095492992, - 0.011401097914993538, - 0.05120399561913456, - 0.08604142186417321, - 0.11213120776405484, - 0.12635519745351942, - 0.12682462991954654 - ], - [ - 0.12737682780328718, - 0.11386982985895412, - 0.08604142186417321, - 0.04715716246995318, - 0.0015985347206329088, - -0.045879073833053875, - -0.09088915480098324, - -0.13002759779533626, - -0.16125584316588004, - -0.1839992311665768, - -0.1989615614861598, - -0.20771065130836514, - -0.21213174340744032, - -0.2138682230867493, - -0.2138682230867493, - -0.21213174340744037, - -0.20771065130836514, - -0.1989615614861598, - -0.1839992311665768, - -0.16125584316588004, - -0.13002759779533626, - -0.09088915480098324, - -0.04587907383305402, - 0.0015985347206329088, - 0.04715716246995306, - 0.08604142186417321, - 0.11386982985895412, - 0.12737682780328718 - ], - [ - 0.11846831815380296, - 0.09174384146839097, - 0.051203995619134676, - 0.0015985347206329088, - -0.05141764082976182, - -0.1022114315231641, - -0.1460480754887461, - -0.17976608439012912, - -0.20213862295031954, - -0.2138682230867493, - -0.2172293925717256, - -0.21543782918861734, - -0.21187393452997094, - -0.20931329174357388, - -0.20931329174357388, - -0.21187393452997091, - -0.21543782918861734, - -0.2172293925717256, - -0.2138682230867493, - -0.20213862295031954, - -0.17976608439012912, - -0.1460480754887461, - -0.10221143152316423, - -0.05141764082976182, - 0.0015985347206329088, - 0.051203995619134676, - 0.09174384146839097, - 0.11846831815380296 - ], - [ - 0.10186643610630686, - 0.06274354564982379, - 0.011401097914993538, - -0.045879073833053875, - -0.1022114315231641, - -0.15122123991517378, - -0.18804908974216286, - -0.21007342776120977, - -0.2172293925717256, - -0.21187393452997091, - -0.1982275625850077, - -0.18149785600410848, - -0.16684582088550717, - -0.1583833179392697, - -0.1583833179392697, - -0.16684582088550717, - -0.18149785600410842, - -0.1982275625850077, - -0.21187393452997091, - -0.2172293925717256, - -0.21007342776120977, - -0.18804908974216297, - -0.15122123991517392, - -0.10221143152316423, - -0.04587907383305402, - 0.011401097914993538, - 0.06274354564982379, - 0.10186643610630686 - ], - [ - 0.0799124073170399, - 0.030006056773055224, - -0.029528350954929776, - -0.09088915480098324, - -0.1460480754887461, - -0.18804908974216286, - -0.21213174340744037, - -0.21648694256897097, - -0.20251503596944523, - -0.1745407635343178, - -0.13903280673798818, - -0.10346108234773095, - -0.07498767904568035, - -0.05921643708196729, - -0.05921643708196729, - -0.07498767904568035, - -0.10346108234773083, - -0.13903280673798818, - -0.1745407635343178, - -0.20251503596944523, - -0.21648694256897097, - -0.2121317434074404, - -0.18804908974216297, - -0.1460480754887461, - -0.09088915480098324, - -0.029528350954929776, - 0.030006056773055224, - 0.0799124073170399 - ], - [ - 0.05515148446540677, - -0.003418301489264026, - -0.06822876507074507, - -0.13002759779533626, - -0.17976608439012912, - -0.21007342776120977, - -0.21648694256897097, - -0.1982275625850077, - -0.1583833179392698, - -0.10346108234773095, - -0.042372535037570866, - 0.014984057583321607, - 0.059315391064978046, - 0.0834363447647132, - 0.0834363447647132, - 0.05931539106497819, - 0.014984057583321898, - -0.042372535037570866, - -0.10346108234773095, - -0.1583833179392698, - -0.1982275625850077, - -0.21648694256897097, - -0.21007342776120985, - -0.17976608439012923, - -0.1300275977953364, - -0.06822876507074507, - -0.003418301489264026, - 0.05515148446540677 - ], - [ - 0.030006056773055224, - -0.03492980639601228, - -0.10221143152316423, - -0.16125584316588004, - -0.20213862295031954, - -0.2172293925717256, - -0.20251503596944523, - -0.1583833179392698, - -0.08972371851374739, - -0.005312155821950362, - 0.08343634476471336, - 0.16415774887470502, - 0.22540737588752824, - 0.25840976490336276, - 0.25840976490336276, - 0.22540737588752824, - 0.16415774887470502, - 0.08343634476471336, - -0.005312155821950362, - -0.08972371851374739, - -0.1583833179392698, - -0.20251503596944517, - -0.2172293925717256, - -0.2021386229503196, - -0.16125584316588015, - -0.10221143152316423, - -0.03492980639601228, - 0.030006056773055224 - ], - [ - 0.006539307734329662, - -0.06259780962122471, - -0.1300275977953364, - -0.1839992311665768, - -0.2138682230867493, - -0.21187393452997091, - -0.1745407635343178, - -0.10346108234773095, - -0.005312155821950362, - 0.10891980905843199, - 0.22540737588752824, - 0.32942645897590556, - 0.4074823767322103, - 0.4492885365111389, - 0.4492885365111389, - 0.4074823767322105, - 0.3294264589759057, - 0.22540737588752824, - 0.10891980905843199, - -0.005312155821950362, - -0.10346108234773095, - -0.1745407635343176, - -0.21187393452997086, - -0.21386822308674935, - -0.1839992311665769, - -0.1300275977953364, - -0.06259780962122471, - 0.006539307734329662 - ], - [ - -0.013667579789292917, - -0.08521600094933486, - -0.15122123991517392, - -0.1989615614861598, - -0.2172293925717256, - -0.1982275625850077, - -0.13903280673798818, - -0.042372535037570866, - 0.08343634476471336, - 0.22540737588752824, - 0.3675471372972722, - 0.493023287997632, - 0.5865159597566532, - 0.6363960060850318, - 0.6363960060850319, - 0.5865159597566533, - 0.4930232879976322, - 0.3675471372972722, - 0.22540737588752824, - 0.08343634476471336, - -0.042372535037570866, - -0.13903280673798799, - -0.1982275625850076, - -0.2172293925717256, - -0.19896156148615987, - -0.15122123991517392, - -0.08521600094933486, - -0.013667579789292917 - ], - [ - -0.029528350954929623, - -0.1022114315231641, - -0.1660955742179871, - -0.20771065130836514, - -0.21543782918861734, - -0.18149785600410848, - -0.10346108234773095, - 0.014984057583321607, - 0.16415774887470502, - 0.32942645897590556, - 0.493023287997632, - 0.6363960060850314, - 0.7427393908561389, - 0.7993334098103074, - 0.7993334098103075, - 0.742739390856139, - 0.6363960060850318, - 0.493023287997632, - 0.32942645897590556, - 0.16415774887470502, - 0.014984057583321607, - -0.10346108234773063, - -0.18149785600410837, - -0.21543782918861734, - -0.2077106513083652, - -0.1660955742179871, - -0.1022114315231641, - -0.029528350954929623 - ], - [ - -0.040381550792932826, - -0.11345326509316117, - -0.17536272731412633, - -0.21213174340744032, - -0.21187393452997094, - -0.16684582088550717, - -0.07498767904568035, - 0.059315391064978046, - 0.22540737588752824, - 0.4074823767322103, - 0.5865159597566532, - 0.7427393908561389, - 0.8582985689787012, - 0.9197039526349604, - 0.9197039526349604, - 0.8582985689787013, - 0.7427393908561392, - 0.5865159597566532, - 0.4074823767322103, - 0.22540737588752824, - 0.059315391064978046, - -0.07498767904568, - -0.16684582088550703, - -0.21187393452997091, - -0.2121317434074404, - -0.17536272731412633, - -0.11345326509316117, - -0.040381550792932826 - ], - [ - -0.04587907383305402, - -0.11902474787051427, - -0.17976608439012923, - -0.2138682230867493, - -0.20931329174357388, - -0.1583833179392697, - -0.05921643708196729, - 0.0834363447647132, - 0.25840976490336276, - 0.4492885365111389, - 0.6363960060850318, - 0.7993334098103074, - 0.9197039526349604, - 0.9836201921792352, - 0.9836201921792354, - 0.9197039526349605, - 0.7993334098103078, - 0.6363960060850318, - 0.4492885365111389, - 0.25840976490336276, - 0.0834363447647132, - -0.05921643708196692, - -0.15838331793926957, - -0.20931329174357385, - -0.21386822308674935, - -0.17976608439012923, - -0.11902474787051427, - -0.04587907383305402 - ], - [ - -0.04587907383305402, - -0.11902474787051427, - -0.17976608439012923, - -0.2138682230867493, - -0.20931329174357388, - -0.1583833179392697, - -0.05921643708196729, - 0.0834363447647132, - 0.25840976490336276, - 0.4492885365111389, - 0.6363960060850319, - 0.7993334098103075, - 0.9197039526349604, - 0.9836201921792354, - 0.9836201921792354, - 0.9197039526349606, - 0.7993334098103079, - 0.6363960060850319, - 0.4492885365111389, - 0.25840976490336276, - 0.0834363447647132, - -0.05921643708196692, - -0.15838331793926957, - -0.20931329174357385, - -0.21386822308674935, - -0.17976608439012923, - -0.11902474787051427, - -0.04587907383305402 - ], - [ - -0.040381550792932826, - -0.11345326509316131, - -0.17536272731412633, - -0.21213174340744037, - -0.21187393452997091, - -0.16684582088550717, - -0.07498767904568035, - 0.05931539106497819, - 0.22540737588752824, - 0.4074823767322105, - 0.5865159597566533, - 0.742739390856139, - 0.8582985689787013, - 0.9197039526349605, - 0.9197039526349606, - 0.8582985689787015, - 0.7427393908561394, - 0.5865159597566533, - 0.4074823767322105, - 0.22540737588752824, - 0.05931539106497819, - -0.07498767904568, - -0.16684582088550695, - -0.21187393452997086, - -0.2121317434074404, - -0.17536272731412633, - -0.11345326509316131, - -0.040381550792932826 - ], - [ - -0.029528350954929623, - -0.1022114315231641, - -0.1660955742179871, - -0.20771065130836514, - -0.21543782918861734, - -0.18149785600410842, - -0.10346108234773083, - 0.014984057583321898, - 0.16415774887470502, - 0.3294264589759057, - 0.4930232879976322, - 0.6363960060850318, - 0.7427393908561392, - 0.7993334098103078, - 0.7993334098103079, - 0.7427393908561394, - 0.636396006085032, - 0.4930232879976322, - 0.3294264589759057, - 0.16415774887470502, - 0.014984057583321898, - -0.10346108234773052, - -0.1814978560041083, - -0.2154378291886173, - -0.2077106513083652, - -0.1660955742179871, - -0.1022114315231641, - -0.029528350954929623 - ], - [ - -0.013667579789292917, - -0.08521600094933486, - -0.15122123991517392, - -0.1989615614861598, - -0.2172293925717256, - -0.1982275625850077, - -0.13903280673798818, - -0.042372535037570866, - 0.08343634476471336, - 0.22540737588752824, - 0.3675471372972722, - 0.493023287997632, - 0.5865159597566532, - 0.6363960060850318, - 0.6363960060850319, - 0.5865159597566533, - 0.4930232879976322, - 0.3675471372972722, - 0.22540737588752824, - 0.08343634476471336, - -0.042372535037570866, - -0.13903280673798799, - -0.1982275625850076, - -0.2172293925717256, - -0.19896156148615987, - -0.15122123991517392, - -0.08521600094933486, - -0.013667579789292917 - ], - [ - 0.006539307734329662, - -0.06259780962122471, - -0.1300275977953364, - -0.1839992311665768, - -0.2138682230867493, - -0.21187393452997091, - -0.1745407635343178, - -0.10346108234773095, - -0.005312155821950362, - 0.10891980905843199, - 0.22540737588752824, - 0.32942645897590556, - 0.4074823767322103, - 0.4492885365111389, - 0.4492885365111389, - 0.4074823767322105, - 0.3294264589759057, - 0.22540737588752824, - 0.10891980905843199, - -0.005312155821950362, - -0.10346108234773095, - -0.1745407635343176, - -0.21187393452997086, - -0.21386822308674935, - -0.1839992311665769, - -0.1300275977953364, - -0.06259780962122471, - 0.006539307734329662 - ], - [ - 0.030006056773055224, - -0.03492980639601228, - -0.10221143152316423, - -0.16125584316588004, - -0.20213862295031954, - -0.2172293925717256, - -0.20251503596944523, - -0.1583833179392698, - -0.08972371851374739, - -0.005312155821950362, - 0.08343634476471336, - 0.16415774887470502, - 0.22540737588752824, - 0.25840976490336276, - 0.25840976490336276, - 0.22540737588752824, - 0.16415774887470502, - 0.08343634476471336, - -0.005312155821950362, - -0.08972371851374739, - -0.1583833179392698, - -0.20251503596944517, - -0.2172293925717256, - -0.2021386229503196, - -0.16125584316588015, - -0.10221143152316423, - -0.03492980639601228, - 0.030006056773055224 - ], - [ - 0.05515148446540677, - -0.003418301489264026, - -0.06822876507074507, - -0.13002759779533626, - -0.17976608439012912, - -0.21007342776120977, - -0.21648694256897097, - -0.1982275625850077, - -0.1583833179392698, - -0.10346108234773095, - -0.042372535037570866, - 0.014984057583321607, - 0.059315391064978046, - 0.0834363447647132, - 0.0834363447647132, - 0.05931539106497819, - 0.014984057583321898, - -0.042372535037570866, - -0.10346108234773095, - -0.1583833179392698, - -0.1982275625850077, - -0.21648694256897097, - -0.21007342776120985, - -0.17976608439012923, - -0.1300275977953364, - -0.06822876507074507, - -0.003418301489264026, - 0.05515148446540677 - ], - [ - 0.0799124073170398, - 0.030006056773055095, - -0.02952835095492992, - -0.09088915480098324, - -0.1460480754887461, - -0.18804908974216297, - -0.2121317434074404, - -0.21648694256897097, - -0.20251503596944517, - -0.1745407635343176, - -0.13903280673798799, - -0.10346108234773063, - -0.07498767904568, - -0.05921643708196692, - -0.05921643708196692, - -0.07498767904568, - -0.10346108234773052, - -0.13903280673798799, - -0.1745407635343176, - -0.20251503596944517, - -0.21648694256897097, - -0.21213174340744045, - -0.18804908974216306, - -0.14604807548874624, - -0.09088915480098353, - -0.02952835095492992, - 0.030006056773055095, - 0.0799124073170398 - ], - [ - 0.10186643610630679, - 0.06274354564982379, - 0.011401097914993538, - -0.04587907383305402, - -0.10221143152316423, - -0.15122123991517392, - -0.18804908974216297, - -0.21007342776120985, - -0.2172293925717256, - -0.21187393452997086, - -0.1982275625850076, - -0.18149785600410837, - -0.16684582088550703, - -0.15838331793926957, - -0.15838331793926957, - -0.16684582088550695, - -0.1814978560041083, - -0.1982275625850076, - -0.21187393452997086, - -0.2172293925717256, - -0.21007342776120985, - -0.18804908974216306, - -0.15122123991517405, - -0.10221143152316438, - -0.045879073833054174, - 0.011401097914993538, - 0.06274354564982379, - 0.10186643610630679 - ], - [ - 0.11846831815380293, - 0.09174384146839097, - 0.05120399561913456, - 0.0015985347206329088, - -0.05141764082976182, - -0.10221143152316423, - -0.1460480754887461, - -0.17976608439012923, - -0.2021386229503196, - -0.21386822308674935, - -0.2172293925717256, - -0.21543782918861734, - -0.21187393452997091, - -0.20931329174357385, - -0.20931329174357385, - -0.21187393452997086, - -0.2154378291886173, - -0.2172293925717256, - -0.21386822308674935, - -0.2021386229503196, - -0.17976608439012923, - -0.14604807548874624, - -0.10221143152316438, - -0.051417640829761975, - 0.0015985347206327678, - 0.05120399561913456, - 0.09174384146839097, - 0.11846831815380293 - ], - [ - 0.12737682780328718, - 0.11386982985895407, - 0.08604142186417321, - 0.04715716246995306, - 0.0015985347206329088, - -0.04587907383305402, - -0.09088915480098324, - -0.1300275977953364, - -0.16125584316588015, - -0.1839992311665769, - -0.19896156148615987, - -0.2077106513083652, - -0.2121317434074404, - -0.21386822308674935, - -0.21386822308674935, - -0.2121317434074404, - -0.2077106513083652, - -0.19896156148615987, - -0.1839992311665769, - -0.16125584316588015, - -0.1300275977953364, - -0.09088915480098353, - -0.045879073833054174, - 0.0015985347206327678, - 0.04715716246995294, - 0.08604142186417321, - 0.11386982985895407, - 0.12737682780328718 - ], - [ - 0.12682462991954654, - 0.12635519745351942, - 0.11213120776405484, - 0.08604142186417321, - 0.051203995619134676, - 0.011401097914993538, - -0.029528350954929776, - -0.06822876507074507, - -0.10221143152316423, - -0.1300275977953364, - -0.15122123991517392, - -0.1660955742179871, - -0.17536272731412633, - -0.17976608439012923, - -0.17976608439012923, - -0.17536272731412633, - -0.1660955742179871, - -0.15122123991517392, - -0.1300275977953364, - -0.10221143152316423, - -0.06822876507074507, - -0.02952835095492992, - 0.011401097914993538, - 0.05120399561913456, - 0.08604142186417321, - 0.11213120776405484, - 0.12635519745351942, - 0.12682462991954654 - ], - [ - 0.11596656479351104, - 0.12727873569956913, - 0.12635519745351942, - 0.11386982985895412, - 0.09174384146839097, - 0.06274354564982379, - 0.030006056773055224, - -0.003418301489264026, - -0.03492980639601228, - -0.06259780962122471, - -0.08521600094933486, - -0.1022114315231641, - -0.11345326509316117, - -0.11902474787051427, - -0.11902474787051427, - -0.11345326509316131, - -0.1022114315231641, - -0.08521600094933486, - -0.06259780962122471, - -0.03492980639601228, - -0.003418301489264026, - 0.030006056773055095, - 0.06274354564982379, - 0.09174384146839097, - 0.11386982985895407, - 0.12635519745351942, - 0.12727873569956913, - 0.11596656479351104 - ], - [ - 0.09513663192183475, - 0.11596656479351104, - 0.12682462991954654, - 0.12737682780328718, - 0.11846831815380296, - 0.10186643610630686, - 0.0799124073170399, - 0.05515148446540677, - 0.030006056773055224, - 0.006539307734329662, - -0.013667579789292917, - -0.029528350954929623, - -0.040381550792932826, - -0.04587907383305402, - -0.04587907383305402, - -0.040381550792932826, - -0.029528350954929623, - -0.013667579789292917, - 0.006539307734329662, - 0.030006056773055224, - 0.05515148446540677, - 0.0799124073170398, - 0.10186643610630679, - 0.11846831815380293, - 0.12737682780328718, - 0.12682462991954654, - 0.11596656479351104, - 0.09513663192183475 - ] - ] - }, - "title": "Sombrero Wave Surface (z = sin(r) / r)" - }, - "Title": "Sombrero Wave Surface (z = sin(r) / r)" - } - ], "HadInteractiveControl": false, "HasImage": false, "HasHtml": true, @@ -1820,7 +259,7 @@ "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -1841,22 +280,21 @@ { "Id": "cell-img-code", "Type": 0, - "Language": "csharp", - "Source": "using System.IO;\nusing System.IO.Compression;\n\nbyte[] GenerateMandelbrot(int w, int h)\n{\n var raw = new byte[h * (1 \u002B w * 4)];\n for (int y = 0; y \u003C h; y\u002B\u002B)\n {\n int rowOff = y * (1 \u002B w * 4);\n raw[rowOff] = 0;\n double cy = (y - h / 2.0) * 2.8 / h;\n for (int x = 0; x \u003C w; x\u002B\u002B)\n {\n double cx = (x - w * 0.72) * 2.8 / w;\n double zx = 0, zy = 0;\n int iter = 0;\n while (zx * zx \u002B zy * zy \u003C 4.0 \u0026\u0026 iter \u003C 64)\n {\n double tmp = zx * zx - zy * zy \u002B cx;\n zy = 2.0 * zx * zy \u002B cy;\n zx = tmp;\n iter\u002B\u002B;\n }\n\n int pxOff = rowOff \u002B 1 \u002B x * 4;\n if (iter == 64)\n {\n raw[pxOff] = 13; raw[pxOff \u002B 1] = 17; raw[pxOff \u002B 2] = 23; raw[pxOff \u002B 3] = 255;\n }\n else\n {\n double t = (double)iter / 64.0;\n raw[pxOff] = (byte)(Math.Sin(t * 6.28) * 110 \u002B 130);\n raw[pxOff \u002B 1] = (byte)(Math.Sin(t * 6.28 \u002B 2.0) * 110 \u002B 130);\n raw[pxOff \u002B 2] = (byte)(Math.Sin(t * 6.28 \u002B 4.0) * 110 \u002B 130);\n raw[pxOff \u002B 3] = 255;\n }\n }\n }\n\n using var ms = new MemoryStream();\n using var bw = new BinaryWriter(ms);\n bw.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });\n\n void Chunk(string name, byte[] data)\n {\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(data.Length));\n var tag = System.Text.Encoding.ASCII.GetBytes(name);\n bw.Write(tag);\n bw.Write(data);\n uint crc = 0xFFFFFFFF;\n foreach (var b in tag) crc = Crc32(crc, b);\n foreach (var b in data) crc = Crc32(crc, b);\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(~crc));\n }\n\n uint Crc32(uint c, byte b)\n {\n c ^= b;\n for (int k = 0; k \u003C 8; k\u002B\u002B) c = (c \u0026 1) != 0 ? (0xEDB88320 ^ (c \u003E\u003E 1)) : (c \u003E\u003E 1);\n return c;\n }\n\n using (var ihdr = new MemoryStream())\n using (var iw = new BinaryWriter(ihdr))\n {\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(w));\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(h));\n iw.Write((byte)8); iw.Write((byte)6); iw.Write((byte)0); iw.Write((byte)0); iw.Write((byte)0);\n Chunk(\u0022IHDR\u0022, ihdr.ToArray());\n }\n\n using (var idat = new MemoryStream())\n {\n using (var deflate = new ZLibStream(idat, CompressionLevel.Optimal, true))\n {\n deflate.Write(raw);\n }\n Chunk(\u0022IDAT\u0022, idat.ToArray());\n }\n\n Chunk(\u0022IEND\u0022, Array.Empty\u003Cbyte\u003E());\n return ms.ToArray();\n}\n\nbyte[] pngBytes = GenerateMandelbrot(540, 270);\nDisplay.Image(pngBytes, \u0022PNG\u0022);\nConsole.WriteLine($\u0022\u2713 Mandelbrot fractal synthesized ({pngBytes.Length:N0} bytes PNG).\u0022);", - "OutputText": "\u2713 Mandelbrot fractal synthesized (20,374 bytes PNG).\n", - "ExecutionCount": 5, + "Source": "using System.IO;\nusing System.IO.Compression;\n\nbyte[] GenerateMandelbrot(int w, int h)\n{\n var raw = new byte[h * (1 + w * 4)];\n for (int y = 0; y < h; y++)\n {\n int rowOff = y * (1 + w * 4);\n raw[rowOff] = 0;\n double cy = (y - h / 2.0) * 2.8 / h;\n for (int x = 0; x < w; x++)\n {\n double cx = (x - w * 0.72) * 2.8 / w;\n double zx = 0, zy = 0;\n int iter = 0;\n while (zx * zx + zy * zy < 4.0 && iter < 64)\n {\n double tmp = zx * zx - zy * zy + cx;\n zy = 2.0 * zx * zy + cy;\n zx = tmp;\n iter++;\n }\n\n int pxOff = rowOff + 1 + x * 4;\n if (iter == 64)\n {\n raw[pxOff] = 13; raw[pxOff + 1] = 17; raw[pxOff + 2] = 23; raw[pxOff + 3] = 255;\n }\n else\n {\n double t = (double)iter / 64.0;\n raw[pxOff] = (byte)(Math.Sin(t * 6.28) * 110 + 130);\n raw[pxOff + 1] = (byte)(Math.Sin(t * 6.28 + 2.0) * 110 + 130);\n raw[pxOff + 2] = (byte)(Math.Sin(t * 6.28 + 4.0) * 110 + 130);\n raw[pxOff + 3] = 255;\n }\n }\n }\n\n using var ms = new MemoryStream();\n using var bw = new BinaryWriter(ms);\n bw.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });\n\n void Chunk(string name, byte[] data)\n {\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(data.Length));\n var tag = System.Text.Encoding.ASCII.GetBytes(name);\n bw.Write(tag);\n bw.Write(data);\n uint crc = 0xFFFFFFFF;\n foreach (var b in tag) crc = Crc32(crc, b);\n foreach (var b in data) crc = Crc32(crc, b);\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(~crc));\n }\n\n uint Crc32(uint c, byte b)\n {\n c ^= b;\n for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? (0xEDB88320 ^ (c >> 1)) : (c >> 1);\n return c;\n }\n\n using (var ihdr = new MemoryStream())\n using (var iw = new BinaryWriter(ihdr))\n {\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(w));\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(h));\n iw.Write((byte)8); iw.Write((byte)6); iw.Write((byte)0); iw.Write((byte)0); iw.Write((byte)0);\n Chunk(\"IHDR\", ihdr.ToArray());\n }\n\n using (var idat = new MemoryStream())\n {\n using (var deflate = new ZLibStream(idat, CompressionLevel.Optimal, true))\n {\n deflate.Write(raw);\n }\n Chunk(\"IDAT\", idat.ToArray());\n }\n\n Chunk(\"IEND\", Array.Empty());\n return ms.ToArray();\n}\n\nbyte[] pngBytes = GenerateMandelbrot(540, 270);\nDisplay.Image(pngBytes, \"PNG\");\nConsole.WriteLine($\"✓ Mandelbrot fractal synthesized ({pngBytes.Length:N0} bytes PNG).\");", + "OutputText": "✓ Mandelbrot fractal synthesized (20,374 bytes PNG).\n", + "ExecutionCount": 13, "IsExecuting": false, - "ExecutionTimeText": "181 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, - "ImageBytes": "iVBORw0KGgoAAAANSUhEUgAAAhwAAAEOCAYAAADCCM5xAABPXUlEQVR4nO2dbctt13We/Qf6oaDITqvg1ghhaociEQSSQ4IOCQ5HODhwhI0DEi4YXAg4YOOAP6QQELQQsKGBQA0tLTK0EGJICSS0EBJaAqYl2DStiwnCrWiMIvn8hFOueda9Mp551vtee4/1cn8YPO9772fuuea41j3ePvDbP/zoI9v+1+B3f/ARm9fAe8B7wHvAe2Cze+AD2Y7SZtDIvghsXgPvAe8B7wEDh4HEioYPAjsD7wHvAe+BA+wBKxw7U1myN4zNa+A94D3gPeA9YODYABAYNHwh+jD2HvAe8B7wHrDCkQ4OVjR8EPkg8h7wHvAeOOMecEhlA1Dh8En+hWDzGngPeA94Dxg40h2/QcMHkQ8i7wHvAe8B7wErHOmAYEXDB9GRD6J/+z9+6tCWvb42r8FZ9oBDKgaN9E1ou84aZDvys5n3sa9l7wEDR7py4TwNH0SGh3wg2KLZQflsONMesMJh0EjfhGe2bIdn28caZO9Tm9dgjT1g4LCi4cPEMJHuUG2GEkPN8c9iA4fDJ+mbcK9mJ2lQ2NMeyL5ebF4DA4fzNHwQGCrSnaHNMGIgOf5ZbOBwQuijs5udnR2+94CVkexz6Axm4HD1yWnMTsVg4T3gEE32OXRmM3C4zPVwZqdisPAecJ5I9jlkM3C4p8bBLgQ7FsOF98D290D2OWHbxhpY4XDzrvRNaLjIdwg2r4EhJP+MO7oZONwpdJNmB2gH6D1wvj2Qfe7YDBybNG9Mw0X24WzzGhx9D/icPRYEWeEwbFi92MDBatvPGvzef/vJ3Vn2mhlC8p39FszAYVXj6pss+4CyeQ2OChKGEqsg2RBh4HAIJW1D2bnbuW95D2Q79T77w//y9KgZRqa9x9lO1WaFw7kahox0Z2c7L1hMAYpLzYqIAWTrsOOQihNDrWIYBnYLQ9kgMQcw/uQ/PTXJ9gogWPZ+mGLZTvfMZuBwFcroJsk+IGxegy3DxZCCAUD8+R889eiv/uUHH/3Fv3+q2J9/8Hcef/yDx3YL+MgMyWz9+s12wmcyA4fLXQ0ZGzj0bE+uQTZELIWMWtEAKgCM//mvf6KAx//553+/fB6hQyYo4edYBJK9g8ceACTbIR/dDBzurVE2QvaFbjv3GmQ7wTVBow6f1EABdPzNl5999PD1Fx799W9+uHwUYOhnfF/AUSshR1A9DCD5zt/A4UZeN90E2U7Gdt41yHZy1wKNvlyNPhVDisf7X/xYsYcPXr6jgIyFXo4EHlj2vhyybGd9BDu9wpH9Btzasi9a2znXINuR3QI0+mCjho46bAJ0SOkANvi6CzjG8j3WgJDstTZ85PsIA4fbkxswNuA0beeBjLmgMQU4hqAD0AA4CKcIOC6BjktBJHvtDR/HtNMqHNkLf02zYzQcWMW4LWhcAhxR4QA4pHLIuhJMl0LHXAjJBg3DR74/MXAYNgwZhopNgGW281nT1siFmOLk+/I4SiLp/XuPcznu3ytf8/n//Uf/tE0ivRZ4jAFI9ntj+DiGnUrhyF7stS3b2djOuQbZTmaLoDEVNmqVo87jAC7+zt/9YGt/89Jr5ftdYZausto+COn6vbpyZqwSJvt9Mnzk+xwDxwlhI9vh2M61BtmOZA+wcQlwlPyNBy8/tvv3Hv3F3/sXj372Vx6WBmF/9exXHv31828U6OD3ZBE6huBjDDT0GgQ1U0pws9+zPYMHlu0/DBxWNgwZGzgIbOeAjLVBY244pXb4OPsIFHxOKIWQSgmrvPJqgRDgI+Z41GrHFAiJP5OywuN3hWyGQi3Z798RwOOM8HH4kEr2Al9i2ReD7VxrkO0gzgQbfeWxsggfgg7lckTgWGrxeYCb33vqP5bwjZ6zKzSzR7VjL/CR7WsMHCeGjezNbzvXGmQ7gz2DxhrAEaEjAoGgoySTPnj50b976j+Xj4RagIQ+8IjgUqpf7t9rVRRVwkhFUbfTOkdkbqOx7Pf2KOCBZfufa9phFY7shTVk5F+4NkPGFmBjLIdiDDraEMtLrz36J//4/5XQCtAhWIi5HV3qCMABrMSEVPJCeEw+zulsegTo2At4HBE+Dgkc2Ys6x7I3tO08a5B9yB8NNKYAx5SkzSnQ0bY+f/2FYs98760SZinQ0KgXKqMtuR4vvVZ+/rtP/9c7qkZROR68/Pj3Bvp8XDLFNvs9N3jk+7VTAEf2Yhoy8p2qzZBxK9BYGzZq4OjK55Da8Sc/8W8el8w+/0Zb4fLLP/9+CxsljNKoGAq/xIZiQ51MLwUOg8d1z6Fs/2Xg2Als2BkaCKxmHB825pSkzgGOO0mkUjvu3yuhEtSMAh6NmiFFo6trabShctqlTcIMHrc557L92WkVjuxFNGgYJLYAk9ny9FlA41qwMVax0gUc5GZ84r0v3VEzImwMAceU/h1rQYdDLQaPQwBHNkxYzch3tGe2bCe/FTsqbHSWyH7xY48+/s6bJXRCCIXwSlE47t8rKsdc4LgEOpasX/ZemWPZ1/ccy/Z5h1c4shevy7I3ne0ca5B9EG/FjgAaQ8qG8jbaipPXX3j01Le/8+h/ffifFdj46S/8+NEv/tLD0hgM6wKOodyNLOjYE3hkX+tLLNsPHg44shfOoJF/UZ3Nsg/es4LGJbAxBTL6em/81k/9ZTvM7U6VClUmr7xakkQJp/B7JVG0I4cjdjEdA46l+RxrrG/2fjJ4GDh2ARvZTsh2/DXIPmi3ZHsFjbGOn6W7Z9PQS6WvQEQpa21CJUDGZx5++tGvffSHReH47Z/870XtUI+Nzz/85OPfff2F9nH+8Kn/UL7HYw8NgZsCHteGDoPH9c6QbD+5W4Uje8EMGvkO+AyW7di3ZlsBjT7YmAIXQzka6gIKVAAJJIECEc+9/fUCC6gYytfg5z/zqw/vTJUFTIAQlcSqLJbv1WGWqdCxZm+Oo8FH9vmw1LL95q6AI3uxsOwNYzv2GmQfpFuzrYJGn4KhJl1SFOqkzanVJ5ocCzTwWEDFh77x9qOn/vhP74CG7Olvfr8oHsAIoRY+x4AWJs/ykXkpU5NIh+DjVsBh8DgeeOwGOAwa+c7QZsg4ImiMlX5G2Bjrm6FR8+qNUapHmhbkdSJnDRtd/TVQNFA3uiCjzz718HNP9Oaoy2Vr+BlTZFBO+HxM7bj2e5UNwF2253PRwLEx2MjeELbjrkH2QblF2xJkdIHG2GTXdijag5eL2qAcCgFFTOYUYKhzqDqGKl9DIRFyNGhlPgU2PvSd3y9hGGauqAtpX3+OKaZQD8bXU9SOW72H2XvV4HFAhcOgke8YbQaNM0FGV/hEkCGYiNUgfE1JKqrCP/zajwo8EL4g54KPMZHzTsVJo4R89sV3y+/xOUrC03/2R23ZK48LtBAymQIcPD+AAmy0+RzN69Xr6FJbIiwBO5pMy2MAMfyf/P9TQyy3ek+3Bh97PytPHVKxopG/AW3rrEH2QbhF2xpk9CWF4lxjcicAAAwoiVOJmVGd4HeUxNk25ZLpd1E+XnqtQAWQATAAHgqh1ImhVKj0gcZT3/puUTVIHqUvB3kfPJbAg9fK6+HrZ958547iIqVFIRyqYPi8TjYd6lB667yOrcPH3s/N0wGHQyf5m85m0NgbaCwBjCnVJzGnoeRpNGGSrvAIP0O1QBkAAlS2igECdAnF+fM1vwMkzMnRmGM892985AcFQvi8VT0qxYWvVQ1T2qQ3+R9d1S1zK1gyoGML8HGE8/MUwOEcjfyNZrtsDbLvsLZmWwSMIdCYmiDaWWUSlAucPg6fkAjKgipK6J8BaPzCj79wNdiojaoVlA6AQiEg1AwpL8obUVku/wN/Uz4PcDU09O2WZbN7gI8jnKMGDieEpm9CmyEjAzLWAIwpsNHXJXRsfDzAAUjgzAlzTM29uJWhdPD6CLcQxlH/DqDoZ370tVJ22yohTbIoIMLPSxLq6y9cNNZ+C+CRAR9HOC8PqXC48iR/Y9nmrUG2erAl2zJcTAWNsbbkQ8ARS1tx0MqvyAaNMaNLKR+BD5XyRlWjr2HY0vkrWwGPW8LHUc7VwwDHtWEj+42yHWsNsp37lmzrgDEHNKbOQKk7hbZVK6FxV5zoSviCHhnZcNFlqBq8ToWCunp2zB1tPwU6tgQet4CP7DNrLds9cBg28jeRzZBxNMCYCxpzgUOD1kgOBSpw3uRAoBLIeZMHkQ0UXUYVDF1Ied2EgEq1zUCTsL6OpGtAx5ng40jn7C6Bw6pG/saxDa9BtoKwBdsTYCyBjCWwIeBAzQA6UDEYGa8yVJQD8iT6WpJnGhUyfCSXo+RnxLLdZkrt1PkrU9Z1yXuYDR0Gj3XBIx04HD6xs98y7GQ7+WzbE2BcAhlzYEPAUfIbnn+jGJCh8Ak5G4ROlIiZDRZjSaTqK6LqFTUho4Km/H8Tp8xOXeNL3+MjwUf2+bambR44DBv5m8RmyFgLMjIAYw3IGAONoeqUtpV5U9GBOoCigdLx3K+/lw4UU42wzz/43/+qHfpGTw5ABPXm0gmz14CObBAxdCwDj0MBh52nAcJqhlWMa6kaQ9UpdypUmm6jqATqxbF1YxItHwkLta3QmzboXUPexqAjEzxuCSZWOuZBRxpwGDYMB1sBxOywxZ7CJNdWMtZQK64BGUND2+qSWMITn3jvS8WBE55gvkk2UAyZmoCpHHZosuyccfZbB481wcThlWngkQIcVjXynezZLdvp7wkwhiDjVoCQCRk1aPQBhyarEpogf4OEURSDbKAYM7VWJ4yiQW1xuNvYKPup4HGNqpatgcil12r2uXgNSwUOw0b+BjizZTv/vQBGH2is4eTj5NW5Mv2Q45rzt2OAMQQaNWx0hlWafA4SSSk/JS9iS5UqtFaPX6NukOzaN8Z+DDqWAsjW4KPeT109R6bAh6GjGzx2CxzZjsu2rzXIhoC9AEafmtHl0OWQpzqZrgoP5QrIsS0Bj7XgYggwxpSN2PgrDkQDOEgeveaAtkvVDZSNGEqpO4wuBY+xde4ClFsCyBgox73K+8j/3wceffBhpePuOXxT4DBo5DveM1k2COwFMMZAAyuKRBPjL9NSGwdF/L8umxwLQ6iyg0Ocao52fPvrLyxKTuwDixZsGhionWfXnXyXU+1TM7pgowaOtqfFK69uIoGUnht8RHFpJ8eS5Nq85vh/xP+vXoM58NH1/vOc5Lrw/Jc0Fbtm2E2vidcMmLEm8f/Q6zN0bBA4DBv5Dvgslg0Ee4CLOfkZ8QCWo+au+Jd//v07Mzdqp4xTKV03mwZSMeSgMejPfO+tx105AxCscfcspyAo4vl4PeVuHgh48HI7Lr5rbkifejFkd0BDsBGAAwfP/0xDsAzFg54gavLVwoYafWlQ2wh4aC2k3tRrNqYKtX+v96KphKnf90sSUNfK8dFeqiE5QlKf6rKW2pF9lu4SOAwb+W/00S0bDPYEGEOQ0RfLltWqAY4U50mSpMaby2nxNVBClYacL3e1VG4AIjTH4m9jR8sux7UkHFI7N+CC56TRlcpAcXh8n/beAqMuiMDBkADK7/ERgJEiI7Dgf+J7pT24HHkDGoINjOdTF1L1ugCGrgUZNCTjtfFcPCfPL+DQa2sVJgFSU9ZbRtcH9UMw9dkX3y2PR2Mw3j8ejzyVLjVE7wHr9pmHn27zRXh+HoO1KDDYrBf7RaG1KQrXNUDjCdhoQn+snwCMjwIOQ8eBgCPbkdm2vQbZgLAHuJgDGn2x7a48jDa+3Th1nAjOA0elceY4Gjk6vg98tA67ubOemzcwJceiKCzciTYDyXBkODsqSPj43NtfL86SOSL8TnG64U4f0wRVoEB/ExM/pVjgSJ958532+/y//K/6WjNW9FixcoXXwJqxXjhyHDcwpNc5x3gdT3/z+wUyeCweU4284v/B69HrxekDJHoM/ob3Sq+R8l5+p8BHA1DtWnzru+XnPJdAJiad3oE21vT+vfJcGP8fiavkt1AZw2sqINTRaGzNSpg5eUZS86RqCIyjCjOWY9J3HZ5V6bg6cBg28t/ko1k2KOwFLuaCxhLgeGIse2h6haNCUdBgM7XPvnNHHWT8Lit3xB0KyJBJ/ub5eA04cV4Dn/N4hHHaMENTQcLPi9qi11ZNehWwROPx9JjRcMB1KOPW4ZMleR1SRLoUFyCRtWjnr3zz++3PpPqgAN1Rfhr1p+T5PP9G+V0eg99nzbUuCi/xXrTgV+V2XAIfcxOJBdLsYUzrIaWuDvsYOjYCHIaNfOd8JMuGhr3AxVLQGMrc7600CTH9eEBzp8wdtfInopPmdwQgpbNllPObsATGHTQf+d2+cs2+fIuYtIkTUyiHO3VJ+7XxM70+3d0r0fOr776YDgbZBhDUMBXfV82VieEkdTAFZACSWrkRuOhveZ+KKhWUjkvyeS4Jx8W9zf/DXmCPRAWmT1m5Vt+O7DP4kMCRvSi27axBNjzsCS4uAY0pZYIxtl2cf1AruEtFpsdZK19BIQccPQoBd9LI6IQq+H2+B5jwuf5OTgdIkTPi7/kbnBWKSV8yY4SNCBxzgIHn4g4cB5nt4PdghJiUFwNQlO99+zsFFHkf9fXQY7DevL8KW8xtPFbDAu+dwnZxXxQo6uieOlTurHChKmp4ffF1db2W8lyGjtsCh2Ej31nv2bJBYk9wcSlozAEODlQUByU9Sj1AzagTD3VXKJVCcnyf00FRAEC4i0TdkAP7+Dtv/i3cVEmMdxIam+dXPkGsDlHoo0/dqEMLtsvXAGjsU0RqAyZLe/WBXiBTeqMIEnhu9pSScnnsUlV1/14B4xLe68kZqmGDv2Mfo8bVOSr8XI+hvKGuktmx6/YsSsdVgMOqRv4bu0fLBoq9wcWtYKNL4Yghld7GVxzGzej2SxyX2oRz8PPxiTyBpkQzlpoKLIAd/l5QA8x0Sfu23DWIPUGU18NHOfkpOTxPhEGacBqPQWhHVUbluQK4DuURxTwU9pfCRao2kvKhx9DrnAscZ4GOTQBH9iLYzg0Z2aCQCRpz1I2upNGuu8uYQMphrFwO5QFc4ph4rLaqJOQKKCmxvqMGdpRHYNvWGgB/hM1Qv6hWAQiAAfaKEntVKYTFfi59oTTBZwmnPP9Gq1oBHdo7/Jx9EZODARHCboTUFI5RiA9TWTVKmYClHnDXlUg6BzjOAB2rA4dhI/9N3boZLrYDGnNgo6+bZ4QODuEykyP0nuDARmUg1n9J2ILHlWohR8DnEWSkZtj2twZAgUp0BRxSogQBda8Uga1yiH7t/Z+7U0FUAy7PoZJfnov9o99BQeNn6rnCY/L8hPRKD46md4kqdgTUU4DD0LEB4Mh2fLZjQ0a28rBlRWMJbPSVEz7RaKupNIklkzG58FLDkfCY3HEaMI5vKquNvVLqjq7KJVpbgSH/o35eQQ4/Zw9GpWOoGdica/yoSseqwGHYyH9Dt2SGi+2CxhLYmAIcqlhRKaxCHHSlXMMJAC1qZOU8jOMbuTj1+9w2jmtM5apKMl7LeLwCy9/+zmN1IySMqteLVA72/JLhbgYOw0a6o96zWbnYtqIxBhtzmyXFkEpM1kPhoBullA7yKIYqRKZYbNhlO+caCDalemBdzdcugQxgWaXa5Jho1k9XxUzdkGxuq/OzQcdqCofDKPlv5lEhIzuEcSTQuAZsdDZLCuWxShilU6PyMGxeg0v2gHI1rjGBlzCJSmgVRhkaLtjVcbTv2js7dKwCHIaN/DfyKJCR7eSPChlDsDG3DfTUhkkx3k0yniRqlI6xZlA2r8Fc1WMNExCXniDK3WhyROpcjbmwcQvg2Dp03Aw4sv9R2/YAI9u5nx005qgaYw2X+kpj6xbjcUS7BoepQZS7exoyLgWFtUyt9/mchnSaywPczJ2lYuhYETgMG8eFGQPG/kFjrqoxBhhdkNHbh6MBDSXWqZJABzk5HoYMQ8ZW1Cy10kd909Rj9e4ANNjXY7Bh4LiywmFlIx8MDBj5CsXWIGMKaAwlgMZx8+o/0Nd0qc7b4O6QpDsl9qmZkw52QitrVxTYvAaX7AHa7gMXKG8llNKU3PIz9nQ9O+US4DhzaOUi4DBs5L+BVjDyAWJLkDElfDKW+Ak8ICMjIZc+A6EVdGy6VKsZ3AlqSBrqBQ2VABY+d88MA0EGFLL3hn5Ocqj2uaYcd3UR7YKNPuDYisqxRegwcGzgTTBg5APBUSGjBo22X0bTAjoOoiJRjux/7vA4iOncyAEs4NBd3xOzJprQCb9HLgbVKH1ytc1rkLUHyBcCLsrgtkZ5q+e3TC1/vRQ45kLHJeHtQwCH1Y1tW2aS59p0vyfbCmR0hU80cA2gAC7In+Aj4Q/Fq2NOhRLndDjzMSbnEevWTBMlhPJ4/J3BwmCx5T3QThSuYGMt4NgSdBweOLL/qbPZNeBiDDJu4VCz4WFLcDEVMsZmoNRNugAF4tZx0ioqBcoGd4R8TnybEAtfU94KdKB+KKFOw61oXy4Q8aj3fKdqG18DgTQdbIvqFzqJjvXdmAIda5bJHkHlWAQcho1jwsUUwLiE5vcMJBn/3xzAGKo+qXM2OEyJVyMvo2wAC+rWCHh0hUWGAIJ5Kfw9MOKqEzv6vYAOYcP4NZUotdqBsbf7kkbXUDtuARxbgY5VgSP7nzmaXRsupoRKouPjouJirIcUbQE+9m5zwWJJqWvnoLXXXyh3eKgXVI6gWLSj5H/0tcmHN+PFsx2IzWuwdA8QBiwhljCfBWUPBYSf69wbg46l4GHgsLpxOLCYAhhdd/TRoXGhxXr0IYkx24kfFSymgsZQVUrsncGBisoRe2VwV+ewiB34WSCOAXFAh5KbVbXC54AHSiDQMRZemQogGcCxBZVjtsJhdWMfULEEMvpCB/FCUaWD7gS4MOOFKCdWT048KoBcAyDWAo2xXhtdrcjVe4Dprgxcy3YENq9B1h5A4VMuE2ed5ql0JZJOgY+xAW9nUDlWAY7sf2JLlg0WSyBjLE+hdnK18yrOqpk7wMexWQO228DGWM+NGjjaaa/375W7OknNdvp2+mfcA5SIq+kdMN6VSNoFHn2DDks5etPPRmfiXOjYu8oxCzgMG9sEi0sgY0pCZJ+jizkBJdGKjO/QLGdJjPMWakB53c9+ZXHW+dZVjSWwUc89UXiFuDZrpe6h2U7A5jW4xR5A1WDftzdSoUdHXxXL2DgA3Zjpcbqg4xbAkQkdBo4dKRZrQsbc6os+x82FQ/wfo1RSjaDqmQO3Bo8hB8xr5jAZy0XZCoDMgYwpeRvkZ0jJKJ0VmzHcETYADX6mw9eO3o7+rHuAMu+uhnfcYAHh+jpCSD1fiOotzhxK0HVexsT7qdBxGuA4urqRDQ23hIylwNGnbuCcyOpWHfvUbO61nfqYA67vNnRolLv5L35s0mvdM2jEmScRIiQbR1WD+DWScvZhb/MaZO+B9ox45dUSaqQiS/07VNFV+ng0eR7RuNaAC34HlTBW+fXlc9wCOLKgYzFwZAPCmeDiGqAxBzYicNTO7M7gLu6Mq/kDS5Krpjr2qQ44JrNyh6GGV9xxKPm1hqRbgtIlgDEEGwKr+H9yQJJ9z5wT+g6gdujQJEP/8w8/Wb7m+/TjQLXKPvBtXoOsPcB1wpmB8TXVKlI99DtcN1wzbat0qYWNYkgFDD1qSndT1NVwLnZBh4Fjx8CRDQhbg4wlsCHg4CIpyVPUq0tibGKc0Hvs1NcV25ySXDXH2Y853Ro29NrjMLIYjx17nXNexzXBYuj/7fqfOQQ5MHVYfvydNwtIcJjydd3ki46h+l2b1+Dse0DlsUPGNUR4BeUQFYRwpNRCGuPpzNH8IowzU2GVqcmja/uWTSoce4KNbDjYOmgsVTf6FA7dRatTZbmwQmilL6mqz1HGx+XOvEt5mGr1ayzDyT76w/YupGtY0xTwWBOQ1gCMscRQVAsOvnh40mWR0tfsw9zmNdj6HpjTAE9GE73yt7/6sCiFwEV9NkaFw8CxE+DIhoI9QcYlsNGXwxHvprmYSs16pR7UDr1VGzqSrPTzEgYIiVhzS9C6Rq7zugAO5SwASeX1atrphDkKQ8+1pkKyFK768jaK0tGULROD5n9H6nWvjXyHZjvGGsTBhqgaUlP5vnKkpHCoiq8rh+OWCsetVY7ZCocBY7+gsTSUMhQKwNkV2GgSL3HeyPdccAIPVUTQJEzSoiY1RpVBUqPCADxGF5jUzr2vDK0u/4yvpdTWN3kcvC7u+OmsKfjg9Y09dw01yg9ZUqe/1IbK8Poae+n/J9GNwxEpmCx652vkOy3bfteA66ltj94oqHxE4YjnQl/uhoFjI7CRrTrsWc1YS90Yyj2IeQN3JpHqrjr0d0DSBzqIc6o8rNC/kqwaZ8j3SbRS052oPigRsk8h6StNu+N0m9cj6OBunyRKDTJDEo3P3fWYUnX4G6AF+fSr7774GFhCctg1wGOs5r+GDXUQxcpdVjNKfkp82uY18B7oHmAoaMcACxRT4J1rK96kRJWzPlezgeOWKsdmgSMbBo4GGpfCxpjKMZRD0OXwcebqYom6wF02CgHOH2VDWd111rdgZCxs0/XcTwBHI3FqYBnPC2gwip3XReJXTIZ94nGaZNkWmirFpk8VuRZoDIVSNBtFVSmstfJubF4D74Fpe4CQrCpSFCaJ51Nf+/O+ZPK+8/f0wGHI2C9ozAWOuRUWY4mLde6GVAQs1rPjvFEZVEWBU+SuoYQ3mhAInwMnutBrEIhdM7uMuw8dFrrL53m401e1BsmUn3r4ufKagBF+FmXSqNjwc8CE/4WPquDpSka9JIl2isV15jUDGMSPBVCqSonmcfKGDcPGvD3w3NtfL2dFaQUQrvWYb9aXu7VV4LiVyvGBbHUjW204sqIxFzaW9ooYcpZ90FG30ZbzJzShDG8+kiGOTEndOwAAjGigUkksbS76GCYBSErcFEjQz/nYhFCACTW+khPme/FQ4XlQXgCfthunHk/WhH/02nl9JGQKVGIjoKjELGmNPAYZXcoG/4PW0uY18B5YZw8A81zffM71X25iBga7GThmAodBY7+gMRU4LmlQNSb/DyYwNgmaOGnyH3DyfRc6F7bu1gl/xIu+BYKmLbca9ShHQcpGVDWAm6mZ5+oWKDlVygyvIT6e+lcAKqX7YAM8giz+jnwRhYX6wkFzLYJMvb5ShrQmNq+B98B6e6D02VgAHHPVjWsrHLdQOW4KHNlKw5kUjWvBxlLg6MrlAAYUXpHDjqVlQ6aKCpWfCVr4GJMhNfUU4/EJ2dDYCoVD1RlDz4O6QYtvfh+AULtvHovXzfN3PQa/q5CRQIjnJmzUDoWq5Ngl4NGlnNS5MqyV1tgD2Awbho119gDXFGXlQw0Pa/AwcNwAOLIB4KygMQYba7TgHktwrKtWamVDA8JoD/zM996afdHjQAENTGESDgGce91jQurIGsbr5eOQIkO4plTTNECksAvQQQhHiaYx/2QKeHRBxhOw0XQ1jLkpAiE7HEOH98Dle4DrO4Z0+/K2ynVJcvpI741TKxxrwEY2AJwZNLYAG1gsy1TpaAxDKHmUqpS5F7zyOQQtGF01t3CYkjsRX5eajMWwC1YSUus8lA7lY8zq6hmXu+bvAdux14DzC5hXlZuSy7uS2Iv6+psfLmfjknLYWwHHtaHjKsCRDQBnB40h2FgKGlOBY6xEUw4xJotywaJIkNsw54LfClyMGYmvXTX8qCMcWqgf/C8AicJCbc5HRxVOn9UKktYW2HFjr/x9YDv2GqC2tonsjYLL97kOSwXbb374Ti6HgaMCDoPG/kDjFrAxVro5p/GWEj81ofRMZZocQsAI/zdhFvJByGHRzIU472UMMmrYIFxD2Ac1hedxtUr++207/hqQH8Z1zI0ECmwpwW/6BS1tZ34ahcOqxr5Aow821lY1ppZo1rBRV0/UZaUKsXDBFmfc0TfiiAYMUKGDwqM8E1XCxOZCQ/1F6tyY7P/J5jU44x4gNAxo8Dkl8qUarUneHmtpvhXguCZ0fODScEp2WOPsoZNbggamdt5q4DWYaxBCA9EhAhIqK6XqA9BQ11FMlSBnNUIr6j2C4qPk0jrP4wkLvUZY2888/HQJVdGoKPt/snkNjrYHVGaupGyqwdTVl5uE2J9jDdg4NHAYNs4FGlOmlsZEUBwczoxQSJf0r9bhXITxTl2KBhcrDnXt6pGjGDkdqoRhrdo1VP5LAIy6GRkVMMCG1zX/fbQddw1U8l6PM5jS1tzAMRM4slWHM6saU3I05oxCn9I9tA6dKDyiduQCCb6G8FE/SIpUWAQwoUID56nQiXtCjB9qrCHry91S278jNDmLFtu/q9X63ERcm9fAe2AmePTAxtSGX3PO/lv6wE0oHNkgcEbQmFLeeilgTC1z7WrkVecPEA4gD0N32Di92NCLz5eUwdoeqx0Cjy4D+qQc2bwG3gPX2QNxsGTfLJWp81MMHD3AkQ0DZwCNuTNPBApjUwrnwMWUUteuhFAlRxEOADgAD+7Qz56TsaZptgtgp8TS2GpdLczVidXmNfAeuM4eUOVXHF/A53x/6ij6uf7hkArHmWBjazAxtaS1q3Kk1HmTU9HE8lWKdclY9Knlrm3ehp67aTWuPATbOmuAakT2O+EpNVCLnUzV0+RM5cU2r0HWHkCpVYUdXxNSVoOvKa3MTw8cXepGNhRsFTQuAYglkDEUPomhDjY/zl8yX18Z61LQ6GroFSslYqmrczWuc9AJKBRikQF5dsB2wN4D19kD5J3Ra4PPAXt1E6YUlgnVqLprzU3JVjiwmwPH2UHj2lCxpMKkDzjaNuIPXi7OHtoWeERQmDvyvGuOhxQNSsJ4LiUzAhrcfWvc+5IOorZpa0DYhOF0miWjOS1eP+8h74Hr7YGYuxFnIMXcjWvAhoHjQKBxa7CYAxhjsFErEgUG7t8rxK2ppGNDwSJIQOlFpah6bHS20Q4tyrnbHhpmZlt/DX7hx18o7xeggWmAnc1r4D2w7nXG+abzVN1Dx5JEh85+A0eVv3FkyMgCjCWQMQYbQ+Pg1S8j9sqIdeNdw7+4U44X1uDMjpC3odkdOD46Z/rAu43To/SYJF0Myddrb9jwtXf9/huou7Gp17Vh45AKxxFgY2uAcQlkTIWNGjjqHAvCHtz9qg9GbJX9RB5Go45IOozfv9N4qoENHpuwCbDh3ho5zo6ZKUyrjJNobV4D74HL90B9ppG3UfrdhOTQKRUpBo6VgSOL1LYIGGtARh9sjCV6xlBJHFleWol/9Id3ci74WCYcNs2joHc5ME0yjU299HVsNqUEUY1o9iGX4+gIZ/3iL51j/ozNa3CLPcBNmoYpTg2jjMGGFY4KONYEi1tYNlhcCzS6yl/HKku6qkikTggaKKuknAtS54JCzQAWVPFAZQlhGF10JIHyNX/D76n6RCWZSlB1CWyuE0Di1VwHm9fAe2CdPaAbL86/sZblU2BjT8CxdlilU+G4FWRc4syjA+5608dsy5AxFzRq2OjKyxB0oEjEctXYARRT0ueHvvH2ne/XlSZcfDxGHC3vcEq+k3v6z/4o/TXYvAZ73gOUvaLUcl5yzpUk+i9+bLRl+bXVjSPkcTwBHNcAjbUded17Yog2rwUic/Mt5rYYHytdrctYlVNRJ3m2U12b4V9K7KSk8tJ4Px1FARQuThIWnT+Qf1gytC37Ndi8BnvfAwoNx/DJGrBh4OgBjuwwx5hD1waAQusNsbbasCZMTEn6RJ3Q6Pe6b8ZQAy7yNJDU66TOWLaqkeUoFmveDVMp4fyB/IPS5jXwHli+B3Tuxr4aXSMiLrlhtcJRAceW1Iwuq51z3U2zb0NcAgRKwOzq0LkUMPo6e6qcVe3J+3po9IVOgAvqxqVsIAkqN4N8DJSIa9wJqwOfzWvgPeA9sIc9wE0XuWecjVPnUV2qjhs4AnBcAhpLQxVjQBB/xh27NkVbhaFJmY3TjQDS5+DngEEbsmgAoJbZLrG+oWjl/3j+jSc62sU+GDF80tUPgzAHMUg+cnExVMhTWvMPOZvXwHtgIxNfm/O17sI85CsMHCvmcExRHkRp+tnQGzGUozDk/IfCDXFmB3fscROphKkORfSpCbFR1lD4ojh38iC++LHeNuF6nFK9MfJ4BYqe/Uor37XTVkMHT1WAFPAIYRIBVvu9JhmUtdAgIcACJeOZ772VfmHbvAbeA94D2XuAM1H+IbYknwMbBo6Vk0azN4XNa+A94D3gPeA9cMkeoPQfZTf2Eopj5Ptgw8Bx4yoVKxxWOHzY+7D3HvAeONoesMKxwbJY53A4hyP7YLB5DbwHvAeuvQecw7EB4HCViqtUll7ArlKxkzAoeA/saQ+4SmUDwOE+HO7DMffCdR+O/MPT5jXwHrhc9XAfDncadadRdxr1YTrhwHSnUe8TQ8fle8CdRj1L5YmeHZ6l4lkqPlyflIi9JoYO74Hle8CzVDYwvM3TYnMGuXlarJ3H1IPS02K9Vwwa6+8BT4u9MnBMgY5LQWTNaXbXmOWyhl1rfsucrqVty/OmkRjNwIhTlrr0pmEYDcj4XF1Kucj4vVjDrtH2bU178/v6GY/J1FhJkbbbrwGD9DzPxnvP1956e4CxEGoWpi7WsdP00hH1Z5wUuzpwbMW2CCDXGBo3BhzqYKqJsVw8QAQWu5reGWHfdC797IvvPoYKfa/5fmmeUw2H47EZBge4eER9jsMDEIE9T+w1cBg41t0D9Zn2Gx/5QbnR4nxdCh0Gjgo49g4dfeCxBQC5FnTULdbVBp7psLHDXuy01zUADqWiqBgd4+7jLJeuibRAB0rIV9990QffjZwfFUJ0VcQ4HL32hg5Dx/X2QFE87t8rIUzO2j7oMHBMUDh+9wcfORR0TIGPTABZCzpiFUuZy9KoFAKHvomzXZNnUUI0DK8LSu5AR4ANlBNkfR92t3N4TAXm/QLyMD73+hs4vAfWv8443+KN2JTwytrQcbiQylGBYyp4ZIPIUujQYLsCG02YAwck2OgbONdlfVBSgwmPDeXHnBDAgyFyn3r4uXKhoq4QbvEBuL4TfObNdx596Du/XxQpGe+519rA4T1wvT2gUHO8AdOEcoHHFOgwcHQAxxGhYwl4ZMDIXOAQEChHQ3DQBxZ9yae1YjIEH0/kfzRTfJVI+ucf/B3ndVzh0PvpL/y4fOS9JowlAzrsbAwc3gPX2QOELNVRmZsp8jn4/OPvvFkUZRTGa0LH4RSOOo/jyNCxFnjcAlDGymWlcOBwFOaoYWMIMJaARx1mifkcVLPwWp7+5vd9+K144H3ivS+VSZioSAAGUMdaY3zO9zgIBSQ2r4H3wHUHwnHdAf58XSr5QjLpGHRsGTjWho1e4OhSOY4MHbcGj0uhpAYPNjcOP4ZLYlO0KRUuS6CjCzhQNz7/8JMlgRHaJ6fj197/OR96K4ZRuKsCMFCRogk8OPz4PTsaw4b3wPX2wM/+ysPyMY6953O+3wUcXdBh4BgAjqNDx5bAYyqEDIVZ5toc6Ii5HG1Ipen1AWgAHNyNc0GSw/HUt77bXqh8zp2BD8P5h5xCKIBdlwEeT337O15bw4b3wLUnzYZcjpiUP7dHhxWOAeAweGwXQMbyPC6Bjxo4BBpIiEj4sTkYeRtckKgblGxygRJeIQygPiDO7Zh2sLGGCpEINNR0rTZBB9DH+qKEOGHXjtfwdd09IOCoFeZ4nho4FoRUDB37gI+1enz09fmI/T1KzkhHvw41CkPaB0RiZ1OV0OpOXEqI7W/XgLCU8l9Yq3YNY+M1fR7t/r1SIcQQN6+r95Svqev344h9jYbC2mtAx57zNwaBw9BxTvAYSkzFBBvqXlr36rhjtEFv+nXEsAuqh3IOyO/AocZyzrPnfBCSwgAylI0WNGLX1y5roEMqB9ABFD739tfT/yeb1+Boe0CJorQG4CM3V1x/qIuACNeucuvqnA4Dx0KV4ywhFoNHd5OxsfLZroZhsZolltAq1EL+B+EAhWOObiSZ0R2U8IdUCSWCtiGU0Iq+z2IeTfb/ZPManHEPkJtGiSyfU01Wcj2aZPo1oOOQCscS4DgLdOwBPC5pKjZF8ejL7ZhSPlv37CAvgYuT0AywcaZyzgJVP/pa+b9ReAAtEmo1LKqvtXxXx9caOjjgCMkwW4XnUUa9zWvgPXC9PUCPDq5jbp4Aj3LT0HQl5WzcA3BcCzZGgWMJdBg8zgEdfVUtXeARq1nadujV/JUlHUnJc9jD4QlUPP1nf9SZEKqJvPwvhFEArxiKGoKNwRk3D15u11Yg51LZ/L1gO3ZTsDJ5uznfpDRyHZZSdvokhXyOJX05DBwGj91BxyXgMbeMNrZBp0qFMIkaVimEgi0pk+UOggtZ3f62BCEoCvF18T8CFKxBnOjKhNeu/IzB3JgOu6MgPXi5PG72Gti8BkfeA5xf5G+ow2+5lrmJ6rgRKDcSTUOwSxqBHRY4LlU5rHjkA8cWoEMltDEHIeYayCkj/z/zvbcW3VVojogGl33oG28XxYSP8XfXrNpQBcnQsDrBVewEqnknVJIoGbSeyTA0ZC/2QOkDD0EHd1Ycgkpqw37xlxxayXZUtmOsAdd3vFmIlSpPhJRRG0dCKlOgY8+wcTPgcKhlu9CxBDymNgzraxQmp4gz1LwV7ha4+48NwoZM4QHipXLoCkfwmLrDZ7iZ/obHB0SIswInwMpYzghARNUMv4+zVgUNj6UE167H4Hf5uUrnMJ4b2Gj7aXQoGUOgMRU+Yq4Mz8NaaY35n7MPapvX4Ah7gGuKm5oaNvrGSawxX8XAYfjYRXLpmtCxBDjGupMCC1RqDCkGOHapFDh6JZzWzbDU3pufCTxKiVpT+aFwDtAwdqgIgPgbPvI6YyvxGB6K4RKcPNAR2x6XnIyXXiu5KiV7nf99IWgMgUe9viqT1ZrYvAbeA+vtAc6ivtEScxqAGTgahWNNleOMqscegGMqdExtiT5UsVKHVpQ8ihPGaauigo8kXKonBc5eeRt8r03QqnpRcBfP5MYCIFWTLJwv4QyFX1R6y/fiIcLzAD+aXdICTdVkK5b18vooheNjeW0hntulRGhN+sqMp9jQBF/+B1enGC4MF+vuAW5i4s1O7LcxBziWQMeewymjIZVrhFXODB97gI4x8JjajXQINhRGUchBCofapOMocfTckQMOpWojtO0GKOIcg7FS0WhSOur+HzHX4Rd+/IUCIAIHJYN15aHwc4BErcR5fUPx3D7AGKv4mQMfPC+vmcoYFBqpQl19Tc5UhmzzGqyxB2ikx1mhm5m+WSoGjguA41bQcQb42DpwDEHHEHD0Ob+uPhw4fHUXRUkAIjSYDIlSY5/l6Nu+FK+82oYjVN/eF5aon/uJPiChLBe44HlRBOSgAQheYxfYKDGTn/N6lABatznuAow5k3rnQkiX0qHuhwAIH/lfVSFkJ2wn7D0wfQ8QMlVYNTbli4mjU9ubb0nhuJVf3yxwnAE+9gYdQ/NWorOLVSlywlIEuPPmYiWZE0fdOusAAYIKoKMoG8GJy5GW6o+JuQ9Djcd4fB6LvArUFakvgEd87j6Y4XEUCkJBIA9FuRlrgsYc8OgCLl6nDs2Yy+LyWQOHgWPaHujqpROrvlBjCdWiGnJtxRuOvsTRueWxhweOrUCH4eP2wFFDRx9sFMfbDDHCsXH3jFpwBzbu3ytOuVRtNImitSJwBwru3yuPIRWjLyQxpdupHG98LQrRABu8LlQWDpSYyDr23HUVDv83fz+Uqb62zYGO+v/XYalqHTcGM3wYPpbvAa6ncq6F8CsfSz5ZOBfGWpzfGjhu6b9nA8cWoOPIALJllaNL1YgOl4sKwh8Ld8j59ykURR0g6fL1F3onME51wlHh4HUhiUoOVTOutjNnRx+Mseedmo9RJ4+N2aXg0Rda4f8msZX/HVWn7lVi8xp4DyzbA7GkX+X6nDV8X+GXoi6i0C5sc27g2AB0HBVAtgYdXfkaCqEoH0BzQGIYYkmSZPm7Z7/SmYQ11wnrNSJzAhzxDqQruXOJMjEHJi6xOaAl4KD7qtosK4SCokNyrJ2LAcN7YHgPUCk3d41UHUaYFYWDXK/6bIxKRxZw3No/T1I4tq5yHB1AsoFD0IEjK8pEU4lRKjaaZMmYXNnVAOdWDrzLCd9RVZq7jlqFmQIac17H3EqfNQGk/p8BLMI96hGiKiBVrcROpOVO7Y//9E77dZvX4Mx7YEqeE9cQoUkUU/LTSDhXVV3p09GcOVI4MM5MrlcDx4GA42gQkqlydCkcMVxRmm6NdNub6riX9gQZSmiNeRbK31B/jbFytkte59KGapfAR1R24v+pmQ/ABkmyqlpRu3bUEE3u5c7MeR35Ds+WtwZcJ5wZaponEKcvkH6H60aVK3XVCucioUtV3cVk8r5cjqEze8/qxkUKx96hY+8Qcmvo6MvhKI686aNRl4Xd2nFPAY/oiMvrbQ4JlIApr3VNwLgGgPSFlOpcFjU+w2KJn+7KkJHVxt3mNTjzHmjPiFdeLeoFOVCxfxC/U7oKdwxu41pTl2IUDc6ZOpxSKxwGjpNAx15B5FbA0QUdODGVt3I3LLKPBJ/hvIecsZJSlbw1Bka3hIxbgAfqhapV2i6q4Y5M7eE1WC/Cic1rcLY9gJoRm/6p+gToIIyir3sHt3352VIFxpmDUqLzUuGUDODI8qeTFY6zAsfeQOQa0NHn/KLKoTkhMaSyJQf+xOt+9iu9oJENGGvBx5SE0ljFUrebl9rBWnF35sFv+c7Pdps10OiEroZ+S0YTSFUtHY6bx9FZMxU2Tgccho59gcha4FE7vNqJ6UJS2aUkw+jMsx310ewa0KEwE7ChKbvqBmvzGpxtDxBSVLixLuOfmwyvfDfBSxds3Ao4Mm/UVwGOMysdU20v8DEGHFw8XHBKQqRpFhdQ3feCjzVozK2M2YNtHTqGSoX7GoNx2FIy6x4d+U7PlrcG5DFpUKTyM4aq2aZci315G1NgYw3gyPaDqwGHoWM/MDIXPGp1Yyz34ciAsUVAuTSRlMNVc2tUvaPZK3Z4dvpn2ANUkqDmUXHC19xIqRwWtY/qlHhjdWlCfAZs7BI4rHIcTxWZAh5ybDHLus8BZjv1Pdo1waOvR4dCKORpcBdHsyINstMd3tQDm8z9bKdh8xos3QPARtuWvCkfBzQIqfBznXuXJpn3Xf9nUDdWBw6rHPuHkCngsRXIuKQ6Z+3E2q2AyBTg0Ph6VAzu3DSwjoOVxFD6b9TNwIbUDhob8ff0GvC4ezv9vYAf3Xbj1/SliR2IZeztKbCxBDTmnGOnBQ5Dx3kgJNMB3xIo9gIhS8GjqyGYGqBRqhcrUAAOxa75nDu9T7z3pfI1UMFsCEqgkZ3L1N7n3yjQQodSlRE6HJPvUG3ja6B+GqWPRuiUPDRPaW5V21pn3N5h42rAYaUj/429dehljiPOBoWjQMgc6IjqBpABIGiMNrCB0gFARIUCmVlAoYM5DqgCLGLDMI28d2WLnf3WYad0/WwmVteqxtjwxjVUjVsCR7YvWgU4DB35b142hGQ7963bVuAjQkec7Ku5DsrnKMPt3v+5Ag/IzagYKB2aA6HmYHUDpHba7kuvdYZjMCXj2bwGWXsAhY7wSITokrNRQccawLEV2DBwbMDx2gwgZ4SPKb05BB+EUDicUT3isLu6dXMsp+Ug/+q7L5bD/bm3v17mUJArwuc8jjqX2rwGt9oD7L2hnwPW2ufs1dJ5F3juGEK5JnDMPT+OABsXKxxWOfLfQCsg+UrGVuFjSpilr2RWigWHsJofxS6LtbUD/JrJwYRpyPTXnaQqXzCqYTS+2+Y12MIeIE8J6CCfqYQJG+WOn7Gn+5JGb6FuLAWObH+SAhzO59ivOQRzDPhYUkI71qa5y2LH0jtt0kOrdFQQTHefrmrJd7a2u2E/cpMAbeU1sV+pYIn9hy4BjrPCxirAYejIfxP3CB/OAbkteMzt3TEUehmCjxo6IngosRTVgztJpGzi6hzu5H4YPuz8l8JPTGZew5QwzeeEF9UMD/AYgw4DxwaAw0pHPjRsHUAMITngsbQ9ehd0dA6Ee/ByKacljEJo5TMPP13uIn1nbcBYYw8AAWuDCwnUmg/VwnLI6xgKrVjduDJwGDryAeCI8HF2CNmq2jGUcFrnc2DK36B6Ze07Uds514BqKj7G3KC1jDAKyaSlgiVUr4z15ZgCHdcOp2T7gs0Bh5WO/Dd9z/BxRgDZA3TEZmJABiETcjT4yCHOIDiUjUscwTPfeyvd0dm2oWYoIRlTl9w1DBWOEB9Jz6gc5HXU6sZQuewQcJwdNlYFDkNH/pt5Rvg4E4TcCjqmJJX2zWfhrpDDWoOvyM3Q9Nk1HAIdIeV0GLhlADg2BAGp9fsMbGjmieaekOC5duWTQoCE/0qlVujTURroNeFC7fl6QvYlwHFE2FgdOAwd+W/oVu2W8HF0CMmEjjHgKCrH/XutssGclXJo//GfFlhYwxFwR8tjurfHOUxqRptT0YQ5YtM5VUCt+bxADOBcP69AQ1UsdW6HgWOjwOHwSj4InAU+jgghGdAxFFJRWIU8DVWkqCqFhkoAxyUzVpT/AcxIThfY0GSMj24utl8jsZiqpRg+k7ohVaNuPifnj8JAZ1zldgClmPZFfA6Mz3ku9o9+h7wNfgYko27wmDz/x995s/ye+tHwWoAbqRuxR0edz7FE4TiqunEV4DB05L+pe7JM+DgKhNwSOvoSRjv7cDQHMneB7cj7ygHMNfVF0AwX9fcAPmIIR0apLfkj2c7U1q0gkCNBCepzv/5ecfJqEifg+PzDT7a/z54aajwn1UMty9kTAlxAQnuHn7MvtH/4GsigSy4QIrgRLGs4IX8PxKjzbt2NdAg4DBsbAg4rHfmOfwuWDR57h5BrAUfdFl2g0XXo1+WwHNaX9tdAtcAhcAfKR1UPRMMJxIm3SlDlzlSqB3+rWL/zP7YFQV1zTfgIKAg0+hrOdZVkx1ALjwF08r6zB2Krfj1PnA/0xKygptKK/SVIAUDUxC5Ws+h1CjrmAMeRlY2rAsdS6DB45G+IrVg2cOwVQq6tcgAa3JGqcZdi59z9tQ4jdBfloOZ3+RvaR/c5HBwBDZaAA6CA8ImSAJG0Ywy/1zk0z99OAg1TbFXJMFQpc0m4x/bkGqih25S1AQBL74tqamtXZchQAzoBB8/NntKMFB5bpa6EXsoAt+rxO8Hlwcvl79jHgp848LBAdfMYSppeAhxngI2rAoehI//NPYJlQ8ZeIeRawCGFoxy6goAmS5+DnINduRuSpJGpObRx6GT8I5Pz+3wPwFAJon4fZ4CTUKtp/p6/wSmRB1I7pC5pPd6d8hgaKjdmPBeOyl1Pp0FUTAYWIPIeA5lK4hxr8sZ68/7GHI0h0BjrB8N7p7BI3BdAZz2QbUoDO/aQlAxeX3xdXa+lPNeM/I2zwMZmgcNKR/7G2KJlw8UeIWQt6OjK4aglbKAB09RN5W6oRBbj0Od31Dqau8Y7ykhT4YKhckgd6XJEXQ6ijufHCgZgAvWkT+XgZ3p9fI3iotyTqcByZCP/pk+xKM68yXeIuTXKnwECukJZgkr9rcIUarTVBxlr22ATO/ZRKL/tytlYmix6Jti4OnAYOvLf4CNaNlDsEUIuhY6+pNEoYytvA0eD08YJcfcLVAgwoipSlxp2Gc5oagw/wgd/o/kXOEpeg8IqsYEYP6OvBz9HaYnJqCguPAavHTWmy8l2NZ2qnfLP/Ohr6bAwZDGUBBhEQJSpMkNhMYFCLH0GKJ7Ir2nyH5TUy2Pw+6y51kW5N7wXbQnq/Xu9oDHWin+KjcEHeygCtDrlFrVj5iwVw8YNgcPQke+gj2zZILE3CFkbOKLsrHH23AWicOBkcC6oFBzcfJ9Du23c1ABHX8x+Suy+TxpXPF1VCQIG3WHTBRVHJxCKvRZkqpBAqdHfEEKQo1UICMWEcJK+z//L/6qveQ59zmOpLFPOVooQoILTRRVYktTK6wAEgAYei8fEsatRmv4PXo9erwbo6TH4G94rvUZUHn6H/0eqRbsW3/pu+TnPpbyZOsfhjtp0/155Loz/j1AL1SlADK+Jv4/KxiWQsaSTbr2/VeathFaBbwQOw8bBgMMhlnynvgfLhoe9Qcgc6IgHch1KwQHhNHFUKk9tFYpXXi2OXneGioXjcHB+n3r4uceqQoeCMRSznyOLC4ZU1qh8A5y6wifcmfOzusdD6yzv32t7M+hu/s6d/IOXy/+kcFDsgskaxJ4kqm5Q0q2SGq+lXgAfvDaeS+BXHHvTU0JWJ/zyvQIZVZIu/y+AwePRPZb3j8cDlLqAUe8B66ZQVkzkVb6O1ov90jUcbS5YzLE+8Gj3EWGi11943IsjwNRQk68+ZWPJmZB9tu4OOAwd+W/0mSwbGvYGIGPQEQ9h3fXhLErWf6NQdDkanJUceZ1fgZPhICe8oVkVQ3MqlsTjeSyeS3kkvB5et5yqwj7xf4j/R1fJ75DdSVZtQEThATlZNSuLJby3MkIYhE8EHC0YBWVH/0P8v7oSc1WKPLWSpAaQ9r14/YUnEjmHQGMNwJjT5C7Ca1mXZt30Og0bGwUOQ0e+Iz6bZcPC3iBkCDo4YMud8OsvlANYzqnE54PM3Kc41MqDDm/uots77Ndf6J3EuSQuX6sxdaimLwl1qHqhBpEuIKmrZAQc15hsujRfgzBL7H0xBhpduTJj1SNDylOs/Bh6v68JGGPQodfEawYQWZP4f9Slr2uqGkdTNlKAYy3ocBVL/sbZm2VDwp4ApA88+u4Ap4LBEyDQSNWCGEnpayQFTk0QnAJJU8onOxufNSoAeQoZisYU43VJaRjqgTEXMqascxdkjAHBJX1m5paBx73K+7hkOJth4yDAYejId+J7tGw42BuAzDmk58bHSyXJQjWjyzldA0KmQEddRhnVDRyVKmFiwmm21b0xUGDKDJye8uM5oHHJe3hNwJgLIdon9Wu9RgXK0ZUNDJ99c+AwdOS/8bZzgMeaALLkLnFrdgmATFU6AA0qMNSOPVakbNWkvqB0UDEyt/HWpaCYDRlzbeg6ufRaPeLZHEWCFOBYGzqsduRvqj1bNhDsDUBudYe5RfgYAg6pG+QmUC6KE6dChCqdbKgYMnJLSlvxJkenVjn6YGMtwNgqZMy5Jta4LrPPwWtY7afTgMPQkb8ZbOcGjzUAJMMRZMNHV+VFX/OzLSSKTjF1BC2dX0PVihKClyTzbhU01gw5rnUNHu0s7hMGDgUcVjvyN9oRLBsAsm1P8LEWgMzJ+eiquImlsIQoqL4hWTQbJKYaOSaEgtQoTD1CuhpxbRU01gSJW4HGmWAjHTgMHfmbw2b4sPoxX+2IlTYYDbbUCp3kS5qakcex9bbmNEIDLNR2HkWGMBDqDI29yv9XlT1fAhprwMYtweKaoHE02JgiBqQDxzWhw7kd+ZvwKJatOmzBLj2s96J8TIGOGFIpjazu3yvhCJQN4EPdREkcRfHYUoVKG0bRXJSmGdidRmChlfcUheNaoJEJF9eCjLPCxmaA49rQYfDI35BHsWynvxVb4yDfMnzMgQ6AQwPqcOClu2rTWRTFQLNMtmaU69KundeNolHasg8kjS5tzrU3wDBorA8amwMOQ0e+M7UZPo4OIGsDRz23pR1n3qgEceIs6gehlmzQ6DKUGA26A5KGgKOrN8UlsHEGyDiaqrH0xn9TwHEL6LDakb9Zj2bZasOWbM3DPxs85gBHXRpbKjyakezkdGQDxZhpMi4qh8pj646jXSrHJbBxFsgwbGwYOG4FHQaPfEd9NMt29luzaziHW4LHnGqVWLFCzgYhCkCDcfHZMFEnifL6SAwld4PPgQwluJJrgtpR5qxooNsrr5afqxX9GHDsATRufS1kn01r2Bo+99TAYejI38RHtWxnvzW7luO4JnSMwUZf4y+FUtSDA2cNeDzz5jvlaz6ntTjOnkqQW8EGuSSxGgUoKiPom2RRwIKfkYOiibL8Tfk8qB0CjrmwcTbIMGjsBDhuDR0Gj3wHfVTLdvRbtGs6lbXBows2yrRcoCI44TtD2+7fe/TZF98tVSAkk6IY4Nwx+lt8/J03i6rA1/zONQe78dwAD8pGq1w0k3n1ehUC4rX91k/9ZQGMElZhFkyVyzGkbmwRNDL3efbZs4at7Wc3CxwZ0GHwyN/gR7VsJ79Fu4XDWQM8cK4xOVR9K5QMWqo7SBJtDGfN7/B9HHxx3io5VdlpSCpFbdDYeEIdOH1VkUR4+MzDT/eCBeEb4AZ4AS6ABx5LSoXKdPkapSWOoRcsAUBKbuXzGjiG8je2BBvZ+/oIsHEt/7pp4MiCDoNH/oY/smUfhluzWzmipdBRT7hV6++2KuXBy22IgrkpOG2cPU6ej12Kgh6Hv0ENUbMt4AD4AA74ex4XeJmaC8Lzo2QINFrAIR+jeR1do+f1vfLaXnm1PIam3WqgG/9/VznsFkAjew8bNA4CHJnQYfDId85HtuzDcYu2NfjoC610DXGLThtnDShgCr/IusIwQEqtkgANah72zPfemgQcwMFzb3+9qB0lyZPHap6ja+z8mGkCLsbXW4KN7L16JNi4lT/dBXBkQ4fBI/+COLJlH5RbtFveHS8Bj6FEUikXQINKTLtUhTrhNJbUAikKcSjEMtXo9dFOf23UjaEJsF0j5+v/C6WDz8fCKGeDDIPGQYFjC9Bh8Mh3zke37MPzzOAxBh9TKlhw5jh3cij6nPvYePvYOAyVgscq6sU33u5tkU7IhYoSFJVf/vn32yFsgAfdRPlI2OaScfNZ+RrZe/CIsJHlP3cFHFuBDoNH/gVzdMs+SLdmWwWPLgAZUgvqcExMRlXPC5WlAgmER1Q9oiRVfl4nlKqfRpu30cCKElq7qk2WTH69Zdlr9p4zaBg4NgUdBo98x3wGyz5ct2RbAY+5XUrH4AMYACziqHvgAZBQ4icwQqUKVSSABAoG/TxUdku30JigykflkEhxmQobWT02svfXUWEj20/uVuHYmtJh8Mi/mM5i2Qftlmyv4DEEIfUEWlQNTW2N/TIEH4ROPvHel8rvFWWjqp5R3sgcZWNJm3KDRv7ZsHXQ2D1wbBE6sOwNZjvHGmQ7/K3YHqBjDDyGql8iNGgwHKqGymZ/+gs/Ln03KJ/F6vCJbGzi6yVzUY6sZmDZ1/oSy/aDhwSOrUKHwSP/gjuLZR/GW7EjgMdQjkedWEq30jiJtm0u1gEcU5JEbwkb2XvlqLCR7fNOARxbhg7DR/5FeCbLPpyz7ajQMVTNQv4GYZXSjryn38YYcCyFjbnAkb0/DBr5vvAQwLEH6MCyHZLtPGuQfWCfATyuAR1D02g7y2ebfh2x50bM4RgCjqEhbGvCRvZ+MGjk+77DAcdeoMPgke+Mz2TZB/hZoWMMPJYAR+xkShiFkIq6iWIkkRJiUWlsVD3GcjmuEUrJ3gMGjXxfd2jg2Bt4GD7yHfKZLPtQPyp4rAkdfZUrd6pUXn+htDovnUy/+LEnWqULOvh5KattVA/9ntqdR/hYs+dG9nt+RNDI9lMGjgNBh8Ej/4I+m2Uf9EcCj7HQw9JeHbFSpeRmvPRamZECUNBXQy3T62qUOvyC2qFeHWoUJvWDj5oEe2lH0ez32KCxfTukwrFX6DB85DviM1r24X8G6OgCjzk9OVAlNMFVDcLq3hpdCadF0SC8InUjqB1STLoagvUBx95hI/tam2LZ/sfAcULowLI3vu1ca5DtDPYMHpcAx9gE2hIqeeXVolB0NfFaYvF5AA/mrKCg6Dm7gMOgYci41KcdWuE4AnQYPvId8RktGwrOAB0CjnqSbBtC0QTZV14tTb1KbsaXn70DHHNnodSqCo/D4/OcdS7HEGxkv39TLPsammLZfuXWdgrgOAp0GD7yD4gzWrbj2At4TAGOCB1y+hpprwmxwAUTXgmdABnkWdS5GnM7hQ7lkPAaFFapYWOPqkb29TJm2f7DwGHoMHhs4CCwnRNAsoFDSgNOPyZ2YqX0dWDo2tRy1jHwEGwYNAwZVjisdlj1MBBsEoiyYWFr4DEVOOqwRpsc2gxr46PKXJW70VW+OhVyptrekkKz97+VDIdUThdiccgl/3CxHQc+rgkcXeGMtpqkqSDp6g56LdAY+3+y3wtDxrHsNDkcZ4MOmR2hYcDqx+3AYwlwtHkcDXCMdQK9FlxsGTS2fI5ln/F7slMDx1mgI1r2xWk75xpkO6xLbC3gGCqHlcLRVYEyFzYuUWiy19qQke8jDBwGD8PHBpym7dwAcgl09FWHPNG6/MHLszp/rpXwmr22si1fY9nO+gh2eoXjzGpHtOyL2XbuNTgaeAzBBpBREkSbLp98jGqHOoHydZe6sRZkbAU0svfekGWfy0czA8fJQyx9ln2h27wGewePuiolKhp9A9PqcMs1QCN73bZ8bWefu0c3A4dzOwwfGzjobONrsEf4EHAAGm2I5IO/M2lI2lHUjK3v7WwnfCYzcDihdPamyT4gbF6DrUPI3ITSo+Rl7OHazHa6ZzYDh6tYLt5E2QeIzWtQ74E9AMjaZrgwYGQDhYHD5bM33VB2/nb+W94D2eBxCZBkvr7s980KRj4sGDjcsyN9AxlC8g9Y27FhxFAx/L5ln282KxxuFrbhC8EO0pCw5z2QDQZHVyjGLPv8si1fA+dwuENp+gWUfYDZvAbeA9vcA9lnk23dNTBwuDX6Ji+q7IPO5jXwHjBgZJ+DRzMDh+expG9CQ4idu537+fZA9nliM3CkD3PzILh9XYjZh6bNa+A9YLjIPgf3YlY4DB3pm9AQYqdtp32MPZB97du2vQYGDqsd6ZvQIJLvKGxeA8NF/hl1dDNwOMTy6OxmZ2tn6z1g1SL7HDqDGTic25G+CbdsdkSGkTPsgezrzHaONTBwOKk0fRPu1bKdhM1rYKjIPwdsBo70ihWPvfeFaCAwEFil8DlgILHCYfDwQbCJg8BQYihxyCP/OrTdZg0cUrHi4YttBweOweSYYJK9r2xeg1vuAQOHwyw+dA566GQ707NZ9vtt8xpsfQ8YODZk2ZvB5jU4I7h433vfew8YONIBwODhg8gHkfeA94D3gPeAFY50MLDi4YPIB5H3gPeA94D3gEMq6cDgMIsPIh9E3gPeA94D3gPO4diVecP60PIe8B7wHvAe2PMecNLoBmDC4JF/Idi8Bt4D3gPeAwaOdCe/RfOF4cPRe8B7wHvAe2BPe8AKxwbgweCRfyHYvAbeA94D3gMfueoa/H/S76BhFZ10YwAAAABJRU5ErkJggg==", + "ImageBytes": "aVZCT1J3MEtHZ29BQUFBTlNVaEVVZ0FBQWh3QUFBRU9DQVlBQUFEQ0NNNXhBQUJQWFVsRVFWUjRuTzJkYmN0dDEzV2UvUWY2b2FESVRxdmcxZ2hoYW9jaUVRU1NRNElPQ1E1SE9EaHdoSTBERWk0WVhBZzRZT09BUDZRUUVMUVFzS0dCUUEwdExUSzBFR0pJQ1NTMEVCSmFBcVlsMkRTdGl3bkNyV2lNSXZuOGhGT3VlZGE5TXA1NTF2dGVlNC8xY244WVBPOTc3MmZ1dWVhNDFqM2VQdkRiUC96b0k5disxK0IzZi9BUm05ZkFlOEI3d0h2QWUyQ3plK0FEMlk3U1p0REl2Z2hzWGdQdkFlOEI3d0VEaDRIRWlvWVBBanNEN3dIdkFlK0JBK3dCS3h3N1UxbXlONHpOYStBOTREM2dQZUE5WU9EWUFCQVlOSHdoK2pEMkh2QWU4Qjd3SHJEQ2tRNE9WalI4RVBrZzhoN3dIdkFlT09NZWNFaGxBMURoOEVuK2hXRHpHbmdQZUE5NER4ZzQwaDIvUWNNSGtROGk3d0h2QWU4Qjd3RXJIT21BWUVYREI5R1JENkovK3o5KzZ0Q1d2YjQycjhGWjlvQkRLZ2FOOUUxb3U4NGFaRHZ5czVuM3NhOWw3d0VEUjdweTRUd05IMFNHaDN3ZzJLTFpRZmxzT05NZXNNSmgwRWpmaEdlMmJJZG4yOGNhWk85VG05ZGdqVDFnNExDaTRjUEVNSkh1VUcyR0VrUE44YzlpQTRmREorbWJjSzltSjJsUTJOTWV5TDVlYkY0REE0ZnpOSHdRR0NyU25hSE5NR0lnT2Y1WmJPQndRdWlqczV1ZG5SMis5NENWa2V4ejZBeG00SEQxeVduTVRzVmc0VDNnRUUzMk9YUm1NM0M0elBWd1pxZGlzUEFlY0o1STlqbGtNM0M0cDhiQkxnUTdGc09GOThEMjkwRDJPV0hieGhwWTRYRHpydlJOYUxqSWR3ZzJyNEVoSlArTU83b1pPTndwZEpObUIyZ0g2RDF3dmoyUWZlN1lEQnliTkc5TXcwWDI0V3p6R2h4OUQvaWNQUllFV2VFd2JGaTkyTURCYXR2UEd2emVmL3ZKM1ZuMm1obEM4cDM5RnN6QVlWWGo2cHNzKzRDeWVRMk9DaEtHRXFzZzJSQmg0SEFJSlcxRDJibmJ1Vzk1RDJRNzlUNzd3Ly95OUtnWlJxYTl4OWxPMVdhRnc3a2Fob3gwWjJjN0wxaE1BWXBMellxSUFXVHJzT09RaWhORHJXSVlCbllMUTlrZ01RY3cvdVEvUFRYSjlnb2dXUForbUdMWlR2Zk1adUJ3RmNyb0pzaytJR3hlZ3kzRHhaQ0NBVUQ4K1I4ODllaXYvdVVISC8zRnYzK3EySjkvOEhjZWYveUR4M1lMK01nTXlXejkrczEyd21jeUE0ZkxYUTBaR3pqMGJFK3VRVFpFTElXTVd0RUFLZ0NNLy9tdmY2S0F4Ly81NTMrL2ZCNmhReVlvNGVkWUJKSzlnOGNlQUNUYklSL2REQnp1clZFMlF2YUZianYzR21RN3dUVkJvdzZmMUVBQmRQek5sNTk5OVBEMUZ4Nzk5VzkrdUh3VVlPaG5mRi9BVVNzaFIxQTlEQ0Q1enQvQTRVWmVOOTBFMlU3R2R0NDF5SFp5MXdLTnZseU5QaFZEaXNmN1gveFlzWWNQWHI2amdJeUZYbzRFSGxqMnZoeXliR2Q5QkR1OXdwSDlCdHphc2k5YTJ6blhJTnVSM1FJMCttQ2pobzQ2YkFKMFNPa0FOdmk2Q3pqRzhqM1dnSkRzdFRaODVQc0lBNGZia3hzd051QTBiZWVCakxtZ01RVTRocUFEMEFBNENLY0lPQzZCamt0QkpIdnREUi9IdE5NcUhOa0xmMDJ6WXpRY1dNVzRMV2hjQWh4UjRRQTRwSExJdWhKTWwwTEhYQWpKQmczRFI3NC9NWEFZTmd3WmhvcE5nR1cyODFuVDFzaUZtT0xrKy9JNFNpTHAvWHVQY3pudTN5dGY4L24vL1VmL3RFMGl2Ulo0akFGSTludGorRGlHblVyaHlGN3N0UzNiMmRqT3VRYlpUbWFMb0RFVk5tcVZvODdqQUM3K3p0LzlZR3QvODlKcjVmdGRZWmF1c3RvK0NPbjZ2YnB5WnF3U0p2dDlNbnprK3h3RHh3bGhJOXZoMk02MUJ0bU9aQSt3Y1Fsd2xQeU5CeTgvdHZ2M0h2M0YzL3NYajM3MlZ4NldCbUYvOWV4WEh2MzE4MjhVNk9EM1pCRTZodUJqRERUMEdnUTFVMHB3czkrelBZTUhsdTAvREJ4V05nd1pHemdJYk9lQWpMVkJZMjQ0cFhiNE9Qc0lGSHhPS0lXUVNnbXJ2UEpxZ1JEZ0krWjQxR3JIRkFpSlA1T3l3dU4zaFd5R1FpM1o3OThSd09PTThISDRrRXIyQWw5aTJSZUQ3Vnhya08wZ3pnUWJmZVd4c2dnZmdnN2xja1RnV0dyeGVZQ2IzM3ZxUDVid2paNnpLelN6UjdWakwvQ1I3V3NNSENlR2plek5ienZYR21RN2d6MkR4aHJBRWFFakFvR2dveVNUUG5qNTBiOTc2aitYajRSYWdJUSs4SWpnVXFwZjd0OXJWUlJWd2toRlViZlRPa2RrYnFPeDdQZjJLT0NCWmZ1ZmE5cGhGWTdzaFRWazVGKzROa1BHRm1CakxJZGlERHJhRU10THJ6MzZKLy80LzVYUUN0QWhXSWk1SFYzcUNNQUJyTVNFVlBKQ2VFdyt6dWxzZWdUbzJBdDRIQkUrRGdrYzJZczZ4N0kzdE8wOGE1Qjl5QjhOTktZQXg1U2t6U25RMGJZK2YvMkZZczk4NzYwU1ppblEwS2dYS3FNdHVSNHZ2VlorL3J0UC85Yzdxa1pST1I2OC9QajNCdnA4WERMRk52czlOM2prKzdWVEFFZjJZaG95OHAycXpaQnhLOUJZR3pacTRPaks1NURhOFNjLzhXOGVsOHcrLzBaYjRmTExQLzkrQ3hzbGpOS29HQXEveElaaVE1MU1Md1VPZzhkMXo2RnMvMlhnMkFsczJCa2FDS3htSEI4MjVwU2t6Z0dPTzBta1VqdnUzeXVoRXRTTUFoNk5taUZGbzZ0cmFiU2hjdHFsVGNJTUhyYzU1N0w5MldrVmp1eEZOR2dZSkxZQWs5bnk5RmxBNDFxd01WYXgwZ1VjNUdaODRyMHYzVkV6SW13TUFjZVUvaDFyUVlkRExRYVBRd0JITmt4WXpjaDN0R2UyYkNlL0ZUc3FiSFNXeUg3eFk0OCsvczZiSlhSQ0NJWHdTbEU0N3Q4cktzZGM0TGdFT3Bhc1gvWmVtV1BaMS9jY3kvWjVoMWM0c2hldnk3STNuZTBjYTVCOUVHL0ZqZ0FhUThxRzhqYmFpcFBYWDNqMDFMZS84K2gvZmZpZkZkajQ2Uy84K05Fdi90TEQwaGdNNndLT29keU5MT2pZRTNoa1grdExMTnNQSGc0NHNoZk9vSkYvVVozTnNnL2VzNExHSmJBeEJUTDZlbS84MWsvOVpUdk03VTZWQ2xVbXI3eGFra1FKcC9CN0pWRzBJNGNqZGpFZEE0NmwrUnhyckcvMmZqSjRHRGgyQVJ2WlRzaDIvRFhJUG1pM1pIc0ZqYkdPbjZXN1o5UFFTNld2UUVRcGEyMUNKVURHWng1Kyt0R3ZmZlNIUmVINDdaLzg3MFh0VUkrTnp6Lzg1T1BmZmYyRjluSCs4S24vVUw3SFl3OE5nWnNDSHRlR0RvUEg5YzZRYkQrNVc0VWplOEVNR3ZrTytBeVc3ZGkzWmxzQmpUN1ltQUlYUXprYTZnSUtWQUFKSklFQ0VjKzkvZlVDQzZnWXl0Zmc1ei96cXcvdlRKVUZUSUFRbGNTcUxKYnYxV0dXcWRDeFptK09vOEZIOXZtdzFMTDk1cTZBSTN1eHNPd05ZenYyR21RZnBGdXpyWUpHbjRLaEpsMVNGT3FremFuVko1b2NDelR3V0VERmg3N3g5cU9uL3ZoUDc0Q0c3T2x2ZnI4b0hzQUlvUlkreDRBV0pzL3lrWGtwVTVOSWgrRGpWc0JoOERnZWVPd0dPQXdhK2M3UVpzZzRJbWlNbFg1RzJCanJtNkZSOCtxTlVhcEhtaGJrZFNKbkRSdGQvVFZRTkZBM3VpQ2p6ejcxOEhOUDlPYW95MlZyK0JsVFpGQk8rSHhNN2JqMmU1VU53RjIyNTNQUndMRXgyTWplRUxianJrSDJRYmxGMnhKa2RJSEcyR1RYZGlqYWc1ZUwycUFjQ2dGRlRPWVVZS2h6cURxR0tsOURJUkZ5TkdobFBnVTJQdlNkM3k5aEdHYXVxQXRwWDMrT0thWlFEOGJYVTlTT1c3MkgyWHZWNEhGQWhjT2drZThZYlFhTk0wRkdWL2hFa0NHWWlOVWdmRTFKS3FyQ1AvemFqd284RUw0ZzU0S1BNWkh6VHNWSm80Ujg5c1YzeSsveE9VckMwMy8yUjIzWks0OEx0QkF5bVFJY1BEK0FBbXkwK1J6TjY5WHI2RkpiSWl3Qk81cE15Mk1BTWZ5Zi9QOVRReXkzZWsrM0JoOTdQeXRQSFZLeG9wRy9BVzNyckVIMlFiaEYyeHBrOUNXRjRseGpjaWNBQUF3b2lWT0ptVkdkNEhlVXhOazI1WkxwZDFFK1hucXRRQVdRQVRBQUhncWgxSW1oVktqMGdjWlQzL3B1VVRWSUhxVXZCM2tmUEpiQWc5Zks2K0hyWjk1ODU0N2lJcVZGSVJ5cVlQaThUallkNmxCNjY3eU9yY1BIM3MvTjB3R0hReWY1bTg1bTBOZ2JhQ3dCakNuVkp6R25vZVJwTkdHU3J2QUlQME8xUUJrQUFsUzJpZ0VDZEFuRitmTTF2d01rek1uUm1HTTg5Mjk4NUFjRlF2aThWVDBxeFlXdlZRMVQycVEzK1I5ZDFTMXpLMWd5b0dNTDhIR0U4L01Vd09FY2pmeU5acnRzRGJMdnNMWm1Xd1NNSWRDWW1pRGFXV1VTbEF1Y1BnNmZrQWpLZ2lwSzZKOEJhUHpDajc5d05kaW9qYW9WbEE2QVFpRWcxQXdwTDhvYlVWa3Uvd04vVXo0UGNEVTA5TzJXWmJON2dJOGpuS01HRGllRXBtOUNteUVqQXpMV0FJd3BzTkhYSlhSc2ZEekFBVWpnekFselRNMjl1SldoZFBENkNMY1F4bEgvRHFEb1ozNzB0VkoyMnlvaFRiSW9JTUxQU3hMcTZ5OWNOTlorQytDUkFSOUhPQzhQcVhDNDhpUi9ZOW5tclVHMmVyQWwyekpjVEFXTnNiYmtROEFSUzF0eDBNcXZ5QWFOTWFOTEtSK0JENVh5UmxXanIySFkwdmtyV3dHUFc4TEhVYzdWd3dESHRXRWorNDJ5SFdzTnNwMzdsbXpyZ0RFSE5LYk9RS2s3aGJaVks2RnhWNXpvU3ZpQ0hoblpjTkZscUJxOFRvV0N1bnAyekIxdFB3VTZ0Z1FldDRDUDdETnJMZHM5Y0JnMjhqZVJ6WkJ4Tk1DWUN4cHpnVU9EMWtnT0JTcHczdVJBb0JMSWVaTUhrUTBVWFVZVkRGMUllZDJFZ0VxMXpVQ1RzTDZPcEd0QXg1bmc0MGpuN0M2Qnc2cEcvc2F4RGE5QnRvS3dCZHNUWUN5QmpDV3dJZUJBelFBNlVERVlHYTh5VkpRRDhpVDZXcEpuR2hVeWZDU1hvK1JueExMZFprcnQxUGtyVTlaMXlYdVlEUjBHajNYQkl4MDRIRDZ4czk4eTdHUTcrV3piRTJCY0FobHpZRVBBVWZJYm5uK2pHSkNoOEFrNUc0Uk9sSWlaRFJaalNhVHFLNkxxRlRVaG80S20vSDhUcDh4T1hlTkwzK01qd1VmMitiYW1iUjQ0REJ2NW04Um15RmdMTWpJQVl3M0lHQU9Ob2VxVXRwVjVVOUdCT29DaWdkTHgzSysvbHc0VVU0Mnd6ei80My8rcUhmcEdUdzVBQlBYbTBnbXoxNENPYkJBeGRDd0RqME1CaDUybkFjSnFobFdNYTZrYVE5VXBkeXBVbW02anFBVHF4YkYxWXhJdEh3a0x0YTNRbXpib1hVUGV4cUFqRXp4dUNTWldPdVpCUnhwd0dEWU1CMXNCeE95d3haN0NKTmRXTXRaUUs2NEJHVU5EMitxU1dNSVRuM2p2UzhXQkU1NWd2a2syVUF5Wm1vQ3BISFpvc3V5Y2NmWmJCNDgxd2NUaGxXbmdrUUljVmpYeW5lelpMZHZwN3drd2hpRGpWb0NRQ1JrMWFQUUJoeWFyRXBvZ2Y0T0VVUlNEYktBWU03VldKNHlpUVcxeHVOdllLUHVwNEhHTnFwYXRnY2lsMTJyMnVYZ05Td1VPdzBiK0JqaXpaVHYvdlFCR0gyaXM0ZVRqNU5XNU12MlE0NXJ6dDJPQU1RUWFOV3gwaGxXYWZBNFNTU2svSlM5aVM1VXF0RmFQWDZOdWtPemFOOForRERxV0Fzalc0S1BlVDEwOVI2YkFoNkdqR3p4MkN4elpqc3UycnpYSWhvQzlBRWFmbXRIbDBPV1FwenFacmdvUDVRcklzUzBCajdYZ1lnZ3d4cFNOMlBnckRrUURPRWdldmVhQXRrdlZEWlNOR0VxcE80d3VCWSt4ZGU0Q2xGc0N5QmdveDczSys4ai8zd2NlZmZCaHBlUHVPWHhUNERCbzVEdmVNMWsyQ093Rk1NWkFBeXVLUkJQakw5TlNHd2RGL0w4dW14d0xRNml5ZzBPY2FvNTJmUHZyTHl4S1R1d0RpeFpzR2hpb25XZlhuWHlYVSsxVE03cGdvd2FPdHFmRks2OXVJb0dVbmh0OFJIRnBKOGVTNU5xODV2aC94UCt2WG9NNThOSDEvdk9jNUxydy9KYzBGYnRtMkUydmlkY01tTEVtOGYvUTZ6TjBiQkE0REJ2NUR2Z3NsZzBFZTRDTE9ma1o4UUNXbythdStKZC8vdjA3TXpkcXA0eFRLVjAzbXdaU01lU2dNZWpQZk8rdHgxMDVBeENzY2Zjc3B5QW80dmw0UGVWdUhnaDQ4SEk3THI1cmJraWZlakZrZDBCRHNCR0FBd2ZQLzB4RHNBekZnNTRnYXZMVndvWWFmV2xRMndoNGFDMmszdFJyTnFZS3RYK3Y5NktwaEtuZjkwc1NVTmZLOGRGZXFpRTVRbEtmNnJLVzJwRjlsdTRTT0F3YitXLzAwUzBiRFBZRUdFT1EwUmZMbHRXcUFZNFU1MG1TcE1hYnkybnhOVkJDbFlhY0wzZTFWRzRBSWpUSDRtOWpSOHN1eDdVa0hGSTdOK0NDNTZUUmxjcEFjWGg4bi9iZUFxTXVpTURCa0FESzcvRVJnSkVpSTdEZ2YrSjdwVDI0SEhrREdvSU5qT2RURjFMMXVnQ0dyZ1VaTkNUanRmRmNQQ2ZQTCtEUWEyc1ZKZ0ZTVTlaYlJ0Y0g5VU13OWRrWDN5MlBSMk13M2o4ZWp6eVZMalZFN3dIcjlwbUhuMjd6UlhoK0hvTzFLRERZckJmN1JhRzFLUXJYTlVEakNkaG9RbitzbndDTWp3SU9ROGVCZ0NQYmtkbTJ2UWJaZ0xBSHVKZ0RHbjJ4N2E0OGpEYSszVGgxbkFqT0EwZWxjZVk0R2prNnZnOTh0QTY3dWJPZW16Y3dKY2VpS0N6Y2lUWUR5WEJrT0RzcVNQajQzTnRmTDg2U09TTDhUbkc2NFU0ZjB3UlZvRUIvRXhNL3BWamdTSjk1ODUzMisveS8vSy82V2pOVzlGaXhjb1hYd0pxeFhqaHlIRGN3cE5jNXgzZ2RUMy96K3dVeWVDd2VVNDI4NHYvQjY5SHJ4ZWtESkhvTS9vYjNTcStSOGw1K3A4QkhBMUR0V256cnUrWG5QSmRBSmlhZDNvRTIxdlQrdmZKY0dQOGZpYXZrdDFBWncyc3FJTlRSYUd6TlNwZzVlVVpTODZScUNJeWpDak9XWTlKM0haNVY2Ymc2Y0JnMjh0L2tvMWsyS093Rkx1YUN4aExnZUdJc2UyaDZoYU5DVWRCZ003WFB2bk5ISFdUOExpdDN4QjBLeUpCSi91YjVlQTA0Y1Y0RG4vTjRoSEhhTUVOVFFjTFBpOXFpMTFaTmVoV3dST1B4OUpqUmNNQjFLT1BXNFpNbGVSMVNSTG9VRnlDUnRXam5yM3p6KyszUHBQcWdBTjFSZmhyMXArVDVQUDlHK1YwZWc5OW56YlV1Q2kveFhyVGdWK1YyWEFJZmN4T0pCZExzWVV6cklhV3VEdnNZT2pZQ0hJYU5mT2Q4Sk11R2hyM0F4VkxRR01yYzc2MDBDVEg5ZUVCenA4d2R0Zklub3BQbWR3UWdwYk5sbFBPYnNBVEdIVFFmK2QyK2NzMitmSXVZdElrVFV5aUhPM1ZKKzdYeE03MCszZDByMGZPcjc3NllEZ2JaQmhEVU1CWGZWODJWaWVFa2RUQUZaQUNTV3JrUnVPaHZlWitLS2hXVWprdnllUzRKeDhXOXpmL0RYbUNQUkFXbVQxbTVWdCtPN0RQNGtNQ1J2U2kyN2F4Qk5qenNDUzR1QVkwcFpZSXh0bDJjZjFBcnVFdEZwc2RaSzE5QklRY2NQUW9CZDlMSTZJUXErSDIrQjVqd3VmNU9UZ2RJa1RQaTcva2JuQldLU1Y4eVk0U05DQnh6Z0lIbjRnNGNCNW50NFBkZ2hKaVVGd05RbE85OSt6c0ZGSGtmOWZYUVk3RGV2TDhLVzh4dFBGYkRBdStkd25aeFh4UW82dWllT2xUdXJIQ2hLbXA0ZmZGMWRiMlc4bHlHanRzQ2gyRWozMW52MmJKQllrOXdjU2xvekFFT0RsUVVCeVU5U2oxQXphZ1REM1ZYS0pWQ2NueWYwMEZSQUVDNGkwVGRrQVA3K0R0di9pM2NWRW1NZHhJYW0rZFhQa0dzRGxIb28wL2RxRU1MdHN2WEFHanNVMFJxQXlaTGUvV0JYaUJUZXFNSUVuaHU5cFNTY25uc1VsVjEvMTRCNHhMZTY4a1pxbUdEdjJNZm84YlZPU3I4WEkraHZLR3VrdG14Ni9Zc1NzZFZnTU9xUnY0YnUwZkxCb3E5d2NXdFlLTkw0WWdobGQ3R1Z4ekd6ZWoyU3h5WDJvUno4UFB4aVR5QnBrUXpscG9LTElBZC9sNVFBOHgwU2Z1MjNEV0lQVUdVMThOSE9ma3BPVHhQaEVHYWNCcVBRV2hIVlVibHVRSzREdVVSeFR3VTlwZkNSYW8ya3ZLaHg5RHJuQXNjWjRHT1RRQkg5aUxZemcwWjJhQ1FDUnB6MUkydXBOR3V1OHVZUU1waHJGd081UUZjNHBoNHJMYXFKT1FLS0NteHZxTUdkcFJIWU52V0dnQi9oTTFRdjZoV0FRaUFBZmFLRW50VktZVEZmaTU5b1RUQlp3bW5QUDlHcTFvQkhkbzcvSng5RVpPREFSSENib1RVRkk1UmlBOVRXVFZLbVlDbEhuRFhsVWc2QnpqT0FCMnJBNGRoSS85TjNib1pMcllER25OZ282K2JaNFFPRHVFeWt5UDBudURBUm1VZzFuOUoySUxIbFdvaFI4RG5FV1NrWnRqMnR3WkFnVXAwQlJ4U29nUUJkYThVZ2ExeWlIN3QvWis3VTBGVUF5N1BvWkpmbm92OW85OUJRZU5uNnJuQ1kvTDhoUFJLRDQ2bWQ0a3FkZ1RVVTRERDBMRUI0TWgyZkxaalEwYTI4ckJsUldNSmJQU1ZFejdSYUt1cE5Ja2xrekc1OEZMRGtmQ1kzSEVhTUk1dktxdU52VkxxanE3S0pWcGJnU0gvbzM1ZVFRNC9adzlHcFdPb0dkaWNhL3lvU3NlcXdHSFl5SDlEdDJTR2krMkN4aExZbUFJY3FsaFJLYXhDSEhTbFhNTUpBQzFxWk9VOGpPTWJ1VGoxKzl3MmptdE01YXBLTWw3TGVMd0N5OS8rem1OMUl5U01xdGVMVkE3Mi9KTGhiZ1lPdzBhNm85NnpXYm5ZdHFJeEJodHpteVhGa0VwTTFrUGhvQnVsbEE3eUtJWXFSS1pZYk5obE8rY2FDRGFsZW1CZHpkY3VnUXhnV2FYYTVKaG8xazlYeFV6ZGtHeHVxL096UWNkcUNvZkRLUGx2NWxFaEl6dUVjU1RRdUFac2REWkxDdVd4U2hpbFU2UHlNR3hlZzB2MmdISTFyakdCbHpDSlNtZ1ZSaGthTHRqVmNiVHYyanM3ZEt3Q0hJYU4vRGZ5S0pDUjdlU1BDaGxEc0RHM0RmVFVoa2t4M2sweW5pUnFsSTZ4WmxBMnI4RmMxV01ORXhDWG5pREszV2h5Uk9wY2pibXdjUXZnMkRwMDNBdzRzdjlSMi9ZQUk5dTVueDAwNXFnYVl3Mlgra3BqNnhiamNVUzdCb2VwUVpTN2V4b3lMZ1dGdFV5dDkvbWNoblNheXdQY3pKMmxZdWhZRVRnTUc4ZUZHUVBHL2tGanJxb3hCaGhka05IYmg2TUJEU1hXcVpKQUJ6azVIb1lNUThaVzFDeTEwa2Q5MDlSajllNEFOTmpYWTdCaDRMaXl3bUZsSXg4TURCajVDc1hXSUdNS2FBd2xnTVp4OCtvLzBOZDBxYzdiNE82UXBEc2w5cW1aa3c1MlFpdHJWeFRZdkFhWDdBSGE3Z01YS0c4bGxOS1UzUEl6OW5ROU8rVVM0RGh6YU9VaTREQnM1TCtCVmpEeUFXSkxrREVsZkRLVytBazhJQ01qSVpjK0E2RVZkR3k2VktzWjNBbHFTQnJxQlEyVkFCWStkODhNQTBFR0ZMTDNobjVPY3FqMnVhWWNkM1VSN1lLTlB1RFlpc3F4UmVnd2NHemdUVEJnNUFQQlVTR2pCbzIyWDBiVEFqb09vaUpSanV4Lzd2QTRpT25jeUFFczROQmQzeE96SnByUUNiOUhMZ2JWS0gxeXRjMXJrTFVIeUJjQ0xzcmd0a1o1cStlM1RDMS92UlE0NWtMSEplSHRRd0NIMVkxdFcyYVM1OXAwdnlmYkNtUjBoVTgwY0EyZ0FDN0luK0FqNFEvRnEyTk9oUkxuZERqek1TYm5FZXZXVEJNbGhQSjQvSjNCd21DeDVUM1FUaFN1WUdNdDROZ1NkQndlT0xML3FiUFpOZUJpRERKdTRWQ3o0V0ZMY0RFVk1zWm1vTlJOdWdBRjR0WngwaW9xQmNvR2Q0UjhUbnliRUF0ZlU5NEtkS0IrS0tGT3c2MW9YeTRROGFqM2ZLZHFHMThEZ1RRZGJJdnFGenFKanZYZG1BSWRhNWJKSGtIbFdBUWNobzFqd3NVVXdMaUU1dmNNSkJuLzN4ekFHS28rcVhNMk9FeUpWeU12bzJ3QUMrcldDSGgwaFVXR0FJSjVLZnc5TU9LcUV6djZ2WUFPWWNQNE5aVW90ZHFCc2JmN2trYlhVRHR1QVJ4YmdZNVZnU1A3bnptYVhSc3Vwb1JLb3VQam91SmlySWNVYlFFKzltNXp3V0pKcVd2bm9MWFhYeWgzZUtnWFZJNmdXTFNqNUgvMHRjbUhOK1BGc3gySXpXdXdkQThRQml3aGxqQ2ZCV1VQQllTZjY5d2JnNDZsNEdIZ3NMcHhPTENZQWhoZGQvVFJvWEdoeFhyMElZa3gyNGtmRlN5bWdzWlFWVXJzbmNHQmlzb1JlMlZ3Vitld2lCMzRXU0NPQVhGQWg1S2JWYlhDNTRBSFNpRFFNUlplbVFvZ0djQ3hCWlZqdHNKaGRXTWZVTEVFTXZwQ0IvRkNVYVdEN2dTNE1PT0ZLQ2RXVDA0OEtvQmNBeURXQW8yeFhodGRyY2pWZTREcHJneGN5M1lFTnE5QjFoNUE0Vk11RTJlZDVxbDBKWkpPZ1kreEFXOW5VRGxXQVk3c2YySkxsZzBXU3lCakxFK2hkbksxOHlyT3Fwazd3TWV4V1FPMjI4REdXTStOR2pqYWFhLzM3NVc3T2tuTmR2cDIrbWZjQTVTSXEra2RNTjZWU05vRkhuMkREa3M1ZXRQUFJtZmlYT2pZdThveEN6Z01HOXNFaTBzZ1kwcENaSitqaXprQkpkR0tqTy9RTEdkSmpQTVdha0I1M2M5K1pYSFcrZFpWalNXd1VjODlVWGlGdURacnBlNmgyVTdBNWpXNHhSNUExV0RmdHpkU29VZEhYeFhMMkRnQTNaanBjYnFnNHhiQWtRa2RCbzRkS1JaclFzYmM2b3MreDgyRlEvd2ZvMVJTamFEcW1RTzNCbzhoQjh4cjVqQVp5MFhaQ29ETWdZd3BlUnZrWjBqSktKMFZtekhjRVRZQURYNm13OWVPM283K3JIdUFNdSt1aG5mY1lBSGgranBDU0QxZmlPb3R6aHhLMEhWZXhzVDdxZEJ4R3VBNHVycVJEUTIzaEl5bHdOR25idUNjeU9wV0hmdlViTzYxbmZxWUE2N3ZOblJvbEx2NUwzNXMwbXZkTTJqRW1TY1JJaVFiUjFXRCtEV1NjdlpoYi9NYVpPK0I5b3g0NWRVU2FxUWlTLzA3Vk5GVituZzBlUjdSdU5hQUMzNEhsVEJXK2ZYbGM5d0NPTEtnWXpGd1pBUENtZURpR3FBeEJ6WWljTlRPN003Z0x1Nk1xL2tEUzVLcnBqcjJxUTQ0SnJOeWg2R0dWOXh4S1BtMWhxUmJndElsZ0RFRUd3S3IrSDl5UUpKOXo1d1QrZzZnZHVqUUpFUC84dzgvV2I3bSsvVGpRTFhLUHZCdFhvT3NQY0Ixd3BtQjhUWFZLbEk5OUR0Y04xd3piYXQwcVlXTllrZ0ZERDFxU25kVDFOVndMblpCaDRGang4Q1JEUWhiZzR3bHNDSGc0Q0lweVZQVXEwdGliR0tjMEh2czFOY1YyNXlTWERYSDJZODUzUm8yOU5yak1MSVlqeDE3blhOZXh6WEJZdWovN2ZxZk9RUTVNSFZZZnZ5ZE53dEljSmp5ZGQza2k0NmgrbDJiMStEc2UwRGxzVVBHTlVSNEJlVVFGWVJ3cE5SQ0d1UHB6Tkg4SW93elUyR1ZxY21qYS91V1RTb2NlNEtOYkRqWU9tZ3NWVGY2RkE3ZFJhdFRaYm13UW1pbEw2bXF6MUhHeCtYT3ZFdDVtR3IxYXl6RHlUNzZ3L1l1cEd0WTB4VHdXQk9RMWdDTXNjUlFWQXNPdm5oNDBtV1IwdGZzdzl6bU5kajZIcGpUQUU5R0U3M3l0Ny82c0NpRndFVjlOa2FGdzhDeEUrRElob0k5UWNZbHNOR1h3eEh2cHJtWVNzMTZwUjdVRHIxVkd6cVNyUFR6RWdZSWlWaHpTOUM2UnE3enVnQU81U3dBU2VYMWF0cnBoRGtLUTgrMXBrS3lGSzc2OGphSzB0R1VMUk9ENW45SDZuV3ZqWHlIWmp2R0dzVEJocWdhVWxQNXZuS2twSENvaXE4cmgrT1dDc2V0Vlk3WkNvY0JZNytnc1RTVU1oUUt3TmtWMkdnU0wzSGV5UGRjY0FJUFZVVFFKRXpTb2lZMVJwVkJVcVBDQUR4R0Y1alV6cjJ2REswdS80eXZwZFRXTjNrY3ZDN3UrT21zS2ZqZzlZMDlkdzAxeWc5WlVxZS8xSWJLOFBvYWUrbi9KOUdOd3hFcG1DeDY1MnZrT3kzYmZ0ZUE2Nmx0ajk0b3FIeEU0WWpuUWwvdWhvRmpJN0NSclRyc1djMVlTOTBZeWoySWVRTjNKcEhxcmpyMGQwRFNCenFJYzZvOHJOQy9rcXdhWjhqM1NiUlMwNTJvUGlnUnNrOGg2U3ROdStOMG05Y2o2T0J1bnlSS0RUSkRFbzNQM2ZXWVVuWDRHNkFGK2ZTcjc3NzRHRmhDY3RnMXdHT3M1citHRFhVUXhjcGRWak5LZmtwODJ1WTE4QjdvSG1Bb2FNY0FDeFJUNEoxcks5NmtSSld6UGxlemdlT1dLc2RtZ1NNYkJvNEdHcGZDeHBqS01aUkQwT1h3Y2VicVlvbTZ3RjAyQ2dIT0gyVkRXZDExMXJkZ1pDeHMwL1hjVHdCSEkzRnFZQm5QQzJnd2lwM1hSZUpYVElaOTRuR2FaTmtXbWlyRnBrOFZ1UlpvRElWU05CdEZWU21zdGZKdWJGNEQ3NEZwZTRDUXJDcFNGQ2FKNTFOZisvTytaUEsrOC9mMHdHSEkyQzlvekFXT3VSVVdZNG1MZGU2R1ZBUXMxclBqdkZFWlZFV0JVK1N1b1lRM21oQUlud01udXRCckVJaGRNN3VNdXc4ZEZyckw1M200MDFlMUJzbVVuM3I0dWZLYWdCRitGbVhTcU5qd2M4Q0UvNFdQcXVEcFNrYTlKSWwyaXNWMTVqVURHTVNQQlZDcVNvbm1jZktHRGNQR3ZEM3czTnRmTDJkRmFRVVFydldZYjlhWHU3VlY0TGlWeXZHQmJIVWpXMjA0c3FJeEZ6YVc5b29ZY3BaOTBGRzMwWmJ6SnpTaERHOCtraUdPVEVuZE93QUFqR2lnVWtrc2JTNzZHQ1lCU0VyY0ZFalF6L25ZaEZDQUNUVytraFBtZS9GUTRYbFFYZ0NmdGh1bkhrL1doSC8wMm5sOUpHUUtWR0lqb0tqRUxHbU5QQVlaWGNvRy80UFcwdVkxOEI1WVp3OEE4MXpmZk03MVgyNWlCZ2E3R1RobUFvZEJZNytnTVJVNExtbFFOU2IvRHlZd05nbWFPR255SDNEeWZSYzZGN2J1MWdsL3hJdStCWUttTGJjYTlTaEhRY3BHVkRXQW02bVo1K29XS0RsVnlneXZJVDZlK2xjQUtxWDdZQU04Z2l6K2pud1JoWVg2d2tGekxZSk12YjVTaHJRbU5xK0I5OEI2ZTZEMDJWZ0FISFBWaldzckhMZFFPVzRLSE5sS3c1a1VqV3ZCeGxMZzZNcmxBQVlVWHBIRGpxVmxRNmFLQ3BXZkNWcjRHSk1oTmZVVTQvRUoyZERZQ29WRDFSbER6NE82UVl0dmZoK0FVTHR2SG92WHpmTjNQUWEvcTVDUlFJam5KbXpVRG9XcTVOZ2w0Tkdsbk5TNU1xeVYxdGdEMkF3YmhvMTE5Z0RYRkdYbFF3MFBhL0F3Y053QU9MSUI0S3lnTVFZYmE3VGdIa3R3ckt0V2FtVkRBOEpvRC96TTk5NmFmZEhqUUFFTlRHRVNEZ0djZTkxalF1cklHc2JyNWVPUUlrTzRwbFRUTkVDa3NBdlFRUWhIaWFZeC8yUUtlSFJCeGhPdzBYUTFqTGtwQWlFN0hFT0g5OERsZTREck80WjArL0syeW5WSmN2cEk3NDFUS3h4cndFWTJBSndaTkxZQUcxZ3N5MVRwYUF4REtIbVVxcFM1Rjd6eU9RUXRHRjAxdDNDWWtqc1JYNWVhak1Xd0MxWVNVdXM4bEE3bFk4enE2aG1YdStidkFkdXgxNER6QzVoWGxadVN5N3VTMkl2Nitwc2ZMbWZqa25MWVd3SEh0YUhqS3NDUkRRQm5CNDBoMkZnS0dsT0JZNnhFVXc0eEpvdHl3YUpJa05zdzU0TGZDbHlNR1ltdlhUWDhxQ01jV3FnZi9DOEFpY0pDYmM1SFJ4Vk9uOVVLa3RZVzJIRmpyL3g5WUR2MkdxQzJ0b25zallMTDk3a09Td1hiYjM3NFRpNkhnYU1DRG9QRy9rRGpGckF4VnJvNXAvR1dFajgxb2ZSTVpab2NRc0FJL3pkaEZ2SkJ5R0hSeklVNDcyVU1NbXJZSUZ4RDJBYzFoZWR4dFVyKysyMDcvaHFRSDhaMXpJMEVDbXdwd1cvNkJTMXRaMzRhaGNPcXhyNUFvdzgyMWxZMXBwWm8xckJSVjAvVVphVUtzWERCRm1mYzBUZmlpQVlNVUtHRHdxTThFMVhDeE9aQ1EvMUY2dHlZN1AvSjVqVTQ0eDRnTkF4bzhEa2w4cVVhclVuZUhtdHB2aFhndUNaMGZPRFNjRXAyV09Qc29aTmJnZ2FtZHQ1cTREV1lheEJDQTlFaEFoSXFLNlhxQTlCUTExRk1sU0JuTlVJcjZqMkM0cVBrMGpyUDR3a0x2VVpZMjg4OC9IUUpWZEdvS1B0L3Nua05qcllIVkdhdXBHeXF3ZFRWbDV1RTJKOWpEZGc0TkhBWU5zNEZHbE9tbHNaRVVCd2N6b3hRU0pmMHI5YmhYSVR4VGwyS0JoY3JEblh0NnBHakdEa2Rxb1JocmRvMVZQNUxBSXk2R1JrVk1NQ0cxelgvZmJRZGR3MVU4bDZQTTVqUzF0ekFNUk00c2xXSE02c2FVM0kwNW94Q245STl0QTZkS0R5aWR1UUNDYjZHOEZFL1NJcFVXQVF3b1VJRDU2blFpWHRDakI5cXJDSHJ5OTFTMjc4ak5EbUxGdHUvcTlYNjNFUmNtOWZBZTJBbWVQVEF4dFNHWDNQTy9sdjZ3RTBvSE5rZ2NFYlFtRkxlZWlsZ1RDMXo3V3JrVmVjUEVBNGdEME4zMkRpOTJOQ0x6NWVVd2RvZXF4MENqeTREK3FRYzJid0czZ1BYMlFOeHNHVGZMSldwODFNTUhEM0FrUTBEWndDTnVUTlBCQXBqVXdybndNV1VVdGV1aEZBbFJ4RU9BRGdBRCs3UXo1NlRzYVpwdGd0Z3A4VFMyR3BkTGN6VmlkWG1OZkFldU00ZVVPVlhIRi9BNTN4LzZpajZ1Zjdoa0FySG1XQmphekF4dGFTMXEzS2sxSG1UVTlIRThsV0tkY2xZOUtubHJtM2VocDY3YVRXdVBBVGJPbXVBYWtUMk8rRXBOVkNMblV6VjArUk01Y1UycjBIV0hrQ3BWWVVkWHhOU1ZvT3ZLYTNNVHc4Y1hlcEdOaFJzRlRRdUFZZ2xrREVVUG9taERqWS96bDh5WDE4WjYxTFE2R3JvRlNzbFlxbXJjeld1YzlBSktCUmlrUUY1ZHNCMndONEQxOWtENUozUmE0UFBBWHQxRTZZVWxnblZxTHByelUzSlZqaXdtd1BIMlVIajJsQ3hwTUtrRHpqYU51SVBYaTdPSHRvV2VFUlFtRHZ5dkd1T2h4UU5Tc0o0TGlVekFocmNmV3ZjKzVJT29yWnBhMERZaE9GMG1pV2pPUzFlUCs4aDc0SHI3WUdZdXhGbklNWGNqV3ZBaG9IalFLQnhhN0NZQXhoanNGRXJFZ1VHN3Q4cnhLMnBwR05Ed1NKSVFPbEZwYWg2YkhTMjBRNHR5cm5iSGhwbVpsdC9EWDdoeDE4bzd4ZWdnV21BbmMxcjREMnc3blhHK2FielZOMUR4NUpFaDg1K0EwZVZ2M0ZreU1nQ2pDV1FNUVliUStQZzFTOGo5c3FJZGVOZHc3KzRVNDRYMXVETWpwQzNvZGtkT0Q0NlovckF1NDNUby9TWUpGME15ZGRyYjlqd3RYZjkvaHVvdTdHcDE3Vmg0NUFLeHhGZ1kydUFjUWxrVElXTkdqanFIQXZDSHR6OXFnOUdiSlg5UkI1R280NUlPb3pmdjlONHFvRU5IcHV3Q2JEaDNobzV6bzZaS1V5cmpKTm9iVjRENzRITDkwQjlwcEczVWZyZGhPVFFLUlVwQm82VmdTT0wxTFlJR0d0QVJoOXNqQ1Y2eGxCSkhGbGVXb2wvOUlkM2NpNzRXQ1ljTnMyam9IYzVNRTB5alUyOTlIVnNOcVVFVVkxbzlpR1g0K2dJWi8zaUw1MWovb3pOYTNDTFBjQk5tb1lwVGcyampNR0dGWTRLT05ZRWkxdFlObGhjQ3pTNnlsL0hLa3U2cWtpa1RnZ2FLS3VrbkF0UzU0SkN6UUFXVlBGQVpRbGhHRjEwSklIeU5YL0Q3Nm42UkNXWlNsQjFDV3l1RTBEaTFWd0htOWZBZTJDZFBhQWJMODYvc1pibFUyQmpUOEN4ZGxpbFUrRzRGV1JjNHN5akErNTYwOGRzeTVBeEZ6UnEyT2pLeXhCMG9FakVjdFhZQVJSVDB1ZUh2dkgybmUvWGxTWmNmRHhHSEMzdmNFcStrM3Y2ei80by9UWFl2QVo3M2dPVXZhTFVjbDV5enBVaytpOStiTFJsK2JYVmpTUGtjVHdCSE5jQWpiVWRlZDE3WW9nMnJ3VWljL010NXJZWUh5dGRyY3RZbFZOUkozbTJVMTJiNFY5SzdLU2s4dEo0UHgxRkFSUXVUaElXblQrUWYxZ3l0QzM3TmRpOEJudmZBd29OeC9ESkdyQmg0T2dCanV3d3g1aEQxd2FBUXVzTnNiYmFzQ1pNVEVuNlJKM1E2UGU2YjhaUUF5N3lOSkRVNjZUT1dMYXFrZVVvRm12ZURWTXA0ZnlCL0lQUzVqWHdIbGkrQjNUdXhyNGFYU01pTHJsaHRjSlJBY2VXMUl3dXE1MXozVTJ6YjBOY0FnUkt3T3pxMExrVU1QbzZlNnFjVmUzSiszcG85SVZPZ0F2cXhxVnNJQWtxTjROOERKU0lhOXdKcXdPZnpXdmdQZUE5c0ljOXdFMFh1V2VjalZQblVWMnFqaHM0QW5CY0FocExReFZqUUJCL3hoMjdOa1ZiaGFGSm1ZM1RqUURTNStEbmdFRWJzbWdBb0piWkxyRytvV2psLzNqK2pTYzYyc1UrR0RGODB0VVBnekFITVVnK2NuRXhWTWhUV3ZNUE9adlh3SHRnSXhOZm0vTzE3c0k4NUNzTUhDdm1jRXhSSGtScCt0blFHekdVb3pEay9JZkNEWEZtQjNmc2NST3BoS2tPUmZTcENiRlIxbEQ0b2poMzhpQysrTEhlTnVGNm5GSzlNZko0QllxZS9Vb3IzN1hUVmtNSFQxV0FGUEFJWVJJQlZ2dTlKaG1VdGRBZ0ljQUNKZU9aNzcyVmZtSGJ2QWJlQTk0RDJYdUFNMUgrSWJZa253TWJCbzZWazBhek40WE5hK0E5NEQzZ1BlQTljTWtlb1BRZlpUZjJFb3BqNVB0Z3c4Qng0eW9WS3h4V09IelkrN0QzSHZBZU9Ob2VzTUt4d2JKWTUzQTRoeVA3WUxCNURid0h2QWV1dlFlY3c3RUI0SENWaXF0VWxsN0FybEt4a3pBb2VBL3NhUSs0U21VRHdPRStITzdETWZmQ2RSK08vTVBUNWpYd0hyaGM5WEFmRG5jYWRhZFJkeHIxWVRyaHdIU25VZThUUThmbGU4Q2RSajFMNVltZUhaNmw0bGtxUGx5ZmxJaTlKb1lPNzRIbGU4Q3pWRFl3dk0zVFluTUd1WGxhckozSDFJUFMwMks5Vnd3YTYrOEJUNHU5TW5CTWdZNUxRV1ROYVhiWG1PV3lobDFyZnN1Y3JxVnR5L09ta1JqTndJaFRscnIwcG1FWURjajRYRjFLdWNqNHZWakRydEgyYlUxNzgvdjZHWS9KMUZoSmtiYmJyd0dEOUR6UHhudlAxOTU2ZTRDeEVHb1dwaTdXc2RQMDBoSDFaNXdVdXpwd2JNVzJDQ0RYR0JvM0JoenFZS3FKc1Z3OFFBUVd1NXJlR1dIZmRDNzk3SXZ2UG9ZS2ZhLzVmbW1lVXcySDQ3RVpCZ2U0ZUVSOWpzTURFSUU5VCt3MWNCZzQxdDBEOVpuMkd4LzVRYm5SNG54ZENoMEdqZ280OWc0ZGZlQ3hCUUM1Rm5UVUxkYlZCcDdwc0xIRFh1eTAxelVBRHFXaXFCZ2Q0KzdqTEpldWliUkFCMHJJVjk5OTBRZmZqWndmRlVKMFZjUTRITDMyaGc1RHgvWDJRRkU4N3Q4cklVek8yajdvTUhCTVVEaCs5d2NmT1JSMFRJR1BUQUJaQ3pwaUZVdVp5OUtvRkFLSHZvbXpYWk5uVVVJMERLOExTdTVBUjRBTmxCTmtmUjkydDNONFRBWG0vUUx5TUQ3MytoczR2QWZXdjg0NDMrS04ySlR3eXRyUWNiaVF5bEdCWXlwNFpJUElVdWpRWUxzQ0cwMllBd2NrMk9nYk9OZGxmVkJTZ3dtUERlWEhuQkRBZ3lGeW4zcjR1WEtob3E0UWJ2RUJ1TDRUZk9iTmR4NTk2RHUvWHhRcEdlKzUxOXJBNFQxd3ZUMmdVSE84QWRPRWNvSEhGT2d3Y0hRQXh4R2hZd2w0Wk1ESVhPQVFFQ2hIUTNEUUJ4Wjl5YWUxWWpJRUgwL2tmelJUZkpWSSt1Y2YvQjNuZFZ6aDBQdnBML3k0Zk9TOUpvd2xBenJzYkF3YzNnUFgyUU9FTE5WUm1ac3A4am40L09QdnZGa1VaUlRHYTBMSDRSU09Pby9qeU5DeEZuamNBbERHeW1XbGNPQndGT2FvWVdNSU1KYUFSeDFtaWZrY1ZMUHdXcDcrNXZkOStLMTQ0SDNpdlMrVlNaaW9TQUFHVU1kYVkzek85emdJQlNRMnI0SDN3SFVId25IZEFmNThYU3I1UWpMcEdIUnNHVGpXaG8xZTRPaFNPWTRNSGJjR2owdWhwQVlQTmpjT1A0WkxZbE8wS1JVdVM2Q2pDemhRTno3LzhKTWxnUkhhSjZmajE5Ny9PUjk2SzRaUnVLc0NNRkNSb2drOE9QejRQVHNhdzRiM3dQWDJ3TS8reXNQeU1ZNjk1M08rM3dVY1hkQmg0QmdBanFORHg1YkFZeXFFRElWWjV0b2M2SWk1SEcxSXBlbjFBV2dBSE55TmMwR1N3L0hVdDc3YlhxaDh6cDJCRDhQNWg1eENLSUJkbHdFZVQzMzdPMTVidzRiM3dMVW56WVpjanBpVVA3ZEhoeFdPQWVBd2VHd1hRTWJ5UEM2Qmp4bzRCQnBJaUVqNHNUa1llUnRja0tnYmxHeHlnUkplSVF5Z1BpRE83Wmgyc0xHR0NwRUlOTlIwclRaQkI5REgrcUtFT0dIWGp0ZndkZDA5SU9Db0ZlWjRuaG80Rm9SVURCMzdnSSsxZW56MDlmbUkvVDFLemtoSHZ3NDFDa1BhQjBSaVoxT1YwT3BPWEVxSTdXL1hnTENVOGw5WXEzWU5ZK00xZlI3dC9yMVNJY1FRTjYrcjk1U3ZxZXYzNDRoOWpZYkMybXRBeDU3ek53YUJ3OUJ4VHZBWVNrekZCQnZxWGxyMzZyaGp0RUZ2K25YRXNBdXFoM0lPeU8vQW9jWnl6clBuZkJDU3dnQXlsSTBXTkdMWDF5NXJvRU1xQjlBQkZENzM5dGZUL3llYjErQm9lMENKb3JRRzRDTTNWMXgvcUl1QUNOZXVjdXZxbkE0RHgwS1Y0eXdoRm9OSGQ1T3hzZkxacm9aaHNab2xsdEFxMUVMK0IrRUFoV09PYmlTWjBSMlU4SWRVQ1NXQ3RpR1UwSXErejJJZVRmYi9aUE1hbkhFUGtKdEdpU3lmVTAxV2NqMmFaUG8xb09PUUNzY1M0RGdMZE93QlBDNXBLalpGOGVqTDdaaFNQbHYzN0NBdmdZdVQwQXl3Y2FaeXpnSlZQL3BhK2I5UmVBQXRFbW8xTEtxdnRYeFh4OWNhT2pqZ0NNa3dXNFhuVVVhOXpXdmdQWEM5UFVDUERxNWpicDRBajNMVDBIUWw1V3pjQTNCY0N6WkdnV01KZEJnOHpnRWRmVlV0WGVBUnExbmFkdWpWL0pVbEhVbkpjOWpENFFsVVBQMW5mOVNaRUtxSnZQd3ZoRkVBcnhpS0dvS053UmszRDE1dTExWWc1MUxaL0wxZ08zWlRzREo1dXpuZnBEUnlIWlpTZHZva2hYeU9KWDA1REJ3R2o5MUJ4eVhnTWJlTU5yWkJwMHFGTUlrYVZpbUVnaTBwaytVT2dndFozZjYyQkNFb0N2RjE4VDhDRkt4Qm5PaktoTmV1L0l6QjNKZ091Nk1nUFhpNVBHNzJHdGk4QmtmZUE1eGY1RytvdzIrNWxybUo2cmdSS0RjU1RVT3dTeHFCSFJZNExsVTVySGprQThjV29FTWx0REVISWVZYXlDa2ovei96dmJjVzNWVm9qb2dHbDMzb0cyOFh4WVNQOFhmWHJOcFFCY25Rc0RyQlZld0VxbmtuVkpJb0diU2V5VEEwWkMvMlFPa0REMEVIZDFZY2drcHF3Mzd4bHh4YXlYWlV0bU9zQWRkM3ZGbUlsU3BQaEpSUkcwZENLbE9nWTgrd2NUUGdjS2hsdTlDeEJEeW1OZ3pyYXhRbXA0Z3oxTHdWN2hhNCs0OE53b1pNNFFIaXBYTG9Da2Z3bUxyRFo3aVovb2JIQjBTSXN3SW53TXBZemdoQVJOVU12NCt6VmdVTmo2VUUxNjdINEhmNXVVcm5NSjRiMkdqN2FYUW9HVU9nTVJVK1lxNE16OE5hYVkzNW43TVBhcHZYNEFoN2dHdUttNW9hTnZyR1Nhd3hYOFhBWWZqWVJYTHBtdEN4QkRqR3VwTUNDMVJxRENrR09IYXBGRGg2Slp6V3piRFUzcHVmQ1R4S2lWcFQrYUZ3RHRBd2RxZ0lnUGdiUHZJNll5dnhHQjZLNFJLY1BOQVIyeDZYbkl5WFhpdTVLaVY3bmY5OUlXZ01nVWU5dmlxVDFacll2QWJlQSt2dEFjNml2dEVTY3hxQUdUZ2FoV05ObGVPTXFzY2VnR01xZEV4dGlUNVVzVktIVnBROGloUEdhYXVpZ284a1hLb25CYzVlZVJ0OHIwM1FxbnBSY0JmUDVNWUNJRldUTEp3djRReUZYMVI2eS9maUljTHpBRCthWGRJQ1RkVmtLNWIxOHZvb2hlTmplVzBobnR1bFJHaE4rc3FNcDlqUUJGLytCMWVuR0M0TUYrdnVBVzVpNHMxTzdMY3hCemlXUU1lZXd5bWpJWlZyaEZYT0RCOTdnSTR4OEpqYWpYUUlOaFJHVWNoQkNvZmFwT01vY2ZUY2tRTU9wV29qdE8wR0tPSWNnN0ZTMFdoU091citIekhYNFJkKy9JVUNJQUlISllOMTVhSHdjNEJFcmNSNWZVUHgzRDdBR0t2NG1RTWZQQyt2bWNvWUZCcXBRbDE5VGM1VWhtenpHcXl4QjJpa3gxbWhtNW0rV1NvR2pndUE0MWJRY1FiNDJEcHdERUhIRUhEME9iK3VQaHc0ZkhVWFJVa0FJalNZRElsU1k1L2w2TnUrRksrODJvWWpWTi9lRjVhb24vdUpQaUNoTEJlNDRIbFJCT1NnQVFoZVl4ZllLREdUbi9ONmxBQmF0em51QW93NWszcm5Ra2lYMHFIdWh3QUlIL2xmVlNGa0oyd243RDB3ZlE4UU1sVllOVGJsaTRtalU5dWJiMG5odUpWZjN5eHduQUUrOWdZZFEvTldvck9MVlNseXdsSUV1UFBtWWlXWkUwZmRPdXNBQVlJS29LTW9HOEdKeTVHVzZvK0p1UTlEamNkNGZCNkx2QXJVRmFrdmdFZDg3ajZZNFhFVUNrSkJJQTlGdVJscmdzWWM4T2dDTGw2bkRzMll5K0x5V1FPSGdXUGFIdWpxcFJPcnZsQmpDZFdpR25KdHhSdU92c1RSdWVXeGh3ZU9yVUNINGVQMndGRkRSeDlzRk1mYkRESENzWEgzakZwd0J6YnUzeXRPdVZSdE5JbWl0U0p3QndydTN5dVBJUldqTHlReHBkdXBIRzk4TFFyUkFCdThMbFFXRHBTWXlEcjIzSFVWRHY4M2Z6K1VxYjYyellHTyt2L1hZYWxxSFRjR00zd1lQcGJ2QWE2bmNxNkY4Q3NmU3o1Wk9CZkdXcHpmR2podTZiOW5BOGNXb09QSUFMSmxsYU5MMVlnT2w0c0t3aDhMZDhqNTl5a1VSUjBnNmZMMUYzb25NRTUxd2xIaDRIVWhpVW9PVlRPdXRqTm5SeCtNc2VlZG1vOVJKNCtOMmFYZzBSZGE0Zjhtc1pYL0hWV243bFZpOHhwNER5emJBN0drWCtYNm5EVjhYK0dYb2k2aTBDNXNjMjdnMkFCMEhCVkF0Z1lkWGZrYUNxRW9IMEJ6UUdJWVlrbVNaUG03WjcvU21ZUTExd25yTlNKekFoenhEcVFydVhPSk1qRUhKaTZ4T2FBbDRLRDdxdG9zSzRTQ29rTnlySjJMQWNON1lIZ1BVQ2szZDQxVUhVYVlGWVdEWEsvNmJJeEtSeFp3M05vL1QxSTR0cTV5SEIxQXNvRkQwSUVqSzhwRVU0bFJLamFhWk1tWVhOblZBT2RXRHJ6TENkOVJWWnE3amxxRm1RSWFjMTdIM0VxZk5RR2svcDhCTE1JOTZoR2lLaUJWcmNST3BPVk83WS8vOUU3N2Radlg0TXg3WUVxZUU5Y1FvVWtVVS9MVFNEaFhWVjNwMDlHY09WSTRNTTVNcmxjRHg0R0E0MmdRa3FseWRDa2NNVnhSbW02TmROdWI2cmlYOWdRWlNtaU5lUmJLMzFCL2piRnl0a3RlNTlLR2FwZkFSMVIyNHYrcG1RL0FCa215cWxwUnUzYlVFRTN1NWM3TWVSMzVEcytXdHdaY0o1d1phcG9uRUtjdmtINkg2MGFWSzNYVkN1Y2lvVXRWM2NWazhyNWNqcUV6ZTgvcXhrVUt4OTZoWSs4UWNtdm82TXZoS0k2ODZhTlJsNFhkMm5GUEFZL29pTXZyYlE0SmxJQXByM1ZOd0xnR2dQU0ZsT3BjRmpVK3cyS0puKzdLa0pIVnh0M21OVGp6SG1qUGlGZGVMZW9GT1ZDeGZ4Qy9VN29LZHd4dTQxcFRsMklVRGM2Wk9weFNLeHdHanBOQXgxNUI1RmJBMFFVZE9ER1Z0M0kzTExLUEJKL2h2SWVjc1pKU2xidzFCa2EzaEl4YmdBZnFoYXBWMmk2cTRZNU03ZUUxV0MvQ2ljMXJjTFk5Z0pvUm0vNnArZ1RvSUl5aXIzc0h0MzM1MlZJRnhwbURVcUx6VXVHVURPREk4cWVURlk2ekFzZmVRT1FhME5Ibi9LTEtvVGtoTWFTeUpRZit4T3QrOWl1OW9KRU5HR3ZCeDVTRTBsakZVcmVibDlyQlduRjM1c0Z2K2M3UGRwczEwT2lFcm9aK1MwWVRTRlV0SFk2Yng5RlpNeFUyVGdjY2hvNTlnY2hhNEZFN3ZOcUo2VUpTMmFVa3crak1zeDMxMGV3YTBLRXdFN0NoS2J2cUJtdnpHcHh0RHhCU1ZMaXhMdU9mbXd5dmZEZkJTeGRzM0FvNE1tL1VWd0dPTXlzZFUyMHY4REVHSEZ3OFhIQktRcVJwRmhkUTNmZUNqelZveksyTTJZTnRIVHFHU29YN0dvTngyRkl5Nng0ZCtVN1BscmNHNURGcFVLVHlNNGFxMmFaY2kzMTVHMU5nWXczZ3lQYURxd0dIb1dNL01ESVhQR3AxWXl6MzRjaUFzVVZBdVRTUmxNTlZjMnRVdmFQWkszWjRkdnBuMkFOVWtxRG1VWEhDMTl4SXFSd1d0WS9xbEhoamRXbENmQVpzN0JJNHJISWNUeFdaQWg1eWJESEx1czhCWmp2MVBkbzF3YU92UjRkQ0tPUnBjQmRIc3lJTnN0TWQzdFFEbTh6OWJLZGg4eG9zM1FQQVJ0dVd2Q2tmQnpRSXFmQnpuWHVYSnBuM1hmOW5VRGRXQnc2ckhQdUhrQ25nc1JYSXVLUTZaKzNFMnEyQXlCVGcwUGg2VkF6dTNEU3dqb09WeEZENmI5VE53SWJVRGhvYjhmZjBHdkM0ZXp2OXZZQWYzWGJqMS9TbGlSMklaZXp0S2JDeEJEVG1uR09uQlE1RHgza2dKTk1CM3hJbzlnSWhTOEdqcXlHWUdxQlJxaGNyVUFBT3hhNzVuRHU5VDd6M3BmSTFVTUZzQ0VxZ2taM0wxTjduM3lqUVFvZFNsUkU2SEpQdlVHM2phNkIrR3FXUFJ1aVVQRFJQYVc1VjIxcG4zTjVoNDJyQVlhVWovNDI5ZGVobGppUE9Cb1dqUU1nYzZJanFCcEFCSUdpTU5yQ0IwZ0ZBUklVQ21WbEFvWU01RHFnQ0xHTERNSTI4ZDJXTG5mM1dZYWQwL1d3bVZ0ZXF4dGp3eGpWVWpWc0NSN1l2V2dVNERCMzViMTQyaEdRNzk2M2JWdUFqUWtlYzdLdTVEc3JuS01QdDN2KzVBZy9JemFnWUtCMmFBNkhtWUhVRHBIYmE3a3V2ZFlaak1DWGoyYndHV1hzQWhZN3dTSVRva3JOUlFjY2F3TEVWMkRCd2JNRHgyZ3dnWjRTUEtiMDVCQitFVURpY1VUM2lzTHU2ZFhNc3ArVWcvK3E3TDViRC9ibTN2MTdtVUpBcnd1YzhqanFYMnJ3R3Q5b0Q3TDJobndQVzJ1ZnMxZEo1RjNqdUdFSzVKbkRNUFQrT0FCc1hLeHhXT2ZMZlFDc2crVXJHVnVGalNwaWxyMlJXaWdXSHNKb2Z4UzZMdGJVRC9Kckp3WVJweVBUWG5hUXFYekNxWVRTKzIrWTEyTUllSUU4SjZDQ2ZxWVFKRytXT243R24rNUpHYjZGdUxBV09iSCtTQWh6TzU5aXZPUVJ6RFBoWVVrSTcxcWE1eTJMSDBqdHQwa09yZEZRUVRIZWZybXJKZDdhMnUyRS9jcE1BYmVVMXNWK3BZSW45aHk0QmpyUEN4aXJBWWVqSWZ4UDNDQi9PQWJrdGVNenQzVEVVZWhtQ2p4bzZJbmdvc1JUVmd6dEpwR3ppNmh6dTVINFlQdXo4bDhKUFRHWmV3NVF3emVlRUY5VU1EL0FZZ3c0RHh3YUF3MHBIUGpSc0hVQU1JVG5nc2JROWVoZDBkQTZFZS9CeUthY2xqRUpvNVRNUFAxM3VJbjFuYmNCWVl3OEFBV3VEQ3duVW1nL1Z3bkxJNnhnS3JWamR1REp3R0RyeUFlQ0k4SEYyQ05tcTJqR1VjRnJuYzJESzM2QjZaZTA3VWRzNTE0QnFLajdHM0tDMWpEQUt5YVNsZ2lWVXI0ejE1WmdDSGRjT3AyVDdnczBCaDVXTy9EZDl6L0J4UmdEWkEzVEVabUpBQmlFVGNqVDR5Q0hPSURpVWpVc2N3VFBmZXl2ZDBkbTJvV1lvSVJsVGw5dzFEQldPRUI5Sno2Z2M1SFhVNnNaUXVld1FjSndkTmxZRkRrTkgvcHQ1UnZnNEU0VGNDanFtSkpYMnpXZmhycEREV29PdnlNM1E5TmsxSEFJZEllVjBHTGhsQURnMkJBR3A5ZnNNYkdqbWllYWVrT0M1ZHVXVFFvQ0UvMHFsVnVqVFVScm9OZUZDN2ZsNlF2WWx3SEZFMkZnZE9Bd2QrVy9vVnUyVzhIRjBDTW1FampIZ0tDckgvWHV0c3NHY2xYSm8vL0dmRmxoWXd4RndSOHRqdXJmSE9VeHFScHRUMFlRNVl0TTVWVUN0K2J4QURPQmNQNjlBUTFVc2RXNkhnV09qd09Id1NqNEluQVUramdnaEdkQXhGRkpSV0lVOERWV2txQ3FGaGtvQXh5VXpWcFQvQWN4SVRoZlkwR1NNajI0dXRsOGpzWmlxcFJnK2s3b2hWYU51UGlmbmo4SkFaMXpsZGdDbG1QWkZmQTZNejNrdTlvOStoN3dOZmdZa28yN3dtRHoveDk5NXMveWUrdEh3V29BYnFSdXhSMGVkejdGRTRUaXF1bkVWNERCMDVMK3BlN0pNK0RnS2hOd1NPdm9TUmp2N2NEUUhNbmVCN2NqN3lnSE1OZlZGMEF3WDlmY0FQbUlJUjBhcExma2oyYzdVMXEwZ2tDTkJDZXB6di81ZWNmSnFFaWZnK1B6RFQ3YS96NTRhYWp3bjFVTXR5OWtUQWx4QVFudUhuN012dEgvNEdzaWdTeTRRSXJnUkxHczRJWDhQeEtqemJ0Mk5kQWc0REJzYkFnNHJIZm1PZnd1V0RSNTdoNUJyQVVmZEZsMmcwWFhvMStXd0hOYVg5dGRBdGNBaGNBZktSMVVQUk1NSnhJbTNTbERsemxTcUIzK3JXTC96UDdZRlFWMXpUZmdJS0FnMCtock9kWlZreDFBTGp3RjA4cjZ6QjJLcmZqMVBuQS8weEt5Z3B0S0svU1ZJQVVEVXhDNVdzK2gxQ2pybUFNZVJsWTJyQXNkUzZEQjQ1RytJclZnMmNPd1ZRcTZ0Y2dBYTNKR3FjWmRpNTl6OXRRNGpkQmZsb09aMytSdmFSL2M1SEJ3QkRaYUFBNkNBOEltU0FKRzBZd3kvMXprMHo5OU9BZzFUYkZYSk1GUXBjMG00eC9ia0dxaWgyNVMxQVFCTDc0dHFhbXRYWmNoUUF6b0JCOC9ObnRLTUZCNWJwYTZFWHNvQXQrcnhPOEhsd2N2bDc5akhncDg0OExCQWRmTVlTcHBlQWh4bmdJMnJBb2VoSS8vTlBZSmxROFplSWVSYXdDR0ZveHk2Z29BbVM1K0RuSU5kdVJ1U3BKR3BPYlJ4NkdUOEk1UHorM3dQd0ZBSm9uNGZaNENUVUt0cC9wNi93U21SQjFJN3BDNXBQZDZkOGhnYUtqZG1QQmVPeWwxUHAwRlVUQVlXSVBJZUE1bEs0aHhyOHNaNjgvN0dISTBoMEJqckI4TjdwN0JJM0JkQVp6MlFiVW9ETy9hUWxBeGVYM3hkWGErbFBOZU0vSTJ6d01abWdjTktSLzdHMktKbHc4VWVJV1F0Nk9qSzRhZ2xiS0FCMDlSTjVXNm9SQmJqME9kMzFEcWF1OFk3eWtoVDRZS2hja2dkNlhKRVhRNmlqdWZIQ2daZ0F2V2tUK1hnWjNwOWZJM2lvdHlUcWNCeVpDUC9wayt4S002OHlYZUl1VFhLbndFQ3VrSlpna3I5cmNJVWFyVFZCeGxyMjJBVE8vWlJLTC90eXRsWW1peDZKdGk0T25BWU92TGY0Q05hTmxEc0VVSXVoWTYrcE5Fb1l5dHZBMGVEMDhZSmNmY0xWQWd3b2lwU2x4cDJHYzVvYWd3L3dnZC9vL2tYT0VwZWc4SXFzWUVZUDZPdkJ6OUhhWW5KcUNndVBBYXZIVFdteThsMk5aMnFuZkxQL09ocjZiQXdaREdVQkJoRVFKU3BNa05oTVlGQ0xIMEdLSjdJcjJueUg1VFV5MlB3KzZ5NTFrVzVON3dYYlFucS9YdTlvREhXaW4rS2pjRUhleWdDdERybEZyVmo1aXdWdzhZTmdjUFFrZStnajJ6WklMRTNDRmtiT0tMc3JISDIzQVdpY09Ca2NDNm9GQnpjZko5RHUyM2MxQUJIWDh4K1N1eStUeHBYUEYxVkNRSUczV0hUQlJWSEp4Q0t2UlprcXBCQXFkSGZFRUtRbzFVSUNNV0VjSksrei8vTC82cXZlUTU5em1PcExGUE9Wb29Rb0lMVFJSVllrdFRLNndBRWdBWWVpOGZFc2F0Um12NFBYbzllcndibzZUSDRHOTRydlVaVUhuNkgvMGVxUmJzVzMvcHUrVG5QcGJ5Wk9zZmhqdHAwLzE1NUxvei9qMUFMMVNsQURLK0p2NC9LeGlXUXNhU1RicjIvVmVhdGhGYUJid1FPdzhiQmdNTWhsbnludmdmTGhvZTlRY2djNklnSGNoMUt3UUhoTkhGVUtrOXRGWXBYWGkyT1huZUdpb1hqY0hCK24zcjR1Y2VxUW9lQ01SU3pueU9MQzRaVTFxaDhBNXk2d2lmY21mT3p1c2RENnl6djMydDdNK2h1L3M2ZC9JT1h5LytrY0ZEc2dza2F4SjRrcW01UTBxMlNHcStsWGdBZnZEYWVTK0JYSEh2VFUwSldKL3p5dlFJWlZaSXUveStBd2VQUlBaYjNqOGNEbExxQVVlOEI2NlpRVmt6a1ZiNk8xb3Y5MGpVY2JTNVl6TEUrOEdqM0VXR2kxMTk0M0lzandOUlFrNjgrWldQSm1aQjl0dTRPT0F3ZCtXLzBtU3diR3ZZR0lHUFFFUTloM2ZYaExFcldmNk5RZERrYW5KVWNlWjFmZ1pQaElDZThvVmtWUTNNcWxzVGplU3llUzNra3ZCNWV0NXlxd2o3eGY0ai9SMWZKNzVEZFNWWnRRRVRoQVRsWk5TdUxKYnkzTWtJWWhFOEVIQzBZQldWSC8wUDh2N29TYzFXS1BMV1NwQWFROXIxNC9ZVW5Fam1IUUdNTndKalQ1QzdDYTFtWFp0MzBPZzBiR3dVT1EwZStJejZiWmNQQzNpQmtDRG80WU11ZDhPc3ZsQU5ZenFuRTU0UE0zS2M0MU1xRERtL3VvdHM3N05kZjZKM0V1U1F1WDZzeGRhaW1Md2wxcUhxaEJwRXVJS21yWkFRYzE1aHN1alJmZ3pCTDdIMHhCaHBkdVRKajFTTkR5bE9zL0JoNnY2OEpHR1BRb2RmRWF3WVFXWlA0ZjlTbHIydXFHa2RUTmxLQVl5M29jQlZML3NiWm0yVkR3cDRBcEE4OCt1NEFwNExCRXlEUVNOV0NHRW5wYXlRRlRrMFFuQUpKVThvbk94dWZOU29BZVFvWmlzWVU0M1ZKYVJqcWdURVhNcWFzY3hka2pBSEJKWDFtNXBhQng3M0srN2hrT0p0aDR5REFZZWpJZCtKN3RHdzQyQnVBekRtazU4YkhTeVhKUWpXanl6bGRBMEttUUVkZFJoblZEUnlWS21GaXdtbTIxYjB4VUdES0RKeWU4dU01b0hISmUzaE53SmdMSWRvbjlXdTlSZ1hLMFpVTkRKOTljK0F3ZE9TLzhiWnpnTWVhQUxMa0xuRnJkZ21BVEZVNkFBMHFNTlNPUFZha2JOV2t2cUIwVURFeXQvSFdwYUNZRFJsemJlZzZ1ZlJhUGVMWkhFV0NGT0JZR3pxc2R1UnZxajFiTmhEc0RVQnVkWWU1UmZnWUFnNnBHK1FtVUM2S0U2ZENoQ3FkYktnWU1uSkxTbHZ4SmtlblZqbjZZR010d05ncVpNeTVKdGE0THJQUHdXdFk3YWZUZ01QUWtiOFpiT2NHanpVQUpNTVJaTU5IVitWRlgvT3pMU1NLVGpGMUJDMmRYMFBWaWhLQ2x5VHpiaFUwMWd3NXJuVU5IdTBzN2hNR0RnVWNWanZ5TjlvUkxCc0FzbTFQOExFV2dNekorZWlxdUltbHNJUW9xTDRoV1RRYkpLWWFPU2FFZ3RRb1REMUN1aHB4YlJVMDFnU0pXNEhHbVdBakhUZ01IZm1idzJiNHNQb3hYKzJJbFRZWURiYlVDcDNrUzVxYWtjZXg5YmJtTkVJRExOUjJIa1dHTUJEcURJMjl5djlYbFQxZkFocHJ3TVl0d2VLYW9IRTAySmdpQnFRRHh6V2h3N2tkK1p2d0tKYXRPbXpCTGoyczk2SjhUSUdPR0ZJcGphenUzeXZoQ0pRTjRFUGRSRWtjUmZIWVVvVktHMGJSWEpTbUdkaWRSbUNobGZjVWhlTmFvSkVKRjllQ2pMUEN4bWFBNDlyUVlmREkzNUJIc1d5bnZ4VmI0eURmTW56TWdRNkFRd1BxY09DbHUyclRXUlRGUUxOTXRtYVU2OUt1bmRlTm9sSGFzZzhralM1dHpyVTN3REJvckE4YW13TU9RMGUrTTdVWlBvNE9JR3NEUnoyM3BSMW4zcWdFY2VJczZnZWhsbXpRNkRLVUdBMjZBNUtHZ0tPck44VWxzSEVHeURpYXFySDB4bjlUd0hFTDZMRGFrYjlaajJiWmFzT1diTTNEUHhzODVnQkhYUnBiS2p5YWtlemtkR1FEeFpocE1pNHFoOHBqNjQ2alhTckhKYkJ4RnNnd2JHd1lPRzRGSFFhUGZFZDlOTXQyOWx1emF6aUhXNExIbkdxVldMRkN6Z1loQ2tDRGNmSFpNRkVuaWZMNlNBd2xkNFBQZ1F3bHVKSnJndHBSNXF4b29Oc3JyNWFmcXhYOUdIRHNBVFJ1ZlMxa24wMXIyQm8rOTlUQVllakkzOFJIdFd4bnZ6VzdsdU80Sm5TTXdVWmY0eStGVXRTREEyY05lRHp6NWp2bGF6Nm50VGpPbmtxUVc4RUd1U1N4R2dVb0tpUG9tMlJSd0lLZmtZT2lpYkw4VGZrOHFCMENqcm13Y1RiSU1HanNCRGh1RFIwR2ozd0hmVlRMZHZSYnRHczZsYlhCb3dzMnlyUmNvQ0k0NFR0RDIrN2ZlL1RaRjk4dFZTQWtrNklZNE53eCtsdDgvSjAzaTZyQTEvek9OUWU3OGR3QUQ4cEdxMXcwazNuMWVoVUM0clg5MWsvOVpRR01FbFpoRmt5Vnl6R2tibXdSTkRMM2VmYlpzNGF0N1djM0N4d1owR0h3eU4vZ1I3VnNKNzlGdTRYRFdRTThjSzR4T1ZSOUs1UU1XcW83U0JKdERHZk43L0I5SEh4eDNpbzVWZGxwU0NwRmJkRFllRUlkT0gxVmtVUjQrTXpEVC9lQ0JlRWI0QVo0QVM2QUJ4NUxTb1hLZFBrYXBTV09vUmNzQVVCS2J1WHpHamlHOGplMkJCdlorL29Jc0hFdC83cHA0TWlDRG9OSC9vWS9zbVVmaGx1eld6bWlwZEJSVDdoVjYrKzJLdVhCeTIySWdya3BPRzJjUFU2ZWoxMktnaDZIdjBFTlViTXQ0QUQ0QUE3NGV4NFhlSm1hQzhMem8yUUlORnJBSVIramVSMWRvK2YxdmZMYVhubTFQSWFtM1dxZ0cvOS9Wem5zRmtBamV3OGJOQTRDSEpuUVlmRElkODVIdHV6RGNZdTJOZmpvQzYxMERYR0xUaHRuRFNoZ0NyL0l1c0l3UUVxdGtnQU5haDcyelBmZW1nUWN3TUZ6YjMrOXFCMGx5WlBIYXA2amErejhtR2tDTHNiWFc0S043TDE2Sk5pNGxUL2RCWEJrUTRmQkkvK0NPTEpsSDVSYnRGdmVIUzhCajZGRVVpa1hRSU5LVEx0VWhUcmhOSmJVQWlrS2NTakVNdFhvOWRGT2YyM1VqYUVKc0YwajUrdi9DNldEejhmQ0tHZURESVBHUVlGakM5Qmg4TWgzemtlMzdNUHp6T0F4Qmg5VEtsaHc1amgzY2lqNm5QdlllUHZZT0F5VmdzY3E2c1UzM3U1dGtVN0loWW9TRkpWZi92bjMyeUZzZ0FmZFJQbEkyT2FTY2ZOWitSclplL0NJc0pIbFAzY0ZIRnVCRG9OSC9nVnpkTXMrU0xkbVd3V1BMZ0FaVWd2cWNFeE1SbFhQQzVXbEFnbUVSMVE5b2lSVmZsNG5sS3FmUnB1MzBjQ0tFbHE3cWsyV1RINjlaZGxyOXA0emFCZzROZ1VkQm85OHgzd0d5ejVjdDJSYkFZKzVYVXJINEFNWUFDemlxSHZnQVpCUTRpY3dRcVVLVlNTQUJBb0cvVHhVZGt1MzBKaWd5a2Zsa0VoeG1Rb2JXVDAyc3ZmWFVXRWoyMC91VnVIWW10Smg4TWkvbU01aTJRZnRsbXl2NERFRUlmVUVXbFFOVFcyTi9USUVINFJPUHZIZWw4cnZGV1dqcXA1UjNzZ2NaV05KbTNLRFJ2N1pzSFhRMkQxd2JCRTZzT3dOWmp2SEdtUTcvSzNZSHFCakREeUdxbDhpTkdnd0hLcUd5bVovK2dzL0xuMDNLSi9GNnZDSmJHemk2eVZ6VVk2c1ptRFoxL29TeS9hRGh3U09yVUtId1NQL2dqdUxaUi9HVzdFamdNZFFqa2VkV0VxMzBqaUp0bTB1MWdFY1U1SkVid2tiMlh2bHFMQ1I3Zk5PQVJ4YmhnN0RSLzVGZUNiTFBweXo3YWpRTVZUTlF2NEdZWlhTanJ5bjM4WVljQ3lGamJuQWtiMC9EQnI1dnZBUXdMRUg2TUN5SFpMdFBHdVFmV0NmQVR5dUFSMUQwMmc3eTJlYmZoMng1MGJNNFJnQ2pxRWhiR3ZDUnZaK01HamsrNzdEQWNkZW9NUGdrZStNejJUWkIvaFpvV01NUEpZQVIreGtTaGlGa0lxNmlXSWtrUkppVVdsc1ZEM0djam11RVVySjNnTUdqWHhmZDJqZzJCdDRHRDd5SGZLWkxQdFFQeXA0ckFrZGZaVXJkNnBVWG4raHREb3ZuVXkvK0xFbldxVUxPdmg1S2F0dFZBLzludHFkUi9oWXMrZEc5bnQrUk5ESTlsTUdqZ05CaDhFai80SSttMlVmOUVjQ2o3SFF3OUplSGJGU3BlUm12UFJhbVpFQ1VOQlhReTNUNjJxVU92eUMycUZlSFdvVUp2V0RqNW9FZTJsSDBlejMyS0N4ZlR1a3dyRlg2REI4NUR2aU0xcjI0WDhHNk9nQ2p6azlPVkFsTk1GVkRjTHEzaHBkQ2FkRjBTQzhJblVqcUIxU1RMb2FndlVCeDk1aEkvdGFtMkxaL3NmQWNVTG93TEkzdnUxY2E1RHREUFlNSHBjQXg5Z0UyaElxZWVYVm9sQjBOZkZhWXZGNUFBL21yS0NnNkRtN2dNT2dZY2k0MUtjZFd1RTRBblFZUHZJZDhSa3RHd3JPQUIwQ2pucVNiQnRDMFFUWlYxNHRUYjFLYnNhWG43MERISE5ub2RTcUNvL0Q0L09jZFM3SEVHeGt2MzlUTFBzYW1tTFpmdVhXZGdyZ09BcDBHRDd5RDRneldyYmoyQXQ0VEFHT0NCMXkraHBwcndteHdBVVRYZ21kQUJua1dkUzVHbk03aFE3bGtQQWFGRmFwWVdPUHFrYjI5VEptMmY3RHdHSG9NSGhzNENDd25STkFzb0ZEU2dOT1B5WjJZcVgwZFdEbzJ0Unkxakh3RUd3WU5Bd1pWamlzZGxqMU1CQnNFb2l5WVdGcjRERVZPT3F3UnBzYzJneHI0NlBLWEpXNzBWVytPaFZ5cHRyZWtrS3o5NytWRElkVVRoZGljY2dsLzNDeEhRYytyZ2tjWGVHTXRwcWtxU0RwNmc1NkxkQVkrMyt5M3d0RHhySHNORGtjWjRNT21SMmhZY0RxeCszQVl3bHd0SGtjRFhDTWRRSzlGbHhzR1RTMmZJNWxuL0Y3c2xNRHgxbWdJMXIyeFdrNzV4cGtPNnhMYkMzZ0dDcUhsY0xSVllFeUZ6WXVVV2l5MTlxUWtlOGpEQndHRDhQSEJweW03ZHdBY2dsMDlGV0hQTkc2L01ITHN6cC9ycFh3bXIyMnNpMWZZOW5PK2doMmVvWGp6R3BIdE95TDJYYnVOVGdhZUF6QkJwQlJFa1NiTHA5OGpHcUhPb0h5ZFplNnNSWmtiQVUwc3ZmZWtHV2Z5MGN6QThmSlF5eDlsbjJoMjd3R2V3ZVB1aW9sS2hwOUE5UHFjTXMxUUNONzNiWjhiV2VmdTBjM0E0ZHpPd3dmR3pqb2JPTnJzRWY0RUhBQUdtMkk1SU8vTTJsSTJsSFVqSzN2N1d3bmZDWXpjRGloZFBhbXlUNGdiRjZEclVQSTNJVFNvK1JsN09IYXpIYTZaellEaDZ0WUx0NUUyUWVJeld0UTc0RTlBTWphWnJnd1lHUURoWUhENWJNMzNWQjIvbmIrVzk0RDJlQnhDWkJrdnI3czk4MEtSajRzR0RqY3N5TjlBeGxDOGc5WTI3Rmh4RkF4L0w1bG4yODJLeHh1RnJiaEM4RU8wcEN3NXoyUURRWkhWeWpHTFB2OHNpMWZBK2R3dUVOcCtnV1VmWURadkFiZUE5dmNBOWxuazIzZE5UQnd1RFg2SmkrcTdJUE81alh3SGpCZ1pKK0RSek1EaCtleHBHOUNRNGlkdTUzNytmWkE5bmxpTTNDa0QzUHpJTGg5WFlqWmg2Yk5hK0E5WUxqSVBnZjNZbFk0REIzcG05QVFZcWR0cDMyTVBaQjk3ZHUydlFZR0Rxc2Q2WnZRSUpMdktHeGVBOE5GL2hsMWRETndPTVR5Nk94bVoydG42ejFnMVNMN0hEcURHVGljMjVHK0NiZHNka1NHa1RQc2dlenJ6SGFPTlRCd09LazBmUlB1MWJLZGhNMXJZS2pJUHdkc0JvNzBpaFdQdmZlRmFDQXdFRmlsOERsZ0lMSENZZkR3UWJDSmc4QlFZaWh4eUNQL09yVGRaZzBjVXJIaTRZdHRCd2VPd2VTWVlKSzlyMnhlZzF2dUFRT0h3eXcrZEE1NjZHUTcwN05aOXZ0dDh4cHNmUThZT0RaazJadkI1alU0STdoNDMzdmZldzhZT05JQndPRGhnOGdIa2ZlQTk0RDNnUGVBRlk1ME1MRGk0WVBJQjVIM2dQZUE5NEQzZ0VNcTZjRGdNSXNQSWg5RTNnUGVBOTREM2dQTzRkaVZlY1A2MFBJZThCN3dIdkFlMlBNZWNOTG9CbURDNEpGL0lkaThCdDREM2dQZUF3YU9kQ2UvUmZPRjRjUFJlOEI3d0h2QWUyQlBlOEFLeHdiZ3dlQ1JmeUhZdkFiZUE5NEQzZ01mdWVvYS9IL1M3NkJoRloxMFl3QUFBQUJKUlU1RXJrSmdnZz09", "ImageFormat": "PNG", - "ImageWidth": 540, - "ImageHeight": 270, - "HtmlContent": "\u003Cdiv class=\u0022fry-img-card\u0022 style=\u0022margin:8px 0;max-width:840px;\u0022\u003E\n \u003Cimg src=\u0022data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhwAAAEOCAYAAADCCM5xAABPXUlEQVR4nO2dbctt13We/Qf6oaDITqvg1ghhaociEQSSQ4IOCQ5HODhwhI0DEi4YXAg4YOOAP6QQELQQsKGBQA0tLTK0EGJICSS0EBJaAqYl2DStiwnCrWiMIvn8hFOueda9Mp551vtee4/1cn8YPO9772fuuea41j3ePvDbP/zoI9v\u002B1\u002BB3f/ARm9fAe8B7wHvAe2Cze\u002BAD2Y7SZtDIvghsXgPvAe8B7wEDh4HEioYPAjsD7wHvAe\u002BBA\u002BwBKxw7U1myN4zNa\u002BA94D3gPeA9YODYABAYNHwh\u002BjD2HvAe8B7wHrDCkQ4OVjR8EPkg8h7wHvAeOOMecEhlA1Dh8En\u002BhWDzGngPeA94Dxg40h2/QcMHkQ8i7wHvAe8B7wErHOmAYEXDB9GRD6J/\u002Bz9\u002B6tCWvb42r8FZ9oBDKgaN9E1ou84aZDvys5n3sa9l7wEDR7py4TwNH0SGh3wg2KLZQflsONMesMJh0EjfhGe2bIdn28caZO9Tm9dgjT1g4LCi4cPEMJHuUG2GEkPN8c9iA4fDJ\u002BmbcK9mJ2lQ2NMeyL5ebF4DA4fzNHwQGCrSnaHNMGIgOf5ZbOBwQuijs5udnR2\u002B94CVkexz6Axm4HD1yWnMTsVg4T3gEE32OXRmM3C4zPVwZqdisPAecJ5I9jlkM3C4p8bBLgQ7FsOF98D290D2OWHbxhpY4XDzrvRNaLjIdwg2r4EhJP\u002BMO7oZONwpdJNmB2gH6D1wvj2Qfe7YDBybNG9Mw0X24WzzGhx9D/icPRYEWeEwbFi92MDBatvPGvzef/vJ3Vn2mhlC8p39FszAYVXj6pss\u002B4CyeQ2OChKGEqsg2RBh4HAIJW1D2bnbuW95D2Q79T77w//y9KgZRqa9x9lO1WaFw7kahox0Z2c7L1hMAYpLzYqIAWTrsOOQihNDrWIYBnYLQ9kgMQcw/uQ/PTXJ9gogWPZ\u002BmGLZTvfMZuBwFcroJsk\u002BIGxegy3DxZCCAUD8\u002BR889eiv/uUHH/3Fv3\u002Bq2J9/8Hcef/yDx3YL\u002BMgMyWz9\u002Bs12wmcyA4fLXQ0ZGzj0bE\u002BuQTZELIWMWtEAKgCM//mvf6KAx//553\u002B/fB6hQyYo4edYBJK9g8ceACTbIR/dDBzurVE2QvaFbjv3GmQ7wTVBow6f1EABdPzNl5999PD1Fx799W9\u002BuHwUYOhnfF/AUSshR1A9DCD5zt/A4UZeN90E2U7Gdt41yHZy1wKNvlyNPhVDisf7X/xYsYcPXr6jgIyFXo4EHlj2vhyybGd9BDu9wpH9Btzasi9a2znXINuR3QI0\u002BmCjho46bAJ0SOkANvi6CzjG8j3WgJDstTZ85PsIA4fbkxswNuA0beeBjLmgMQU4hqAD0AA4CKcIOC6BjktBJHvtDR/HtNMqHNkLf02zYzQcWMW4LWhcAhxR4QA4pHLIuhJMl0LHXAjJBg3DR74/MXAYNgwZhopNgGW281nT1siFmOLk\u002B/I4SiLp/XuPcznu3ytf8/n//Uf/tE0ivRZ4jAFI9ntj\u002BDiGnUrhyF7stS3b2djOuQbZTmaLoDEVNmqVo87jAC7\u002Bzt/9YGt/89Jr5ftdYZausto\u002BCOn6vbpyZqwSJvt9Mnzk\u002BxwDxwlhI9vh2M61BtmOZA\u002BwcQlwlPyNBy8/tvv3Hv3F3/sXj372Vx6WBmF/9exXHv31828U6OD3ZBE6huBjDDT0GgQ1U0pws9\u002BzPYMHlu0/DBxWNgwZGzgIbOeAjLVBY244pXb4OPsIFHxOKIWQSgmrvPJqgRDgI\u002BZ41GrHFAiJP5OywuN3hWyGQi3Z798RwOOM8HH4kEr2Al9i2ReD7VxrkO0gzgQbfeWxsggfgg7lckTgWGrxeYCb33vqP5bwjZ6zKzSzR7VjL/CR7WsMHCeGjezNbzvXGmQ7gz2DxhrAEaEjAoGgoySTPnj50b976j\u002BXj4RagIQ\u002B8IjgUqpf7t9rVRRVwkhFUbfTOkdkbqOx7Pf2KOCBZfufa9phFY7shTVk5F\u002B4NkPGFmBjLIdiDDraEMtLrz36J//4/5XQCtAhWIi5HV3qCMABrMSEVPJCeEw\u002BzulsegTo2At4HBE\u002BDgkc2Ys6x7I3tO08a5B9yB8NNKYAx5SkzSnQ0bY\u002Bf/2FYs98760SZinQ0KgXKqMtuR4vvVZ\u002B/rtP/9c7qkZROR68/Pj3Bvp8XDLFNvs9N3jk\u002B7VTAEf2Yhoy8p2qzZBxK9BYGzZq4OjK55Da8Sc/8W8el8w\u002B/0Zb4fLLP/9\u002BCxsljNKoGAq/xIZiQ51MLwUOg8d1z6Fs/2Xg2Als2BkaCKxmHB825pSkzgGOO0mkUjvu3yuhEtSMAh6NmiFFo6trabShctqlTcIMHrc557L92WkVjuxFNGgYJLYAk9ny9FlA41qwMVax0gUc5GZ84r0v3VEzImwMAceU/h1rQYdDLQaPQwBHNkxYzch3tGe2bCe/FTsqbHSWyH7xY48\u002B/s6bJXRCCIXwSlE47t8rKsdc4LgEOpasX/ZemWPZ1/ccy/Z5h1c4shevy7I3ne0ca5B9EG/FjgAaQ8qG8jbaipPXX3j01Le/8\u002Bh/ffifFdj46S/8\u002BNEv/tLD0hgM6wKOodyNLOjYE3hkX\u002BtLLNsPHg44shfOoJF/UZ3Nsg/es4LGJbAxBTL6em/81k/9ZTvM7U6VClUmr7xakkQJp/B7JVG0I4cjdjEdA46l\u002BRxrrG/2fjJ4GDh2ARvZTsh2/DXIPmi3ZHsFjbGOn6W7Z9PQS6WvQEQpa21CJUDGZx5\u002B\u002BtGvffSHReH47Z/870XtUI\u002BNzz/85OPfff2F9nH\u002B8Kn/UL7HYw8NgZsCHteGDoPH9c6QbD\u002B5W4Uje8EMGvkO\u002BAyW7di3ZlsBjT7YmAIXQzka6gIKVAAJJIECEc\u002B9/fUCC6gYytfg5z/zqw/vTJUFTIAQlcSqLJbv1WGWqdCxZm\u002BOo8FH9vmw1LL95q6AI3uxsOwNYzv2GmQfpFuzrYJGn4KhJl1SFOqkzanVJ5ocCzTwWEDFh77x9qOn/vhP74CG7Olvfr8oHsAIoRY\u002Bx4AWJs/ykXkpU5NIh\u002BDjVsBh8DgeeOwGOAwa\u002Bc7QZsg4ImiMlX5G2Bjrm6FR8\u002BqNUapHmhbkdSJnDRtd/TVQNFA3uiCjzz718HNP9Oaoy2Vr\u002BBlTZFBO\u002BHxM7bj2e5UNwF2253PRwLEx2MjeELbjrkH2QblF2xJkdIHG2GTXdijag5eL2qAcCgFFTOYUYKhzqDqGKl9DIRFyNGhlPgU2PvSd3y9hGGauqAtpX3\u002BOKaZQD8bXU9SOW72H2XvV4HFAhcOgke8YbQaNM0FGV/hEkCGYiNUgfE1JKqrCP/zajwo8EL4g54KPMZHzTsVJo4R89sV3y\u002B/xOUrC03/2R23ZK48LtBAymQIcPD\u002BAAmy0\u002BRzN69Xr6FJbIiwBO5pMy2MAMfyf/P9TQyy3ek\u002B3Bh97PytPHVKxopG/AW3rrEH2QbhF2xpk9CWF4lxjcicAAAwoiVOJmVGd4HeUxNk25ZLpd1E\u002BXnqtQAWQATAAHgqh1ImhVKj0gcZT3/puUTVIHqUvB3kfPJbAg9fK6\u002BHrZ958547iIqVFIRyqYPi8TjYd6lB667yOrcPH3s/N0wGHQyf5m85m0NgbaCwBjCnVJzGnoeRpNGGSrvAIP0O1QBkAAlS2igECdAnF\u002BfM1vwMkzMnRmGM892985AcFQvi8VT0qxYWvVQ1T2qQ3\u002BR9d1S1zK1gyoGML8HGE8/MUwOEcjfyNZrtsDbLvsLZmWwSMIdCYmiDaWWUSlAucPg6fkAjKgipK6J8BaPzCj79wNdiojaoVlA6AQiEg1AwpL8obUVku/wN/Uz4PcDU09O2WZbN7gI8jnKMGDieEpm9CmyEjAzLWAIwpsNHXJXRsfDzAAUjgzAlzTM29uJWhdPD6CLcQxlH/DqDoZ370tVJ22yohTbIoIMLPSxLq6y9cNNZ\u002BC\u002BCRAR9HOC8PqXC48iR/Y9nmrUG2erAl2zJcTAWNsbbkQ8ARS1tx0MqvyAaNMaNLKR\u002BBD5XyRlWjr2HY0vkrWwGPW8LHUc7VwwDHtWEj\u002B42yHWsNsp37lmzrgDEHNKbOQKk7hbZVK6FxV5zoSviCHhnZcNFlqBq8ToWCunp2zB1tPwU6tgQet4CP7DNrLds9cBg28jeRzZBxNMCYCxpzgUOD1kgOBSpw3uRAoBLIeZMHkQ0UXUYVDF1Ied2EgEq1zUCTsL6OpGtAx5ng40jn7C6Bw6pG/saxDa9BtoKwBdsTYCyBjCWwIeBAzQA6UDEYGa8yVJQD8iT6WpJnGhUyfCSXo\u002BRnxLLdZkrt1PkrU9Z1yXuYDR0Gj3XBIx04HD6xs98y7GQ7\u002BWzbE2BcAhlzYEPAUfIbnn\u002BjGJCh8Ak5G4ROlIiZDRZjSaTqK6LqFTUho4Km/H8Tp8xOXeNL3\u002BMjwUf2\u002BbambR44DBv5m8RmyFgLMjIAYw3IGAONoeqUtpV5U9GBOoCigdLx3K\u002B/lw4UU42wzz/43/\u002BqHfpGTw5ABPXm0gmz14CObBAxdCwDj0MBh52nAcJqhlWMa6kaQ9UpdypUmm6jqATqxbF1YxItHwkLta3QmzboXUPexqAjEzxuCSZWOuZBRxpwGDYMB1sBxOywxZ7CJNdWMtZQK64BGUND2\u002BqSWMITn3jvS8WBE55gvkk2UAyZmoCpHHZosuyccfZbB481wcThlWngkQIcVjXynezZLdvp7wkwhiDjVoCQCRk1aPQBhyarEpogf4OEURSDbKAYM7VWJ4yiQW1xuNvYKPup4HGNqpatgcil12r2uXgNSwUOw0b\u002BBjizZTv/vQBGH2is4eTj5NW5Mv2Q45rzt2OAMQQaNWx0hlWafA4SSSk/JS9iS5UqtFaPX6NukOzaN8Z\u002BDDqWAsjW4KPeT109R6bAh6GjGzx2CxzZjsu2rzXIhoC9AEafmtHl0OWQpzqZrgoP5QrIsS0Bj7XgYggwxpSN2PgrDkQDOEgeveaAtkvVDZSNGEqpO4wuBY\u002Bxde4ClFsCyBgox73K\u002B8j/3wceffBhpePuOXxT4DBo5DveM1k2COwFMMZAAyuKRBPjL9NSGwdF/L8umxwLQ6iyg0Ocao52fPvrLyxKTuwDixZsGhionWfXnXyXU\u002B1TM7pgowaOtqfFK69uIoGUnht8RHFpJ8eS5Nq85vh/xP\u002BvXoM58NH1/vOc5Lrw/Jc0Fbtm2E2vidcMmLEm8f/Q6zN0bBA4DBv5Dvgslg0Ee4CLOfkZ8QCWo\u002Bau\u002BJd//v07Mzdqp4xTKV03mwZSMeSgMejPfO\u002Btx105AxCscfcspyAo4vl4PeVuHgh48HI7Lr5rbkifejFkd0BDsBGAAwfP/0xDsAzFg54gavLVwoYafWlQ2wh4aC2k3tRrNqYKtX\u002Bv96KphKnf90sSUNfK8dFeqiE5QlKf6rKW2pF9lu4SOAwb\u002BW/00S0bDPYEGEOQ0RfLltWqAY4U50mSpMaby2nxNVBClYacL3e1VG4AIjTH4m9jR8sux7UkHFI7N\u002BCC56TRlcpAcXh8n/beAqMuiMDBkADK7/ERgJEiI7Dgf\u002BJ7pT24HHkDGoINjOdTF1L1ugCGrgUZNCTjtfFcPCfPL\u002BDQa2sVJgFSU9ZbRtcH9UMw9dkX3y2PR2Mw3j8ejzyVLjVE7wHr9pmHn27zRXh\u002BHoO1KDDYrBf7RaG1KQrXNUDjCdhoQn\u002BsnwCMjwIOQ8eBgCPbkdm2vQbZgLAHuJgDGn2x7a48jDa\u002B3Th1nAjOA0elceY4Gjk6vg98tA67ubOemzcwJceiKCzciTYDyXBkODsqSPj43NtfL86SOSL8TnG64U4f0wRVoEB/ExM/pVjgSJ958532\u002B/y//K/6WjNW9FixcoXXwJqxXjhyHDcwpNc5x3gdT3/z\u002BwUyeCweU4284v/B69HrxekDJHoM/ob3Sq\u002BR8l5\u002Bp8BHA1DtWnzru\u002BXnPJdAJiad3oE21vT\u002BvfJcGP8fiavkt1AZw2sqINTRaGzNSpg5eUZS86RqCIyjCjOWY9J3HZ5V6bg6cBg28t/ko1k2KOwFLuaCxhLgeGIse2h6haNCUdBgM7XPvnNHHWT8Lit3xB0KyJBJ/ub5eA04cV4Dn/N4hHHaMENTQcLPi9qi11ZNehWwROPx9JjRcMB1KOPW4ZMleR1SRLoUFyCRtWjnr3zz\u002B\u002B3PpPqgAN1Rfhr1p\u002BT5PP9G\u002BV0eg99nzbUuCi/xXrTgV\u002BV2XAIfcxOJBdLsYUzrIaWuDvsYOjYCHIaNfOd8JMuGhr3AxVLQGMrc7600CTH9eEBzp8wdtfInopPmdwQgpbNllPObsATGHTQf\u002Bd2\u002Bcs2\u002BfIuYtIkTUyiHO3VJ\u002B7XxM70\u002B3d0r0fOr776YDgbZBhDUMBXfV82VieEkdTAFZACSWrkRuOhveZ\u002BKKhWUjkvyeS4Jx8W9zf/DXmCPRAWmT1m5Vt\u002BO7DP4kMCRvSi27axBNjzsCS4uAY0pZYIxtl2cf1AruEtFpsdZK19BIQccPQoBd9LI6IQq\u002BH2\u002BB5jwuf5OTgdIkTPi7/kbnBWKSV8yY4SNCBxzgIHn4g4cB5nt4PdghJiUFwNQlO99\u002BzsFFHkf9fXQY7DevL8KW8xtPFbDAu\u002BdwnZxXxQo6uieOlTurHChKmp4ffF1db2W8lyGjtsCh2Ej31nv2bJBYk9wcSlozAEODlQUByU9Sj1AzagTD3VXKJVCcnyf00FRAEC4i0TdkAP7\u002BDtv/i3cVEmMdxIam\u002BdXPkGsDlHoo0/dqEMLtsvXAGjsU0RqAyZLe/WBXiBTeqMIEnhu9pSScnnsUlV1/14B4xLe68kZqmGDv2Mfo8bVOSr8XI\u002BhvKGuktmx6/YsSsdVgMOqRv4bu0fLBoq9wcWtYKNL4Yghld7GVxzGzej2SxyX2oRz8PPxiTyBpkQzlpoKLIAd/l5QA8x0Sfu23DWIPUGU18NHOfkpOTxPhEGacBqPQWhHVUbluQK4DuURxTwU9pfCRao2kvKhx9DrnAscZ4GOTQBH9iLYzg0Z2aCQCRpz1I2upNGuu8uYQMphrFwO5QFc4ph4rLaqJOQKKCmxvqMGdpRHYNvWGgB/hM1Qv6hWAQiAAfaKEntVKYTFfi59oTTBZwmnPP9Gq1oBHdo7/Jx9EZODARHCboTUFI5RiA9TWTVKmYClHnDXlUg6BzjOAB2rA4dhI/9N3boZLrYDGnNgo6\u002BbZ4QODuEykyP0nuDARmUg1n9J2ILHlWohR8DnEWSkZtj2twZAgUp0BRxSogQBda8Uga1yiH7t/Z\u002B7U0FUAy7PoZJfnov9o99BQeNn6rnCY/L8hPRKD46md4kqdgTUU4DD0LEB4Mh2fLZjQ0a28rBlRWMJbPSVEz7RaKupNIklkzG58FLDkfCY3HEaMI5vKquNvVLqjq7KJVpbgSH/o35eQQ4/Zw9GpWOoGdica/yoSseqwGHYyH9Dt2SGi\u002B2CxhLYmAIcqlhRKaxCHHSlXMMJAC1qZOU8jOMbuTj1\u002B9w2jmtM5apKMl7LeLwCy9/\u002BzmN1IySMqteLVA72/JLhbgYOw0a6o96zWbnYtqIxBhtzmyXFkEpM1kPhoBullA7yKIYqRKZYbNhlO\u002BcaCDalemBdzdcugQxgWaXa5Jho1k9XxUzdkGxuq/OzQcdqCofDKPlv5lEhIzuEcSTQuAZsdDZLCuWxShilU6PyMGxeg0v2gHI1rjGBlzCJSmgVRhkaLtjVcbTv2js7dKwCHIaN/DfyKJCR7eSPChlDsDG3DfTUhkkx3k0yniRqlI6xZlA2r8Fc1WMNExCXniDK3WhyROpcjbmwcQvg2Dp03Aw4sv9R2/YAI9u5nx005qgaYw2X\u002Bkpj6xbjcUS7BoepQZS7exoyLgWFtUyt9/mchnSaywPczJ2lYuhYETgMG8eFGQPG/kFjrqoxBhhdkNHbh6MBDSXWqZJABzk5HoYMQ8ZW1Cy10kd909Rj9e4ANNjXY7Bh4LiywmFlIx8MDBj5CsXWIGMKaAwlgMZx8\u002Bo/0Nd0qc7b4O6QpDsl9qmZkw52QitrVxTYvAaX7AHa7gMXKG8llNKU3PIz9nQ9O\u002BUS4DhzaOUi4DBs5L\u002BBVjDyAWJLkDElfDKW\u002BAk8ICMjIZc\u002BA6EVdGy6VKsZ3AlqSBrqBQ2VABY\u002Bd88MA0EGFLL3hn5Ocqj2uaYcd3UR7YKNPuDYisqxRegwcGzgTTBg5APBUSGjBo22X0bTAjoOoiJRjux/7vA4iOncyAEs4NBd3xOzJprQCb9HLgbVKH1ytc1rkLUHyBcCLsrgtkZ5q\u002Be3TC1/vRQ45kLHJeHtQwCH1Y1tW2aS59p0vyfbCmR0hU80cA2gAC7In\u002BAj4Q/Fq2NOhRLndDjzMSbnEevWTBMlhPJ4/J3BwmCx5T3QThSuYGMt4NgSdBweOLL/qbPZNeBiDDJu4VCz4WFLcDEVMsZmoNRNugAF4tZx0ioqBcoGd4R8TnybEAtfU94KdKB\u002BKKFOw61oXy4Q8aj3fKdqG18DgTQdbIvqFzqJjvXdmAIda5bJHkHlWAQcho1jwsUUwLiE5vcMJBn/3xzAGKo\u002BqXM2OEyJVyMvo2wAC\u002BrWCHh0hUWGAIJ5Kfw9MOKqEzv6vYAOYcP4NZUotdqBsbf7kkbXUDtuARxbgY5VgSP7nzmaXRsupoRKouPjouJirIcUbQE\u002B9m5zwWJJqWvnoLXXXyh3eKgXVI6gWLSj5H/0tcmHN\u002BPFsx2IzWuwdA8QBiwhljCfBWUPBYSf69wbg46l4GHgsLpxOLCYAhhdd/TRoXGhxXr0IYkx24kfFSymgsZQVUrsncGBisoRe2VwV\u002BewiB34WSCOAXFAh5KbVbXC54AHSiDQMRZemQogGcCxBZVjtsJhdWMfULEEMvpCB/FCUaWD7gS4MOOFKCdWT048KoBcAyDWAo2xXhtdrcjVe4Dprgxcy3YENq9B1h5A4VMuE2ed5ql0JZJOgY\u002BxAW9nUDlWAY7sf2JLlg0WSyBjLE\u002BhdnK18yrOqpk7wMexWQO228DGWM\u002BNGjjaaa/375W7OknNdvp2\u002BmfcA5SIq\u002BkdMN6VSNoFHn2DDks5etPPRmfiXOjYu8oxCzgMG9sEi0sgY0pCZJ\u002BjizkBJdGKjO/QLGdJjPMWakB53c9\u002BZXHW\u002BdZVjSWwUc89UXiFuDZrpe6h2U7A5jW4xR5A1WDftzdSoUdHXxXL2DgA3Zjpcbqg4xbAkQkdBo4dKRZrQsbc6os\u002Bx82FQ/wfo1RSjaDqmQO3Bo8hB8xr5jAZy0XZCoDMgYwpeRvkZ0jJKJ0VmzHcETYADX6mw9eO3o7\u002BrHuAMu\u002BuhnfcYAHh\u002BjpCSD1fiOotzhxK0HVexsT7qdBxGuA4urqRDQ23hIylwNGnbuCcyOpWHfvUbO61nfqYA67vNnRolLv5L35s0mvdM2jEmScRIiQbR1WD\u002BDWScvZhb/MaZO\u002BB9ox45dUSaqQiS/07VNFV\u002Bng0eR7RuNaAC34HlTBW\u002BfXlc9wCOLKgYzFwZAPCmeDiGqAxBzYicNTO7M7gLu6Mq/kDS5Krpjr2qQ44JrNyh6GGV9xxKPm1hqRbgtIlgDEEGwKr\u002BH9yQJJ9z5wT\u002Bg6gdujQJEP/8w8/Wb7m\u002B/TjQLXKPvBtXoOsPcB1wpmB8TXVKlI99DtcN1wzbat0qYWNYkgFDD1qSndT1NVwLnZBh4Fjx8CRDQhbg4wlsCHg4CIpyVPUq0tibGKc0Hvs1NcV25ySXDXH2Y853Ro29NrjMLIYjx17nXNexzXBYuj/7fqfOQQ5MHVYfvydNwtIcJjydd3ki46h\u002Bl2b1\u002BDse0DlsUPGNUR4BeUQFYRwpNRCGuPpzNH8IowzU2GVqcmja/uWTSoce4KNbDjYOmgsVTf6FA7dRatTZbmwQmilL6mqz1HGx\u002BXOvEt5mGr1ayzDyT76w/YupGtY0xTwWBOQ1gCMscRQVAsOvnh40mWR0tfsw9zmNdj6HpjTAE9GE73yt7/6sCiFwEV9NkaFw8CxE\u002BDIhoI9QcYlsNGXwxHvprmYSs16pR7UDr1VGzqSrPTzEgYIiVhzS9C6Rq7zugAO5SwASeX1atrphDkKQ8\u002B1pkKyFK768jaK0tGULROD5n9H6nWvjXyHZjvGGsTBhqgaUlP5vnKkpHCoiq8rh\u002BOWCsetVY7ZCocBY7\u002BgsTSUMhQKwNkV2GgSL3HeyPdccAIPVUTQJEzSoiY1RpVBUqPCADxGF5jUzr2vDK0u/4yvpdTWN3kcvC7u\u002BOmsKfjg9Y09dw01yg9ZUqe/1IbK8Poae\u002Bn/J9GNwxEpmCx652vkOy3bfteA66ltj94oqHxE4YjnQl/uhoFjI7CRrTrsWc1YS90Yyj2IeQN3JpHqrjr0d0DSBzqIc6o8rNC/kqwaZ8j3SbRS052oPigRsk8h6StNu\u002BN0m9cj6OBunyRKDTJDEo3P3fWYUnX4G6AF\u002BfSr7774GFhCctg1wGOs5r\u002BGDXUQxcpdVjNKfkp82uY18B7oHmAoaMcACxRT4J1rK96kRJWzPlezgeOWKsdmgSMbBo4GGpfCxpjKMZRD0OXwcebqYom6wF02CgHOH2VDWd111rdgZCxs0/XcTwBHI3FqYBnPC2gwip3XReJXTIZ94nGaZNkWmirFpk8VuRZoDIVSNBtFVSmstfJubF4D74Fpe4CQrCpSFCaJ51Nf\u002B/O\u002BZPK\u002B8/f0wGHI2C9ozAWOuRUWY4mLde6GVAQs1rPjvFEZVEWBU\u002BSuoYQ3mhAInwMnutBrEIhdM7uMuw8dFrrL53m401e1BsmUn3r4ufKagBF\u002BFmXSqNjwc8CE/4WPquDpSka9JIl2isV15jUDGMSPBVCqSonmcfKGDcPGvD3w3NtfL2dFaQUQrvWYb9aXu7VV4LiVyvGBbHUjW204sqIxFzaW9ooYcpZ90FG30ZbzJzShDG8\u002BkiGOTEndOwAAjGigUkksbS76GCYBSErcFEjQz/nYhFCACTW\u002BkhPme/FQ4XlQXgCfthunHk/WhH/02nl9JGQKVGIjoKjELGmNPAYZXcoG/4PW0uY18B5YZw8A81zffM71X25iBga7GThmAodBY7\u002BgMRU4LmlQNSb/DyYwNgmaOGnyH3DyfRc6F7bu1gl/xIu\u002BBYKmLbca9ShHQcpGVDWAm6mZ5\u002BoWKDlVygyvIT6e\u002BlcAKqX7YAM8giz\u002BjnwRhYX6wkFzLYJMvb5ShrQmNq\u002BB98B6e6D02VgAHHPVjWsrHLdQOW4KHNlKw5kUjWvBxlLg6MrlAAYUXpHDjqVlQ6aKCpWfCVr4GJMhNfUU4/EJ2dDYCoVD1RlDz4O6QYtvfh\u002BAULtvHovXzfN3PQa/q5CRQIjnJmzUDoWq5Ngl4NGlnNS5MqyV1tgD2Awbho119gDXFGXlQw0Pa/AwcNwAOLIB4KygMQYba7TgHktwrKtWamVDA8JoD/zM996afdHjQAENTGESDgGce91jQurIGsbr5eOQIkO4plTTNECksAvQQQhHiaYx/2QKeHRBxhOw0XQ1jLkpAiE7HEOH98Dle4DrO4Z0\u002B/K2ynVJcvpI741TKxxrwEY2AJwZNLYAG1gsy1TpaAxDKHmUqpS5F7zyOQQtGF01t3CYkjsRX5eajMWwC1YSUus8lA7lY8zq6hmXu\u002BbvAdux14DzC5hXlZuSy7uS2Iv6\u002BpsfLmfjknLYWwHHtaHjKsCRDQBnB40h2FgKGlOBY6xEUw4xJotywaJIkNsw54LfClyMGYmvXTX8qCMcWqgf/C8AicJCbc5HRxVOn9UKktYW2HFjr/x9YDv2GqC2tonsjYLL97kOSwXbb374Ti6HgaMCDoPG/kDjFrAxVro5p/GWEj81ofRMZZocQsAI/zdhFvJByGHRzIU472UMMmrYIFxD2Ac1hedxtUr\u002B\u002B207/hqQH8Z1zI0ECmwpwW/6BS1tZ34ahcOqxr5Aow821lY1ppZo1rBRV0/UZaUKsXDBFmfc0TfiiAYMUKGDwqM8E1XCxOZCQ/1F6tyY7P/J5jU44x4gNAxo8Dkl8qUarUneHmtpvhXguCZ0fODScEp2WOPsoZNbggamdt5q4DWYaxBCA9EhAhIqK6XqA9BQ11FMlSBnNUIr6j2C4qPk0jrP4wkLvUZY2888/HQJVdGoKPt/snkNjrYHVGaupGyqwdTVl5uE2J9jDdg4NHAYNs4FGlOmlsZEUBwczoxQSJf0r9bhXITxTl2KBhcrDnXt6pGjGDkdqoRhrdo1VP5LAIy6GRkVMMCG1zX/fbQddw1U8l6PM5jS1tzAMRM4slWHM6saU3I05oxCn9I9tA6dKDyiduQCCb6G8FE/SIpUWAQwoUID56nQiXtCjB9qrCHry91S278jNDmLFtu/q9X63ERcm9fAe2AmePTAxtSGX3PO/lv6wE0oHNkgcEbQmFLeeilgTC1z7WrkVecPEA4gD0N32Di92NCLz5eUwdoeqx0Cjy4D\u002BqQc2bwG3gPX2QNxsGTfLJWp81MMHD3AkQ0DZwCNuTNPBApjUwrnwMWUUteuhFAlRxEOADgAD\u002B7Qz56TsaZptgtgp8TS2GpdLczVidXmNfAeuM4eUOVXHF/A53x/6ij6uf7hkArHmWBjazAxtaS1q3Kk1HmTU9HE8lWKdclY9Knlrm3ehp67aTWuPATbOmuAakT2O\u002BEpNVCLnUzV0\u002BRM5cU2r0HWHkCpVYUdXxNSVoOvKa3MTw8cXepGNhRsFTQuAYglkDEUPomhDjY/zl8yX18Z61LQ6GroFSslYqmrczWuc9AJKBRikQF5dsB2wN4D19kD5J3Ra4PPAXt1E6YUlgnVqLprzU3JVjiwmwPH2UHj2lCxpMKkDzjaNuIPXi7OHtoWeERQmDvyvGuOhxQNSsJ4LiUzAhrcfWvc\u002B5IOorZpa0DYhOF0miWjOS1eP\u002B8h74Hr7YGYuxFnIMXcjWvAhoHjQKBxa7CYAxhjsFErEgUG7t8rxK2ppGNDwSJIQOlFpah6bHS20Q4tyrnbHhpmZlt/DX7hx18o7xeggWmAnc1r4D2w7nXG\u002BabzVN1Dx5JEh85\u002BA0eVv3FkyMgCjCWQMQYbQ\u002BPg1S8j9sqIdeNdw7\u002B4U44X1uDMjpC3odkdOD46Z/rAu43To/SYJF0Myddrb9jwtXf9/huou7Gp17Vh45AKxxFgY2uAcQlkTIWNGjjqHAvCHtz9qg9GbJX9RB5Go45IOozfv9N4qoENHpuwCbDh3ho5zo6ZKUyrjJNobV4D74HL90B9ppG3UfrdhOTQKRUpBo6VgSOL1LYIGGtARh9sjCV6xlBJHFleWol/9Id3ci74WCYcNs2joHc5ME0yjU299HVsNqUEUY1o9iGX4\u002BgIZ/3iL51j/ozNa3CLPcBNmoYpTg2jjMGGFY4KONYEi1tYNlhcCzS6yl/HKku6qkikTggaKKuknAtS54JCzQAWVPFAZQlhGF10JIHyNX/D76n6RCWZSlB1CWyuE0Di1VwHm9fAe2CdPaAbL86/sZblU2BjT8CxdlilU\u002BG4FWRc4syjA\u002B5608dsy5AxFzRq2OjKyxB0oEjEctXYARRT0ueHvvH2ne/XlSZcfDxGHC3vcEq\u002Bk3v6z/4o/TXYvAZ73gOUvaLUcl5yzpUk\u002Bi9\u002BbLRl\u002BbXVjSPkcTwBHNcAjbUded17Yog2rwUic/Mt5rYYHytdrctYlVNRJ3m2U12b4V9K7KSk8tJ4Px1FARQuThIWnT\u002BQf1gytC37Ndi8BnvfAwoNx/DJGrBh4OgBjuwwx5hD1waAQusNsbbasCZMTEn6RJ3Q6Pe6b8ZQAy7yNJDU66TOWLaqkeUoFmveDVMp4fyB/IPS5jXwHli\u002BB3Tuxr4aXSMiLrlhtcJRAceW1Iwuq51z3U2zb0NcAgRKwOzq0LkUMPo6e6qcVe3J\u002B3po9IVOgAvqxqVsIAkqN4N8DJSIa9wJqwOfzWvgPeA9sIc9wE0XuWecjVPnUV2qjhs4AnBcAhpLQxVjQBB/xh27NkVbhaFJmY3TjQDS5\u002BDngEEbsmgAoJbZLrG\u002BoWjl/3j\u002BjSc62sU\u002BGDF80tUPgzAHMUg\u002BcnExVMhTWvMPOZvXwHtgIxNfm/O17sI85CsMHCvmcExRHkRp\u002BtnQGzGUozDk/IfCDXFmB3fscROphKkORfSpCbFR1lD4ojh38iC\u002B\u002BLHeNuF6nFK9MfJ4BYqe/Uor37XTVkMHT1WAFPAIYRIBVvu9JhmUtdAgIcACJeOZ772VfmHbvAbeA94D2XuAM1H\u002BIbYknwMbBo6Vk0azN4XNa\u002BA94D3gPeA9cMkeoPQfZTf2Eopj5Ptgw8Bx4yoVKxxWOHzY\u002B7D3HvAeONoesMKxwbJY53A4hyP7YLB5DbwHvAeuvQecw7EB4HCViqtUll7ArlKxkzAoeA/saQ\u002B4SmUDwOE\u002BHO7DMffCdR\u002BO/MPT5jXwHrhc9XAfDncadadRdxr1YTrhwHSnUe8TQ8fle8CdRj1L5YmeHZ6l4lkqPlyflIi9JoYO74Hle8CzVDYwvM3TYnMGuXlarJ3H1IPS02K9Vwwa6\u002B8BT4u9MnBMgY5LQWTNaXbXmOWyhl1rfsucrqVty/OmkRjNwIhTlrr0pmEYDcj4XF1Kucj4vVjDrtH2bU178/v6GY/J1FhJkbbbrwGD9DzPxnvP1956e4CxEGoWpi7WsdP00hH1Z5wUuzpwbMW2CCDXGBo3BhzqYKqJsVw8QAQWu5reGWHfdC797IvvPoYKfa/5fmmeUw2H47EZBge4eER9jsMDEIE9T\u002Bw1cBg41t0D9Zn2Gx/5QbnR4nxdCh0Gjgo49g4dfeCxBQC5FnTULdbVBp7psLHDXuy01zUADqWiqBgd4\u002B7jLJeuibRAB0rIV9990QffjZwfFUJ0VcQ4HL32hg5Dx/X2QFE87t8rIUzO2j7oMHBMUDh\u002B9wcfORR0TIGPTABZCzpiFUuZy9KoFAKHvomzXZNnUUI0DK8LSu5AR4ANlBNkfR92t3N4TAXm/QLyMD73\u002Bhs4vAfWv8443\u002BKN2JTwytrQcbiQylGBYyp4ZIPIUujQYLsCG02YAwck2OgbONdlfVBSgwmPDeXHnBDAgyFyn3r4uXKhoq4QbvEBuL4TfObNdx596Du/XxQpGe\u002B519rA4T1wvT2gUHO8AdOEcoHHFOgwcHQAxxGhYwl4ZMDIXOAQEChHQ3DQBxZ9yae1YjIEH0/kfzRTfJVI\u002Bucf/B3ndVzh0PvpL/y4fOS9JowlAzrsbAwc3gPX2QOELNVRmZsp8jn4/OPvvFkUZRTGa0LH4RSOOo/jyNCxFnjcAlDGymWlcOBwFOaoYWMIMJaARx1mifkcVLPwWp7\u002B5vd9\u002BK144H3ivS\u002BVSZioSAAGUMdaY3zO9zgIBSQ2r4H3wHUHwnHdAf58XSr5QjLpGHRsGTjWho1e4OhSOY4MHbcGj0uhpAYPNjcOP4ZLYlO0KRUuS6CjCzhQNz7/8JMlgRHaJ6fj197/OR96K4ZRuKsCMFCRogk8OPz4PTsaw4b3wPX2wM/\u002BysPyMY6953O\u002B3wUcXdBh4BgAjqNDx5bAYyqEDIVZ5toc6Ii5HG1Ipen1AWgAHNyNc0GSw/HUt77bXqh8zp2BD8P5h5xCKIBdlwEeT337O15bw4b3wLUnzYZcjpiUP7dHhxWOAeAweGwXQMbyPC6Bjxo4BBpIiEj4sTkYeRtckKgblGxygRJeIQygPiDO7Zh2sLGGCpEINNR0rTZBB9DH\u002BqKEOGHXjtfwdd09IOCoFeZ4nho4FoRUDB37gI\u002B1enz09fmI/T1KzkhHvw41CkPaB0RiZ1OV0OpOXEqI7W/XgLCU8l9Yq3YNY\u002BM1fR7t/r1SIcQQN6\u002Br95Svqev344h9jYbC2mtAx57zNwaBw9BxTvAYSkzFBBvqXlr36rhjtEFv\u002BnXEsAuqh3IOyO/AocZyzrPnfBCSwgAylI0WNGLX1y5roEMqB9ABFD739tfT/yeb1\u002BBoe0CJorQG4CM3V1x/qIuACNeucuvqnA4Dx0KV4ywhFoNHd5OxsfLZroZhsZolltAq1EL\u002BB\u002BEAhWOObiSZ0R2U8IdUCSWCtiGU0Iq\u002Bz2IeTfb/ZPManHEPkJtGiSyfU01Wcj2aZPo1oOOQCscS4DgLdOwBPC5pKjZF8ejL7ZhSPlv37CAvgYuT0AywcaZyzgJVP/pa\u002Bb9ReAAtEmo1LKqvtXxXx9caOjjgCMkwW4XnUUa9zWvgPXC9PUCPDq5jbp4Aj3LT0HQl5WzcA3BcCzZGgWMJdBg8zgEdfVUtXeARq1nadujV/JUlHUnJc9jD4QlUPP1nf9SZEKqJvPwvhFEArxiKGoKNwRk3D15u11Yg51LZ/L1gO3ZTsDJ5uznfpDRyHZZSdvokhXyOJX05DBwGj91BxyXgMbeMNrZBp0qFMIkaVimEgi0pk\u002BUOggtZ3f62BCEoCvF18T8CFKxBnOjKhNeu/IzB3JgOu6MgPXi5PG72Gti8BkfeA5xf5G\u002Bow2\u002B5lrmJ6rgRKDcSTUOwSxqBHRY4LlU5rHjkA8cWoEMltDEHIeYayCkj/z/zvbcW3VVojogGl33oG28XxYSP8XfXrNpQBcnQsDrBVewEqnknVJIoGbSeyTA0ZC/2QOkDD0EHd1Ycgkpqw37xlxxayXZUtmOsAdd3vFmIlSpPhJRRG0dCKlOgY8\u002BwcTPgcKhlu9CxBDymNgzraxQmp4gz1LwV7ha4\u002B48NwoZM4QHipXLoCkfwmLrDZ7iZ/obHB0SIswInwMpYzghARNUMv4\u002BzVgUNj6UE167H4Hf5uUrnMJ4b2Gj7aXQoGUOgMRU\u002BYq4Mz8NaaY35n7MPapvX4Ah7gGuKm5oaNvrGSawxX8XAYfjYRXLpmtCxBDjGupMCC1RqDCkGOHapFDh6JZzWzbDU3pufCTxKiVpT\u002BaFwDtAwdqgIgPgbPvI6YyvxGB6K4RKcPNAR2x6XnIyXXiu5KiV7nf99IWgMgUe9viqT1ZrYvAbeA\u002BvtAc6ivtEScxqAGTgahWNNleOMqscegGMqdExtiT5UsVKHVpQ8ihPGaauigo8kXKonBc5eeRt8r03QqnpRcBfP5MYCIFWTLJwv4QyFX1R6y/fiIcLzAD\u002BaXdICTdVkK5b18vooheNjeW0hntulRGhN\u002BsqMp9jQBF/\u002BB1enGC4MF\u002BvuAW5i4s1O7LcxBziWQMeewymjIZVrhFXODB97gI4x8JjajXQINhRGUchBCofapOMocfTckQMOpWojtO0GKOIcg7FS0WhSOur\u002BHzHX4Rd\u002B/IUCIAIHJYN15aHwc4BErcR5fUPx3D7AGKv4mQMfPC\u002BvmcoYFBqpQl19Tc5UhmzzGqyxB2ikx1mhm5m\u002BWSoGjguA41bQcQb42DpwDEHHEHD0Ob\u002BuPhw4fHUXRUkAIjSYDIlSY5/l6Nu\u002BFK\u002B82oYjVN/eF5aon/uJPiChLBe44HlRBOSgAQheYxfYKDGTn/N6lABatznuAow5k3rnQkiX0qHuhwAIH/lfVSFkJ2wn7D0wfQ8QMlVYNTbli4mjU9ubb0nhuJVf3yxwnAE\u002B9gYdQ/NWorOLVSlywlIEuPPmYiWZE0fdOusAAYIKoKMoG8GJy5GW6o\u002BJuQ9Djcd4fB6LvArUFakvgEd87j6Y4XEUCkJBIA9FuRlrgsYc8OgCLl6nDs2Yy\u002BLyWQOHgWPaHujqpROrvlBjCdWiGnJtxRuOvsTRueWxhweOrUCH4eP2wFFDRx9sFMfbDDHCsXH3jFpwBzbu3ytOuVRtNImitSJwBwru3yuPIRWjLyQxpdupHG98LQrRABu8LlQWDpSYyDr23HUVDv83fz\u002BUqb62zYGO\u002Bv/XYalqHTcGM3wYPpbvAa6ncq6F8CsfSz5ZOBfGWpzfGjhu6b9nA8cWoOPIALJllaNL1YgOl4sKwh8Ld8j59ykURR0g6fL1F3onME51wlHh4HUhiUoOVTOutjNnRx\u002BMseedmo9RJ4\u002BN2aXg0Rda4f8msZX/HVWn7lVi8xp4DyzbA7GkX\u002BX6nDV8X\u002BGXoi6i0C5sc27g2AB0HBVAtgYdXfkaCqEoH0BzQGIYYkmSZPm7Z7/SmYQ11wnrNSJzAhzxDqQruXOJMjEHJi6xOaAl4KD7qtosK4SCokNyrJ2LAcN7YHgPUCk3d41UHUaYFYWDXK/6bIxKRxZw3No/T1I4tq5yHB1AsoFD0IEjK8pEU4lRKjaaZMmYXNnVAOdWDrzLCd9RVZq7jlqFmQIac17H3EqfNQGk/p8BLMI96hGiKiBVrcROpOVO7Y//9E77dZvX4Mx7YEqeE9cQoUkUU/LTSDhXVV3p09GcOVI4MM5MrlcDx4GA42gQkqlydCkcMVxRmm6NdNub6riX9gQZSmiNeRbK31B/jbFytkte59KGapfAR1R24v\u002BpmQ/ABkmyqlpRu3bUEE3u5c7MeR35Ds\u002BWtwZcJ5wZaponEKcvkH6H60aVK3XVCucioUtV3cVk8r5cjqEze8/qxkUKx96hY\u002B8Qcmvo6MvhKI686aNRl4Xd2nFPAY/oiMvrbQ4JlIApr3VNwLgGgPSFlOpcFjU\u002Bw2KJn\u002B7KkJHVxt3mNTjzHmjPiFdeLeoFOVCxfxC/U7oKdwxu41pTl2IUDc6ZOpxSKxwGjpNAx15B5FbA0QUdODGVt3I3LLKPBJ/hvIecsZJSlbw1Bka3hIxbgAfqhapV2i6q4Y5M7eE1WC/Cic1rcLY9gJoRm/6p\u002BgToIIyir3sHt3352VIFxpmDUqLzUuGUDODI8qeTFY6zAsfeQOQa0NHn/KLKoTkhMaSyJQf\u002BxOt\u002B9iu9oJENGGvBx5SE0ljFUrebl9rBWnF35sFv\u002Bc7Pdps10OiEroZ\u002BS0YTSFUtHY6bx9FZMxU2Tgccho59gcha4FE7vNqJ6UJS2aUkw\u002BjMsx310ewa0KEwE7ChKbvqBmvzGpxtDxBSVLixLuOfmwyvfDfBSxds3Ao4Mm/UVwGOMysdU20v8DEGHFw8XHBKQqRpFhdQ3feCjzVozK2M2YNtHTqGSoX7GoNx2FIy6x4d\u002BU7PlrcG5DFpUKTyM4aq2aZci315G1NgYw3gyPaDqwGHoWM/MDIXPGp1Yyz34ciAsUVAuTSRlMNVc2tUvaPZK3Z4dvpn2ANUkqDmUXHC19xIqRwWtY/qlHhjdWlCfAZs7BI4rHIcTxWZAh5ybDHLus8BZjv1Pdo1waOvR4dCKORpcBdHsyINstMd3tQDm8z9bKdh8xos3QPARtuWvCkfBzQIqfBznXuXJpn3Xf9nUDdWBw6rHPuHkCngsRXIuKQ6Z\u002B3E2q2AyBTg0Ph6VAzu3DSwjoOVxFD6b9TNwIbUDhob8ff0GvC4ezv9vYAf3Xbj1/SliR2IZeztKbCxBDTmnGOnBQ5Dx3kgJNMB3xIo9gIhS8GjqyGYGqBRqhcrUAAOxa75nDu9T7z3pfI1UMFsCEqgkZ3L1N7n3yjQQodSlRE6HJPvUG3ja6B\u002BGqWPRuiUPDRPaW5V21pn3N5h42rAYaUj/429dehljiPOBoWjQMgc6IjqBpABIGiMNrCB0gFARIUCmVlAoYM5DqgCLGLDMI28d2WLnf3WYad0/WwmVteqxtjwxjVUjVsCR7YvWgU4DB35b142hGQ7963bVuAjQkec7Ku5DsrnKMPt3v\u002B5Ag/IzagYKB2aA6HmYHUDpHba7kuvdYZjMCXj2bwGWXsAhY7wSITokrNRQccawLEV2DBwbMDx2gwgZ4SPKb05BB\u002BEUDicUT3isLu6dXMsp\u002BUg/\u002Bq7L5bD/bm3v17mUJArwuc8jjqX2rwGt9oD7L2hnwPW2ufs1dJ5F3juGEK5JnDMPT\u002BOABsXKxxWOfLfQCsg\u002BUrGVuFjSpilr2RWigWHsJofxS6LtbUD/JrJwYRpyPTXnaQqXzCqYTS\u002B2\u002BY12MIeIE8J6CCfqYQJG\u002BWOn7Gn\u002B5JGb6FuLAWObH\u002BSAhzO59ivOQRzDPhYUkI71qa5y2LH0jtt0kOrdFQQTHefrmrJd7a2u2E/cpMAbeU1sV\u002BpYIn9hy4BjrPCxirAYejIfxP3CB/OAbkteMzt3TEUehmCjxo6IngosRTVgztJpGzi6hzu5H4YPuz8l8JPTGZew5QwzeeEF9UMD/AYgw4DxwaAw0pHPjRsHUAMITngsbQ9ehd0dA6Ee/ByKacljEJo5TMPP13uIn1nbcBYYw8AAWuDCwnUmg/VwnLI6xgKrVjduDJwGDryAeCI8HF2CNmq2jGUcFrnc2DK36B6Ze07Uds514BqKj7G3KC1jDAKyaSlgiVUr4z15ZgCHdcOp2T7gs0Bh5WO/Dd9z/BxRgDZA3TEZmJABiETcjT4yCHOIDiUjUscwTPfeyvd0dm2oWYoIRlTl9w1DBWOEB9Jz6gc5HXU6sZQuewQcJwdNlYFDkNH/pt5Rvg4E4TcCjqmJJX2zWfhrpDDWoOvyM3Q9Nk1HAIdIeV0GLhlADg2BAGp9fsMbGjmieaekOC5duWTQoCE/0qlVujTURroNeFC7fl6QvYlwHFE2FgdOAwd\u002BW/oVu2W8HF0CMmEjjHgKCrH/XutssGclXJo//GfFlhYwxFwR8tjurfHOUxqRptT0YQ5YtM5VUCt\u002BbxADOBcP69AQ1UsdW6HgWOjwOHwSj4InAU\u002BjgghGdAxFFJRWIU8DVWkqCqFhkoAxyUzVpT/AcxIThfY0GSMj24utl8jsZiqpRg\u002Bk7ohVaNuPifnj8JAZ1zldgClmPZFfA6Mz3ku9o9\u002Bh7wNfgYko27wmDz/x995s/ye\u002BtHwWoAbqRuxR0edz7FE4TiqunEV4DB05L\u002Bpe7JM\u002BDgKhNwSOvoSRjv7cDQHMneB7cj7ygHMNfVF0AwX9fcAPmIIR0apLfkj2c7U1q0gkCNBCepzv/5ecfJqEifg\u002BPzDT7a/z54aajwn1UMty9kTAlxAQnuHn7MvtH/4GsigSy4QIrgRLGs4IX8PxKjzbt2NdAg4DBsbAg4rHfmOfwuWDR57h5BrAUfdFl2g0XXo1\u002BWwHNaX9tdAtcAhcAfKR1UPRMMJxIm3SlDlzlSqB3\u002BrWL/zP7YFQV1zTfgIKAg0\u002BhrOdZVkx1ALjwF08r6zB2Krfj1PnA/0xKygptKK/SVIAUDUxC5Ws\u002Bh1CjrmAMeRlY2rAsdS6DB45G\u002BIrVg2cOwVQq6tcgAa3JGqcZdi59z9tQ4jdBfloOZ3\u002BRvaR/c5HBwBDZaAA6CA8ImSAJG0Ywy/1zk0z99OAg1TbFXJMFQpc0m4x/bkGqih25S1AQBL74tqamtXZchQAzoBB8/NntKMFB5bpa6EXsoAt\u002BrxO8Hlwcvl79jHgp848LBAdfMYSppeAhxngI2rAoehI//NPYJlQ8ZeIeRawCGFoxy6goAmS5\u002BDnINduRuSpJGpObRx6GT8I5Pz\u002B3wPwFAJon4fZ4CTUKtp/p6/wSmRB1I7pC5pPd6d8hgaKjdmPBeOyl1Pp0FUTAYWIPIeA5lK4hxr8sZ68/7GHI0h0BjrB8N7p7BI3BdAZz2QbUoDO/aQlAxeX3xdXa\u002BlPNeM/I2zwMZmgcNKR/7G2KJlw8UeIWQt6OjK4aglbKAB09RN5W6oRBbj0Od31Dqau8Y7ykhT4YKhckgd6XJEXQ6ijufHCgZgAvWkT\u002BXgZ3p9fI3iotyTqcByZCP/pk\u002BxKM68yXeIuTXKnwECukJZgkr9rcIUarTVBxlr22ATO/ZRKL/tytlYmix6Jti4OnAYOvLf4CNaNlDsEUIuhY6\u002BpNEoYytvA0eD08YJcfcLVAgwoipSlxp2Gc5oagw/wgd/o/kXOEpeg8IqsYEYP6OvBz9HaYnJqCguPAavHTWmy8l2NZ2qnfLP/Ohr6bAwZDGUBBhEQJSpMkNhMYFCLH0GKJ7Ir2nyH5TUy2Pw\u002B6y51kW5N7wXbQnq/Xu9oDHWin\u002BKjcEHeygCtDrlFrVj5iwVw8YNgcPQke\u002Bgj2zZILE3CFkbOKLsrHH23AWicOBkcC6oFBzcfJ9Du23c1ABHX8x\u002BSuy\u002BTxpXPF1VCQIG3WHTBRVHJxCKvRZkqpBAqdHfEEKQo1UICMWEcJK\u002Bz//L/6qveQ59zmOpLFPOVooQoILTRRVYktTK6wAEgAYei8fEsatRmv4PXo9erwbo6TH4G94rvUZUHn6H/0eqRbsW3/pu\u002BTnPpbyZOsfhjtp0/155Loz/j1AL1SlADK\u002BJv4/KxiWQsaSTbr2/VeathFaBbwQOw8bBgMMhlnynvgfLhoe9Qcgc6IgHch1KwQHhNHFUKk9tFYpXXi2OXneGioXjcHB\u002Bn3r4uceqQoeCMRSznyOLC4ZU1qh8A5y6wifcmfOzusdD6yzv32t7M\u002Bhu/s6d/IOXy/\u002BkcFDsgskaxJ4kqm5Q0q2SGq\u002BlXgAfvDaeS\u002BBXHHvTU0JWJ/zyvQIZVZIu/y\u002BAwePRPZb3j8cDlLqAUe8B66ZQVkzkVb6O1ov90jUcbS5YzLE\u002B8Gj3EWGi11943IsjwNRQk68\u002BZWPJmZB9tu4OOAwd\u002BW/0mSwbGvYGIGPQEQ9h3fXhLErWf6NQdDkanJUceZ1fgZPhICe8oVkVQ3MqlsTjeSyeS3kkvB5et5yqwj7xf4j/R1fJ75DdSVZtQEThATlZNSuLJby3MkIYhE8EHC0YBWVH/0P8v7oSc1WKPLWSpAaQ9r14/YUnEjmHQGMNwJjT5C7Ca1mXZt30Og0bGwUOQ0e\u002BIz6bZcPC3iBkCDo4YMud8OsvlANYzqnE54PM3Kc41MqDDm/uots77Ndf6J3EuSQuX6sxdaimLwl1qHqhBpEuIKmrZAQc15hsujRfgzBL7H0xBhpduTJj1SNDylOs/Bh6v68JGGPQodfEawYQWZP4f9Slr2uqGkdTNlKAYy3ocBVL/sbZm2VDwp4ApA88\u002Bu4Ap4LBEyDQSNWCGEnpayQFTk0QnAJJU8onOxufNSoAeQoZisYU43VJaRjqgTEXMqascxdkjAHBJX1m5paBx73K\u002B7hkOJth4yDAYejId\u002BJ7tGw42BuAzDmk58bHSyXJQjWjyzldA0KmQEddRhnVDRyVKmFiwmm21b0xUGDKDJye8uM5oHHJe3hNwJgLIdon9Wu9RgXK0ZUNDJ99c\u002BAwdOS/8bZzgMeaALLkLnFrdgmATFU6AA0qMNSOPVakbNWkvqB0UDEyt/HWpaCYDRlzbeg6ufRaPeLZHEWCFOBYGzqsduRvqj1bNhDsDUBudYe5RfgYAg6pG\u002BQmUC6KE6dChCqdbKgYMnJLSlvxJkenVjn6YGMtwNgqZMy5Jta4LrPPwWtY7afTgMPQkb8ZbOcGjzUAJMMRZMNHV\u002BVFX/OzLSSKTjF1BC2dX0PVihKClyTzbhU01gw5rnUNHu0s7hMGDgUcVjvyN9oRLBsAsm1P8LEWgMzJ\u002BeiquImlsIQoqL4hWTQbJKYaOSaEgtQoTD1CuhpxbRU01gSJW4HGmWAjHTgMHfmbw2b4sPoxX\u002B2IlTYYDbbUCp3kS5qakcex9bbmNEIDLNR2HkWGMBDqDI29yv9XlT1fAhprwMYtweKaoHE02JgiBqQDxzWhw7kd\u002BZvwKJatOmzBLj2s96J8TIGOGFIpjazu3yvhCJQN4EPdREkcRfHYUoVKG0bRXJSmGdidRmChlfcUheNaoJEJF9eCjLPCxmaA49rQYfDI35BHsWynvxVb4yDfMnzMgQ6AQwPqcOClu2rTWRTFQLNMtmaU69KundeNolHasg8kjS5tzrU3wDBorA8amwMOQ0e\u002BM7UZPo4OIGsDRz23pR1n3qgEceIs6gehlmzQ6DKUGA26A5KGgKOrN8UlsHEGyDiaqrH0xn9TwHEL6LDakb9Zj2bZasOWbM3DPxs85gBHXRpbKjyakezkdGQDxZhpMi4qh8pj646jXSrHJbBxFsgwbGwYOG4FHQaPfEd9NMt29luzaziHW4LHnGqVWLFCzgYhCkCDcfHZMFEnifL6SAwld4PPgQwluJJrgtpR5qxooNsrr5afqxX9GHDsATRufS1kn01r2Bo\u002B99TAYejI38RHtWxnvzW7luO4JnSMwUZf4y\u002BFUtSDA2cNeDzz5jvlaz6ntTjOnkqQW8EGuSSxGgUoKiPom2RRwIKfkYOiibL8Tfk8qB0CjrmwcTbIMGjsBDhuDR0Gj3wHfVTLdvRbtGs6lbXBows2yrRcoCI44TtD2\u002B7fe/TZF98tVSAkk6IY4Nwx\u002Blt8/J03i6rA1/zONQe78dwAD8pGq1w0k3n1ehUC4rX91k/9ZQGMElZhFkyVyzGkbmwRNDL3efbZs4at7Wc3CxwZ0GHwyN/gR7VsJ79Fu4XDWQM8cK4xOVR9K5QMWqo7SBJtDGfN7/B9HHxx3io5VdlpSCpFbdDYeEIdOH1VkUR4\u002BMzDT/eCBeEb4AZ4AS6ABx5LSoXKdPkapSWOoRcsAUBKbuXzGjiG8je2BBvZ\u002B/oIsHEt/7pp4MiCDoNH/oY/smUfhluzWzmipdBRT7hV6\u002B\u002B2KuXBy22IgrkpOG2cPU6ej12Kgh6Hv0ENUbMt4AD4AA74ex4XeJmaC8Lzo2QINFrAIR\u002BjeR1do\u002Bf1vfLaXnm1PIam3WqgG/9/VznsFkAjew8bNA4CHJnQYfDId85HtuzDcYu2NfjoC610DXGLThtnDShgCr/IusIwQEqtkgANah72zPfemgQcwMFzb3\u002B9qB0lyZPHap6ja\u002Bz8mGkCLsbXW4KN7L16JNi4lT/dBXBkQ4fBI/\u002BCOLJlH5RbtFveHS8Bj6FEUikXQINKTLtUhTrhNJbUAikKcSjEMtXo9dFOf23UjaEJsF0j5\u002Bv/C6WDz8fCKGeDDIPGQYFjC9Bh8Mh3zke37MPzzOAxBh9TKlhw5jh3cij6nPvYePvYOAyVgscq6sU33u5tkU7IhYoSFJVf/vn32yFsgAfdRPlI2OaScfNZ\u002BRrZe/CIsJHlP3cFHFuBDoNH/gVzdMs\u002BSLdmWwWPLgAZUgvqcExMRlXPC5WlAgmER1Q9oiRVfl4nlKqfRpu30cCKElq7qk2WTH69Zdlr9p4zaBg4NgUdBo98x3wGyz5ct2RbAY\u002B5XUrH4AMYACziqHvgAZBQ4icwQqUKVSSABAoG/TxUdku30JigykflkEhxmQobWT02svfXUWEj20/uVuHYmtJh8Mi/mM5i2Qftlmyv4DEEIfUEWlQNTW2N/TIEH4ROPvHel8rvFWWjqp5R3sgcZWNJm3KDRv7ZsHXQ2D1wbBE6sOwNZjvHGmQ7/K3YHqBjDDyGql8iNGgwHKqGymZ/\u002Bgs/Ln03KJ/F6vCJbGzi6yVzUY6sZmDZ1/oSy/aDhwSOrUKHwSP/gjuLZR/GW7EjgMdQjkedWEq30jiJtm0u1gEcU5JEbwkb2XvlqLCR7fNOARxbhg7DR/5FeCbLPpyz7ajQMVTNQv4GYZXSjryn38YYcCyFjbnAkb0/DBr5vvAQwLEH6MCyHZLtPGuQfWCfATyuAR1D02g7y2ebfh2x50bM4RgCjqEhbGvCRvZ\u002BMGjk\u002B77DAcdeoMPgke\u002BMz2TZB/hZoWMMPJYAR\u002BxkShiFkIq6iWIkkRJiUWlsVD3GcjmuEUrJ3gMGjXxfd2jg2Bt4GD7yHfKZLPtQPyp4rAkdfZUrd6pUXn\u002BhtDovnUy/\u002BLEnWqULOvh5KattVA/9ntqdR/hYs\u002BdG9nt\u002BRNDI9lMGjgNBh8Ej/4I\u002Bm2Uf9EcCj7HQw9JeHbFSpeRmvPRamZECUNBXQy3T62qUOvyC2qFeHWoUJvWDj5oEe2lH0ez32KCxfTukwrFX6DB85DviM1r24X8G6OgCjzk9OVAlNMFVDcLq3hpdCadF0SC8InUjqB1STLoagvUBx95hI/tam2LZ/sfAcULowLI3vu1ca5DtDPYMHpcAx9gE2hIqeeXVolB0NfFaYvF5AA/mrKCg6Dm7gMOgYci41KcdWuE4AnQYPvId8RktGwrOAB0CjnqSbBtC0QTZV14tTb1KbsaXn70DHHNnodSqCo/D4/OcdS7HEGxkv39TLPsammLZfuXWdgrgOAp0GD7yD4gzWrbj2At4TAGOCB1y\u002BhpprwmxwAUTXgmdABnkWdS5GnM7hQ7lkPAaFFapYWOPqkb29TJm2f7DwGHoMHhs4CCwnRNAsoFDSgNOPyZ2YqX0dWDo2tRy1jHwEGwYNAwZVjisdlj1MBBsEoiyYWFr4DEVOOqwRpsc2gxr46PKXJW70VW\u002BOhVyptrekkKz97\u002BVDIdUThdiccgl/3CxHQc\u002BrgkcXeGMtpqkqSDp6g56LdAY\u002B3\u002By3wtDxrHsNDkcZ4MOmR2hYcDqx\u002B3AYwlwtHkcDXCMdQK9FlxsGTS2fI5ln/F7slMDx1mgI1r2xWk75xpkO6xLbC3gGCqHlcLRVYEyFzYuUWiy19qQke8jDBwGD8PHBpym7dwAcgl09FWHPNG6/MHLszp/rpXwmr22si1fY9nO\u002Bgh2eoXjzGpHtOyL2XbuNTgaeAzBBpBREkSbLp98jGqHOoHydZe6sRZkbAU0svfekGWfy0czA8fJQyx9ln2h27wGewePuiolKhp9A9PqcMs1QCN73bZ8bWefu0c3A4dzOwwfGzjobONrsEf4EHAAGm2I5IO/M2lI2lHUjK3v7WwnfCYzcDihdPamyT4gbF6DrUPI3ITSo\u002BRl7OHazHa6ZzYDh6tYLt5E2QeIzWtQ74E9AMjaZrgwYGQDhYHD5bM33VB2/nb\u002BW94D2eBxCZBkvr7s980KRj4sGDjcsyN9AxlC8g9Y27FhxFAx/L5ln282KxxuFrbhC8EO0pCw5z2QDQZHVyjGLPv8si1fA\u002BdwuENp\u002BgWUfYDZvAbeA9vcA9lnk23dNTBwuDX6Ji\u002Bq7IPO5jXwHjBgZJ\u002BDRzMDh\u002BexpG9CQ4idu537\u002BfZA9nliM3CkD3PzILh9XYjZh6bNa\u002BA9YLjIPgf3YlY4DB3pm9AQYqdtp32MPZB97du2vQYGDqsd6ZvQIJLvKGxeA8NF/hl1dDNwOMTy6OxmZ2tn6z1g1SL7HDqDGTic25G\u002BCbdsdkSGkTPsgezrzHaONTBwOKk0fRPu1bKdhM1rYKjIPwdsBo70ihWPvfeFaCAwEFil8DlgILHCYfDwQbCJg8BQYihxyCP/OrTdZg0cUrHi4YttBweOweSYYJK9r2xeg1vuAQOHwyw\u002BdA566GQ707NZ9vtt8xpsfQ8YODZk2ZvB5jU4I7h433vfew8YONIBwODhg8gHkfeA94D3gPeAFY50MLDi4YPIB5H3gPeA94D3gEMq6cDgMIsPIh9E3gPeA94D3gPO4diVecP60PIe8B7wHvAe2PMecNLoBmDC4JF/Idi8Bt4D3gPeAwaOdCe/RfOF4cPRe8B7wHvAe2BPe8AKxwbgweCRfyHYvAbeA94D3gMfueoa/H/S76BhFZ10YwAAAABJRU5ErkJggg==\u0022 style=\u0022max-width:100%;height:auto;border-radius:6px;border:1px solid #30363d;box-shadow:0 4px 16px rgba(0,0,0,0.35);display:block;\u0022 alt=\u0022Synthesized Image\u0022 /\u003E\n\u003C/div\u003E", + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "
\n \"Synthesized\n
", "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, @@ -1869,13 +307,13 @@ { "Id": "cell-polyglot-python-md", "Type": 1, - "Source": "### 6. Python Analytics \u0026 Distribution Table (#!share)", + "Source": "### 6. Python Analytics & Distribution Table (#!share)", "OutputText": "", "ExecutionCount": null, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -1897,11 +335,11 @@ "Id": "cell-polyglot-python-code", "Type": 0, "Language": "python", - "Source": "#!share --from csharp clusterNodes --as nodes\n\nprint(f\u0022\uD83D\uDC0D Python Kernel received {len(nodes)} cluster nodes from C#:\u0022)\nfor n in nodes:\n print(f\u0022 \u2022 {n[\u0027Region\u0027]:\u003C16} CPU: {n[\u0027CpuPercent\u0027]:\u003E5.1f}% | RAM: {n[\u0027MemoryGb\u0027]:\u003E3.0f}GB | Status: {n[\u0027Status\u0027]}\u0022)\n\ncpus = [n[\u0027CpuPercent\u0027] for n in nodes]\nmean_cpu = sum(cpus) / len(cpus)\nvariance = sum((x - mean_cpu) ** 2 for x in cpus) / len(cpus)\nstd_dev = variance ** 0.5\n\nprint(f\u0022\\n\uD83D\uDCCA Python Statistical Moments:\u0022)\nprint(f\u0022 - Mean CPU Utilization: {mean_cpu:.2f}%\u0022)\nprint(f\u0022 - Population Std Dev: {std_dev:.2f}%\u0022)\n\n# Display summary metrics table directly from Python\ndisplay([\n {\u0022Metric\u0022: \u0022Monitored Nodes\u0022, \u0022Value\u0022: str(len(nodes)), \u0022Status\u0022: \u0022Optimal\u0022},\n {\u0022Metric\u0022: \u0022Mean CPU\u0022, \u0022Value\u0022: f\u0022{mean_cpu:.1f}%\u0022, \u0022Status\u0022: \u0022Optimal\u0022 if mean_cpu \u003C 70 else \u0022Warning\u0022},\n {\u0022Metric\u0022: \u0022CPU Std Dev\u0022, \u0022Value\u0022: f\u0022{std_dev:.1f}%\u0022, \u0022Status\u0022: \u0022Nominal\u0022},\n {\u0022Metric\u0022: \u0022Peak Load Node\u0022, \u0022Value\u0022: max(nodes, key=lambda x: x[\u0022CpuPercent\u0022])[\u0022NodeId\u0022], \u0022Status\u0022: \u0022Investigate\u0022}\n])", - "OutputText": "\uD83D\uDC0D Python Kernel received 6 cluster nodes from C#:\n \u2022 us-east-1 CPU: 24.5% | RAM: 64GB | Status: Optimal\n \u2022 us-west-2 CPU: 78.2% | RAM: 128GB | Status: Under Load\n \u2022 eu-west-1 CPU: 45.0% | RAM: 256GB | Status: Optimal\n \u2022 ap-southeast-1 CPU: 15.8% | RAM: 32GB | Status: Optimal\n \u2022 sa-east-1 CPU: 88.9% | RAM: 64GB | Status: Degraded\n \u2022 ap-northeast-1 CPU: 92.1% | RAM: 192GB | Status: Heavy Compute\n\n\uD83D\uDCCA Python Statistical Moments:\n - Mean CPU Utilization: 57.42%\n - Population Std Dev: 30.54%\n", - "ExecutionCount": 6, + "Source": "#!share --from csharp clusterNodes --as nodes\n\nprint(f\"🐍 Python Kernel received {len(nodes)} cluster nodes from C#:\")\nfor n in nodes:\n print(f\" • {n['Region']:<16} CPU: {n['CpuPercent']:>5.1f}% | RAM: {n['MemoryGb']:>3.0f}GB | Status: {n['Status']}\")\n\ncpus = [n['CpuPercent'] for n in nodes]\nmean_cpu = sum(cpus) / len(cpus)\nvariance = sum((x - mean_cpu) ** 2 for x in cpus) / len(cpus)\nstd_dev = variance ** 0.5\n\nprint(f\"\\n📊 Python Statistical Moments:\")\nprint(f\" - Mean CPU Utilization: {mean_cpu:.2f}%\")\nprint(f\" - Population Std Dev: {std_dev:.2f}%\")\n\n# Display summary metrics table directly from Python\ndisplay([\n {\"Metric\": \"Monitored Nodes\", \"Value\": str(len(nodes)), \"Status\": \"Optimal\"},\n {\"Metric\": \"Mean CPU\", \"Value\": f\"{mean_cpu:.1f}%\", \"Status\": \"Optimal\" if mean_cpu < 70 else \"Warning\"},\n {\"Metric\": \"CPU Std Dev\", \"Value\": f\"{std_dev:.1f}%\", \"Status\": \"Nominal\"},\n {\"Metric\": \"Peak Load Node\", \"Value\": max(nodes, key=lambda x: x[\"CpuPercent\"])[\"NodeId\"], \"Status\": \"Investigate\"}\n])", + "OutputText": "🐍 Python Kernel received 6 cluster nodes from C#:\n • us-east-1 CPU: 24.5% | RAM: 64GB | Status: Optimal\n • us-west-2 CPU: 78.2% | RAM: 128GB | Status: Under Load\n • eu-west-1 CPU: 45.0% | RAM: 256GB | Status: Optimal\n • ap-southeast-1 CPU: 15.8% | RAM: 32GB | Status: Optimal\n • sa-east-1 CPU: 88.9% | RAM: 64GB | Status: Degraded\n • ap-northeast-1 CPU: 92.1% | RAM: 192GB | Status: Heavy Compute\n\n📊 Python Statistical Moments:\n - Mean CPU Utilization: 57.42%\n - Population Std Dev: 30.54%\nRecords[4]", + "ExecutionCount": 14, "IsExecuting": false, - "ExecutionTimeText": "386 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, @@ -1911,115 +349,8 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "\u003Cdiv class=\u0022fry-tbl-container\u0022 id=\u0022fry_tbl_fdc0419f\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;margin:10px 0;max-width:960px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow:hidden;\u0022\u003E\n \u003Cstyle\u003E\n #fry_tbl_fdc0419f .fry-tbl-header {\n display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 8px 12px; background: var(--vscode-sideBar-background, #161b22);\n border-bottom: 1px solid var(--vscode-widget-border, #30363d);\n }\n #fry_tbl_fdc0419f .fry-tbl-toggle-btn {\n display: flex; align-items: center; gap: 8px; background: transparent;\n border: none; color: var(--vscode-foreground, #c9d1d9); cursor: pointer;\n padding: 0; font-size: 13px; font-weight: 600; text-align: left;\n }\n #fry_tbl_fdc0419f .fry-tbl-pill {\n font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-badge-background, #21262d); border: 1px solid var(--vscode-widget-border, #30363d);\n padding: 2px 8px; border-radius: 12px;\n }\n #fry_tbl_fdc0419f .fry-tbl-actions {\n display: flex; align-items: center; gap: 8px;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-wrap {\n position: relative; display: flex; align-items: center;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-input {\n background: var(--vscode-input-background, #0d1117);\n border: 1px solid var(--vscode-input-border, #30363d);\n color: var(--vscode-input-foreground, #c9d1d9);\n padding: 3px 8px 3px 24px; border-radius: 4px; font-size: 11.5px;\n width: 140px; transition: border-color 0.2s, width 0.2s;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-input:focus {\n outline: none; border-color: var(--vscode-focusBorder, #58a6ff); width: 180px;\n }\n #fry_tbl_fdc0419f .fry-tbl-search-icon {\n position: absolute; left: 7px; font-size: 11px; opacity: 0.6; pointer-events: none;\n }\n #fry_tbl_fdc0419f .fry-tbl-btn {\n background: var(--vscode-button-secondaryBackground, #21262d);\n color: var(--vscode-button-secondaryForeground, #c9d1d9);\n border: 1px solid var(--vscode-widget-border, #30363d);\n border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500;\n cursor: pointer; transition: background 0.15s;\n }\n #fry_tbl_fdc0419f .fry-tbl-btn:hover {\n background: var(--vscode-button-secondaryHoverBackground, #30363d);\n }\n #fry_tbl_fdc0419f .fry-tbl-scroll {\n max-height: 420px; overflow-y: auto; overflow-x: auto;\n }\n #fry_tbl_fdc0419f table.fry-tbl-grid {\n width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;\n }\n #fry_tbl_fdc0419f thead th {\n position: sticky; top: 0; z-index: 5; background: var(--vscode-sideBar-background, #161b22);\n padding: 8px 12px; border-bottom: 2px solid var(--vscode-widget-border, #30363d);\n font-weight: 600; color: var(--vscode-foreground, #c9d1d9); user-select: none;\n }\n #fry_tbl_fdc0419f thead th.sortable:hover {\n background: var(--vscode-list-hoverBackground, #21262d);\n }\n #fry_tbl_fdc0419f tbody tr.fry-data-row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.06);\n transition: background 0.12s;\n }\n #fry_tbl_fdc0419f tbody tr.fry-data-row:hover {\n background: var(--vscode-list-hoverBackground, rgba(56, 139, 253, 0.08)) !important;\n }\n #fry_tbl_fdc0419f td {\n padding: 7px 12px; vertical-align: middle;\n }\n #fry_tbl_fdc0419f .fry-num {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right; color: #79c0ff;\n }\n #fry_tbl_fdc0419f .fry-badge {\n display: inline-block; padding: 2px 7px; border-radius: 12px;\n font-size: 11px; font-weight: 500; letter-spacing: 0.2px;\n }\n #fry_tbl_fdc0419f .fry-badge-green {\n background: rgba(35, 134, 54, 0.2); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.4);\n }\n #fry_tbl_fdc0419f .fry-badge-amber {\n background: rgba(187, 128, 9, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4);\n }\n #fry_tbl_fdc0419f .fry-badge-red {\n background: rgba(218, 54, 51, 0.2); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.4);\n }\n #fry_tbl_fdc0419f .fry-badge-blue {\n background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.4);\n }\n #fry_tbl_fdc0419f .fry-expander-btn {\n background: transparent; border: none; cursor: pointer;\n color: var(--vscode-textLink-foreground, #58a6ff); font-size: 10px;\n padding: 3px 5px; border-radius: 4px; transition: transform 0.15s;\n }\n #fry_tbl_fdc0419f .fry-expander-btn:hover {\n background: rgba(255,255,255,0.1);\n }\n #fry_tbl_fdc0419f tr.fry-detail-row {\n background: rgba(0, 0, 0, 0.28);\n }\n #fry_tbl_fdc0419f .fry-prop-grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; margin-top: 6px;\n }\n #fry_tbl_fdc0419f .fry-prop-card {\n background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);\n border-radius: 4px; padding: 6px 10px;\n }\n #fry_tbl_fdc0419f .fry-prop-key {\n font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px;\n }\n #fry_tbl_fdc0419f .fry-prop-val {\n font-size: 12px; color: #c9d1d9; margin-top: 2px; word-break: break-word;\n }\n #fry_tbl_fdc0419f .fry-tbl-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px 12px; font-size: 11px; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-sideBar-background, #161b22);\n border-top: 1px solid var(--vscode-widget-border, #30363d);\n }\n \u003C/style\u003E\n\n \u003Cdiv class=\u0022fry-tbl-header\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-toggle-btn\u0022 onclick=\u0022fryTblToggle(\u0027fry_tbl_fdc0419f\u0027)\u0022 title=\u0022Click to collapse/expand table\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-chevron\u0022 style=\u0022color:#58a6ff;\u0022\u003E\u25BC\u003C/span\u003E\n \u003Cspan\u003E\uD83D\uDCCA Records[4]\u003C/span\u003E\n \u003Cspan class=\u0022fry-tbl-pill\u0022\u003E4 rows \u00D7 3 cols\u003C/span\u003E\n \u003C/button\u003E\n \u003Cdiv class=\u0022fry-tbl-actions\u0022\u003E\n \u003Cdiv class=\u0022fry-tbl-search-wrap\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-search-icon\u0022\u003E\uD83D\uDD0D\u003C/span\u003E\n \u003Cinput type=\u0022text\u0022 class=\u0022fry-tbl-search-input\u0022 placeholder=\u0022Filter rows...\u0022 oninput=\u0022fryTblFilter(\u0027fry_tbl_fdc0419f\u0027, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_fdc0419f\u0027, \u0027csv\u0027)\u0022 title=\u0022Copy as CSV\u0022\u003ECSV\u003C/button\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_fdc0419f\u0027, \u0027json\u0027)\u0022 title=\u0022Copy as JSON\u0022\u003EJSON\u003C/button\u003E\n \u003C/div\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-scroll\u0022\u003E\n \u003Ctable class=\u0022fry-tbl-grid\u0022\u003E\n \u003Cthead\u003E\n \u003Ctr\u003E\n \u003Cth style=\u0022width:36px;text-align:center;padding:8px 4px;\u0022\u003E\u003C/th\u003E\n \u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_fdc0419f\u0027, 0, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Metric\u0022\u003E\n Metric \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_fdc0419f\u0027, 1, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Value\u0022\u003E\n Value \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_fdc0419f\u0027, 2, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Status\u0022\u003E\n Status \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\n \u003C/tr\u003E\n \u003C/thead\u003E\n \u003Ctbody\u003E\n \u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00220\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 0)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EMonitored Nodes\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E6\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_0\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #1 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EMonitored Nodes\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E6\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00221\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 1)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EMean CPU\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E57.4%\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_1\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #2 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EMean CPU\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E57.4%\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003EOptimal\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00222\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 2)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ECPU Std Dev\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E30.5%\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ENominal\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_2\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #3 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ECPU Std Dev\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E30.5%\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ENominal\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00223\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_fdc0419f\u0027, 3)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EPeak Load Node\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-ai-06\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EInvestigate\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_fdc0419f_detail_3\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #4 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMetric\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EPeak Load Node\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EValue\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-ai-06\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EInvestigate\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\n \u003C/tbody\u003E\n \u003C/table\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-footer\u0022\u003E\n \u003Cspan id=\u0022fry_tbl_fdc0419f_counter\u0022\u003EShowing 4 of 4 rows\u003C/span\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10.5px;\u0022\u003EFrySharp Polyglot Data\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Cscript\u003E\n (function() {\n if (window.__fryTblInit) return;\n window.__fryTblInit = true;\n\n window.fryTblToggle = function(id) {\n var el = document.getElementById(id);\n if (!el) return;\n var body = el.querySelector(\u0027.fry-tbl-scroll\u0027);\n var footer = el.querySelector(\u0027.fry-tbl-footer\u0027);\n var chevron = el.querySelector(\u0027.fry-tbl-chevron\u0027);\n var isHidden = body.style.display === \u0027none\u0027;\n body.style.display = isHidden ? \u0027block\u0027 : \u0027none\u0027;\n footer.style.display = isHidden ? \u0027flex\u0027 : \u0027none\u0027;\n if (chevron) chevron.textContent = isHidden ? \u0027\u25BC\u0027 : \u0027\u25B6\u0027;\n };\n\n window.fryTblToggleRow = function(id, rowIdx) {\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rowIdx);\n var el = document.getElementById(id);\n if (!drawer || !el) return;\n var btn = el.querySelector(\u0027tr[data-row=\u0022\u0027 \u002B rowIdx \u002B \u0027\u0022] .fry-expander-btn\u0027);\n var isOpen = drawer.style.display !== \u0027none\u0027;\n drawer.style.display = isOpen ? \u0027none\u0027 : \u0027table-row\u0027;\n if (btn) btn.textContent = isOpen ? \u0027\u25B6\u0027 : \u0027\u25BC\u0027;\n };\n\n window.fryTblFilter = function(id, query) {\n var el = document.getElementById(id);\n if (!el) return;\n var q = (query || \u0027\u0027).toLowerCase().trim();\n var rows = el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027);\n var count = 0;\n rows.forEach(function(row) {\n var rIdx = row.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n var match = !q || row.innerText.toLowerCase().indexOf(q) !== -1;\n row.style.display = match ? \u0027\u0027 : \u0027none\u0027;\n if (!match \u0026\u0026 drawer) drawer.style.display = \u0027none\u0027;\n if (match) count\u002B\u002B;\n });\n var cnt = document.getElementById(id \u002B \u0027_counter\u0027);\n if (cnt) {\n cnt.textContent = q ? (\u0027Showing \u0027 \u002B count \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows (filtered)\u0027) : (\u0027Showing \u0027 \u002B rows.length \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows\u0027);\n }\n };\n\n window.fryTblSort = function(id, colIdx, isNum) {\n var el = document.getElementById(id);\n if (!el) return;\n var tbody = el.querySelector(\u0027tbody\u0027);\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var th = el.querySelectorAll(\u0027thead th\u0027)[colIdx \u002B 1];\n var dir = th.getAttribute(\u0027data-sort-dir\u0027) === \u0027asc\u0027 ? \u0027desc\u0027 : \u0027asc\u0027;\n\n el.querySelectorAll(\u0027thead th\u0027).forEach(function(h) {\n h.removeAttribute(\u0027data-sort-dir\u0027);\n var s = h.querySelector(\u0027.fry-sort-ind\u0027);\n if (s) s.textContent = \u0027\u2195\u0027;\n });\n\n th.setAttribute(\u0027data-sort-dir\u0027, dir);\n var ind = th.querySelector(\u0027.fry-sort-ind\u0027);\n if (ind) ind.textContent = dir === \u0027asc\u0027 ? \u0027\u25B2\u0027 : \u0027\u25BC\u0027;\n\n rows.sort(function(a, b) {\n var vA = a.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n var vB = b.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n if (isNum) {\n var nA = parseFloat(vA.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n var nB = parseFloat(vB.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n return dir === \u0027asc\u0027 ? nA - nB : nB - nA;\n }\n return dir === \u0027asc\u0027 ? vA.localeCompare(vB) : vB.localeCompare(vA);\n });\n\n rows.forEach(function(r) {\n var rIdx = r.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n tbody.appendChild(r);\n if (drawer) tbody.appendChild(drawer);\n });\n };\n\n window.fryTblCopy = function(id, format) {\n var el = document.getElementById(id);\n if (!el) return;\n var ths = Array.from(el.querySelectorAll(\u0027thead th\u0027)).slice(1);\n var headers = ths.map(function(h) { return h.innerText.replace(/[\u2195\u25B2\u25BC]/g, \u0027\u0027).trim(); });\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var data = rows.map(function(r) {\n return Array.from(r.children).slice(1).map(function(c) { return c.innerText.trim(); });\n });\n\n var payload = \u0027\u0027;\n if (format === \u0027json\u0027) {\n var list = data.map(function(r) {\n var o = {};\n headers.forEach(function(h, idx) { o[h] = r[idx]; });\n return o;\n });\n payload = JSON.stringify(list, null, 2);\n } else {\n payload = headers.join(\u0027,\u0027) \u002B \u0027\\n\u0027 \u002B data.map(function(r) {\n return r.map(function(val) { return \u0027\u0022\u0027 \u002B val.replace(/\u0022/g, \u0027\u0022\u0022\u0027) \u002B \u0027\u0022\u0027; }).join(\u0027,\u0027);\n }).join(\u0027\\n\u0027);\n }\n\n navigator.clipboard.writeText(payload).then(function() {\n var btn = el.querySelector(\u0027.fry-tbl-btn[onclick*=\u0022\u0027 \u002B format \u002B \u0027\u0022]\u0027);\n if (btn) {\n var prev = btn.textContent;\n btn.textContent = \u0027\u2713 Copied!\u0027;\n setTimeout(function() { btn.textContent = prev; }, 1500);\n }\n });\n };\n })();\n \u003C/script\u003E\n\u003C/div\u003E", - "TableSnapshot": { - "Title": "Records[4]", - "Label": null, - "Columns": [ - { - "Header": "Metric", - "IsNumeric": false - }, - { - "Header": "Value", - "IsNumeric": false - }, - { - "Header": "Status", - "IsNumeric": false - } - ], - "Rows": [ - { - "Cells": [ - { - "DisplayText": "Monitored Nodes", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "6", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Optimal", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "Mean CPU", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "57.4%", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Optimal", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "CPU Std Dev", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "30.5%", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Nominal", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "Peak Load Node", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-ai-06", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Investigate", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - } - ] - }, + "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Metric ↕\n\n Value ↕\n\n Status ↕\n
\n \n Monitored Nodes6Optimal
\n
\n
\n
🔍 Row #1 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Monitored Nodes
\n
\n
Value
\n
6
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n Mean CPU57.4%Optimal
\n
\n
\n
🔍 Row #2 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Mean CPU
\n
\n
Value
\n
57.4%
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n CPU Std Dev30.5%Nominal
\n
\n
\n
🔍 Row #3 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
CPU Std Dev
\n
\n
Value
\n
30.5%
\n
\n
Status
\n
Nominal
\n
\n
\n \n
\n
\n \n Peak Load Nodenode-ai-06Investigate
\n
\n
\n
🔍 Row #4 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Peak Load Node
\n
\n
Value
\n
node-ai-06
\n
\n
Status
\n
Investigate
\n
\n
\n \n
\n
\n
\n\n
\n Showing 4 of 4 rows\n FrySharp Polyglot Data\n
\n\n \n
", + "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, "HasImage": false, @@ -2037,7 +368,7 @@ "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -2059,11 +390,11 @@ "Id": "cell-polyglot-js-code", "Type": 0, "Language": "javascript", - "Source": "#!share --from csharp clusterNodes --as nodes\n\nconsole.log(\u0060\u26A1 Node.js Kernel received ${nodes.length} cluster nodes from C#!\u0060);\n\nconst highLoadNodes = nodes.filter(n =\u003E n.CpuPercent \u003E 70);\nconsole.log(\u0060\uD83D\uDEA8 High Load Nodes (\u003E70% CPU):\u0060);\nhighLoadNodes.forEach(n =\u003E console.log(\u0060 - [${n.Region}] ${n.NodeId} (${n.Role}): ${n.CpuPercent}% CPU\u0060));\n\nconst totalLatency = nodes.reduce((sum, n) =\u003E sum \u002B n.LatencyMs, 0);\nconst avgLatency = (totalLatency / nodes.length).toFixed(1);\nconsole.log(\u0060\\n\u23F1\uFE0F Aggregated JavaScript SLA Latency: ${avgLatency} ms\u0060);\n\n// Display filtered high-load cluster nodes as interactive table from Node.js\ndisplay(highLoadNodes);", - "OutputText": "\u26A1 Node.js Kernel received 6 cluster nodes from C#!\n\uD83D\uDEA8 High Load Nodes (\u003E70% CPU):\n - [us-west-2] node-worker-02 (Compute Shard): 78.2% CPU\n - [sa-east-1] node-cache-05 (Redis In-Memory): 88.9% CPU\n - [ap-northeast-1] node-ai-06 (Tensor Pipeline): 92.1% CPU\n\n\u23F1\uFE0F Aggregated JavaScript SLA Latency: 33.5 ms\n", - "ExecutionCount": 7, + "Source": "#!share --from csharp clusterNodes --as nodes\n\nconsole.log(`⚡ Node.js Kernel received ${nodes.length} cluster nodes from C#!`);\n\nconst highLoadNodes = nodes.filter(n => n.CpuPercent > 70);\nconsole.log(`🚨 High Load Nodes (>70% CPU):`);\nhighLoadNodes.forEach(n => console.log(` - [${n.Region}] ${n.NodeId} (${n.Role}): ${n.CpuPercent}% CPU`));\n\nconst totalLatency = nodes.reduce((sum, n) => sum + n.LatencyMs, 0);\nconst avgLatency = (totalLatency / nodes.length).toFixed(1);\nconsole.log(`\\n⏱️ Aggregated JavaScript SLA Latency: ${avgLatency} ms`);\n\n// Display filtered high-load cluster nodes as interactive table from Node.js\ndisplay(highLoadNodes);", + "OutputText": "⚡ Node.js Kernel received 6 cluster nodes from C#!\n🚨 High Load Nodes (>70% CPU):\n - [us-west-2] node-worker-02 (Compute Shard): 78.2% CPU\n - [sa-east-1] node-cache-05 (Redis In-Memory): 88.9% CPU\n - [ap-northeast-1] node-ai-06 (Tensor Pipeline): 92.1% CPU\n\n⏱️ Aggregated JavaScript SLA Latency: 33.5 ms\nArray[3]", + "ExecutionCount": 15, "IsExecuting": false, - "ExecutionTimeText": "224 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, @@ -2073,181 +404,8 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "\u003Cdiv class=\u0022fry-tbl-container\u0022 id=\u0022fry_tbl_29ce62e8\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;margin:10px 0;max-width:960px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow:hidden;\u0022\u003E\n \u003Cstyle\u003E\n #fry_tbl_29ce62e8 .fry-tbl-header {\n display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 8px 12px; background: var(--vscode-sideBar-background, #161b22);\n border-bottom: 1px solid var(--vscode-widget-border, #30363d);\n }\n #fry_tbl_29ce62e8 .fry-tbl-toggle-btn {\n display: flex; align-items: center; gap: 8px; background: transparent;\n border: none; color: var(--vscode-foreground, #c9d1d9); cursor: pointer;\n padding: 0; font-size: 13px; font-weight: 600; text-align: left;\n }\n #fry_tbl_29ce62e8 .fry-tbl-pill {\n font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-badge-background, #21262d); border: 1px solid var(--vscode-widget-border, #30363d);\n padding: 2px 8px; border-radius: 12px;\n }\n #fry_tbl_29ce62e8 .fry-tbl-actions {\n display: flex; align-items: center; gap: 8px;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-wrap {\n position: relative; display: flex; align-items: center;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-input {\n background: var(--vscode-input-background, #0d1117);\n border: 1px solid var(--vscode-input-border, #30363d);\n color: var(--vscode-input-foreground, #c9d1d9);\n padding: 3px 8px 3px 24px; border-radius: 4px; font-size: 11.5px;\n width: 140px; transition: border-color 0.2s, width 0.2s;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-input:focus {\n outline: none; border-color: var(--vscode-focusBorder, #58a6ff); width: 180px;\n }\n #fry_tbl_29ce62e8 .fry-tbl-search-icon {\n position: absolute; left: 7px; font-size: 11px; opacity: 0.6; pointer-events: none;\n }\n #fry_tbl_29ce62e8 .fry-tbl-btn {\n background: var(--vscode-button-secondaryBackground, #21262d);\n color: var(--vscode-button-secondaryForeground, #c9d1d9);\n border: 1px solid var(--vscode-widget-border, #30363d);\n border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500;\n cursor: pointer; transition: background 0.15s;\n }\n #fry_tbl_29ce62e8 .fry-tbl-btn:hover {\n background: var(--vscode-button-secondaryHoverBackground, #30363d);\n }\n #fry_tbl_29ce62e8 .fry-tbl-scroll {\n max-height: 420px; overflow-y: auto; overflow-x: auto;\n }\n #fry_tbl_29ce62e8 table.fry-tbl-grid {\n width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;\n }\n #fry_tbl_29ce62e8 thead th {\n position: sticky; top: 0; z-index: 5; background: var(--vscode-sideBar-background, #161b22);\n padding: 8px 12px; border-bottom: 2px solid var(--vscode-widget-border, #30363d);\n font-weight: 600; color: var(--vscode-foreground, #c9d1d9); user-select: none;\n }\n #fry_tbl_29ce62e8 thead th.sortable:hover {\n background: var(--vscode-list-hoverBackground, #21262d);\n }\n #fry_tbl_29ce62e8 tbody tr.fry-data-row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.06);\n transition: background 0.12s;\n }\n #fry_tbl_29ce62e8 tbody tr.fry-data-row:hover {\n background: var(--vscode-list-hoverBackground, rgba(56, 139, 253, 0.08)) !important;\n }\n #fry_tbl_29ce62e8 td {\n padding: 7px 12px; vertical-align: middle;\n }\n #fry_tbl_29ce62e8 .fry-num {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right; color: #79c0ff;\n }\n #fry_tbl_29ce62e8 .fry-badge {\n display: inline-block; padding: 2px 7px; border-radius: 12px;\n font-size: 11px; font-weight: 500; letter-spacing: 0.2px;\n }\n #fry_tbl_29ce62e8 .fry-badge-green {\n background: rgba(35, 134, 54, 0.2); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-badge-amber {\n background: rgba(187, 128, 9, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-badge-red {\n background: rgba(218, 54, 51, 0.2); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-badge-blue {\n background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.4);\n }\n #fry_tbl_29ce62e8 .fry-expander-btn {\n background: transparent; border: none; cursor: pointer;\n color: var(--vscode-textLink-foreground, #58a6ff); font-size: 10px;\n padding: 3px 5px; border-radius: 4px; transition: transform 0.15s;\n }\n #fry_tbl_29ce62e8 .fry-expander-btn:hover {\n background: rgba(255,255,255,0.1);\n }\n #fry_tbl_29ce62e8 tr.fry-detail-row {\n background: rgba(0, 0, 0, 0.28);\n }\n #fry_tbl_29ce62e8 .fry-prop-grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; margin-top: 6px;\n }\n #fry_tbl_29ce62e8 .fry-prop-card {\n background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);\n border-radius: 4px; padding: 6px 10px;\n }\n #fry_tbl_29ce62e8 .fry-prop-key {\n font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px;\n }\n #fry_tbl_29ce62e8 .fry-prop-val {\n font-size: 12px; color: #c9d1d9; margin-top: 2px; word-break: break-word;\n }\n #fry_tbl_29ce62e8 .fry-tbl-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px 12px; font-size: 11px; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-sideBar-background, #161b22);\n border-top: 1px solid var(--vscode-widget-border, #30363d);\n }\n \u003C/style\u003E\n\n \u003Cdiv class=\u0022fry-tbl-header\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-toggle-btn\u0022 onclick=\u0022fryTblToggle(\u0027fry_tbl_29ce62e8\u0027)\u0022 title=\u0022Click to collapse/expand table\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-chevron\u0022 style=\u0022color:#58a6ff;\u0022\u003E\u25BC\u003C/span\u003E\n \u003Cspan\u003E\uD83D\uDCCA Array[3]\u003C/span\u003E\n \u003Cspan class=\u0022fry-tbl-pill\u0022\u003E3 rows \u00D7 7 cols\u003C/span\u003E\n \u003C/button\u003E\n \u003Cdiv class=\u0022fry-tbl-actions\u0022\u003E\n \u003Cdiv class=\u0022fry-tbl-search-wrap\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-search-icon\u0022\u003E\uD83D\uDD0D\u003C/span\u003E\n \u003Cinput type=\u0022text\u0022 class=\u0022fry-tbl-search-input\u0022 placeholder=\u0022Filter rows...\u0022 oninput=\u0022fryTblFilter(\u0027fry_tbl_29ce62e8\u0027, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_29ce62e8\u0027, \u0027csv\u0027)\u0022 title=\u0022Copy as CSV\u0022\u003ECSV\u003C/button\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_29ce62e8\u0027, \u0027json\u0027)\u0022 title=\u0022Copy as JSON\u0022\u003EJSON\u003C/button\u003E\n \u003C/div\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-scroll\u0022\u003E\n \u003Ctable class=\u0022fry-tbl-grid\u0022\u003E\n \u003Cthead\u003E\n \u003Ctr\u003E\n \u003Cth style=\u0022width:36px;text-align:center;padding:8px 4px;\u0022\u003E\u003C/th\u003E\n \u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 0, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Region\u0022\u003E\n Region \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 1, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by NodeId\u0022\u003E\n NodeId \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 2, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Role\u0022\u003E\n Role \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 3, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by CpuPercent\u0022\u003E\n CpuPercent \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 4, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by MemoryGb\u0022\u003E\n MemoryGb \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 5, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by LatencyMs\u0022\u003E\n LatencyMs \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_29ce62e8\u0027, 6, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Status\u0022\u003E\n Status \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\n \u003C/tr\u003E\n \u003C/thead\u003E\n \u003Ctbody\u003E\n \u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00220\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_29ce62e8\u0027, 0)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eus-west-2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-worker-02\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ECompute Shard\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E78.2\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E128\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E28.1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_29ce62e8_detail_0\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #1 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eus-west-2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-worker-02\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ECompute Shard\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E78.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E128\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E28.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EUnder Load\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00221\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_29ce62e8\u0027, 1)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Esa-east-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-cache-05\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ERedis In-Memory\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E88.9\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E64\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E68.5\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_29ce62e8_detail_1\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #2 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Esa-east-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-cache-05\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ERedis In-Memory\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E88.9\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E64\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E68.5\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-amber\u0022\u003EDegraded\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00222\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_29ce62e8\u0027, 2)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Eap-northeast-1\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003Enode-ai-06\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003ETensor Pipeline\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E92.1\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E192\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E31.2\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EHeavy Compute\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_29ce62e8_detail_2\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00228\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #3 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E7 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERegion\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Eap-northeast-1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ENodeId\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003Enode-ai-06\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ERole\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003ETensor Pipeline\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ECpuPercent\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E92.1\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EMemoryGb\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E192\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003ELatencyMs\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E31.2\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EStatus\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EHeavy Compute\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\n \u003C/tbody\u003E\n \u003C/table\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-footer\u0022\u003E\n \u003Cspan id=\u0022fry_tbl_29ce62e8_counter\u0022\u003EShowing 3 of 3 rows\u003C/span\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10.5px;\u0022\u003EFrySharp Polyglot Data\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Cscript\u003E\n (function() {\n if (window.__fryTblInit) return;\n window.__fryTblInit = true;\n\n window.fryTblToggle = function(id) {\n var el = document.getElementById(id);\n if (!el) return;\n var body = el.querySelector(\u0027.fry-tbl-scroll\u0027);\n var footer = el.querySelector(\u0027.fry-tbl-footer\u0027);\n var chevron = el.querySelector(\u0027.fry-tbl-chevron\u0027);\n var isHidden = body.style.display === \u0027none\u0027;\n body.style.display = isHidden ? \u0027block\u0027 : \u0027none\u0027;\n footer.style.display = isHidden ? \u0027flex\u0027 : \u0027none\u0027;\n if (chevron) chevron.textContent = isHidden ? \u0027\u25BC\u0027 : \u0027\u25B6\u0027;\n };\n\n window.fryTblToggleRow = function(id, rowIdx) {\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rowIdx);\n var el = document.getElementById(id);\n if (!drawer || !el) return;\n var btn = el.querySelector(\u0027tr[data-row=\u0022\u0027 \u002B rowIdx \u002B \u0027\u0022] .fry-expander-btn\u0027);\n var isOpen = drawer.style.display !== \u0027none\u0027;\n drawer.style.display = isOpen ? \u0027none\u0027 : \u0027table-row\u0027;\n if (btn) btn.textContent = isOpen ? \u0027\u25B6\u0027 : \u0027\u25BC\u0027;\n };\n\n window.fryTblFilter = function(id, query) {\n var el = document.getElementById(id);\n if (!el) return;\n var q = (query || \u0027\u0027).toLowerCase().trim();\n var rows = el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027);\n var count = 0;\n rows.forEach(function(row) {\n var rIdx = row.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n var match = !q || row.innerText.toLowerCase().indexOf(q) !== -1;\n row.style.display = match ? \u0027\u0027 : \u0027none\u0027;\n if (!match \u0026\u0026 drawer) drawer.style.display = \u0027none\u0027;\n if (match) count\u002B\u002B;\n });\n var cnt = document.getElementById(id \u002B \u0027_counter\u0027);\n if (cnt) {\n cnt.textContent = q ? (\u0027Showing \u0027 \u002B count \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows (filtered)\u0027) : (\u0027Showing \u0027 \u002B rows.length \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows\u0027);\n }\n };\n\n window.fryTblSort = function(id, colIdx, isNum) {\n var el = document.getElementById(id);\n if (!el) return;\n var tbody = el.querySelector(\u0027tbody\u0027);\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var th = el.querySelectorAll(\u0027thead th\u0027)[colIdx \u002B 1];\n var dir = th.getAttribute(\u0027data-sort-dir\u0027) === \u0027asc\u0027 ? \u0027desc\u0027 : \u0027asc\u0027;\n\n el.querySelectorAll(\u0027thead th\u0027).forEach(function(h) {\n h.removeAttribute(\u0027data-sort-dir\u0027);\n var s = h.querySelector(\u0027.fry-sort-ind\u0027);\n if (s) s.textContent = \u0027\u2195\u0027;\n });\n\n th.setAttribute(\u0027data-sort-dir\u0027, dir);\n var ind = th.querySelector(\u0027.fry-sort-ind\u0027);\n if (ind) ind.textContent = dir === \u0027asc\u0027 ? \u0027\u25B2\u0027 : \u0027\u25BC\u0027;\n\n rows.sort(function(a, b) {\n var vA = a.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n var vB = b.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n if (isNum) {\n var nA = parseFloat(vA.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n var nB = parseFloat(vB.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n return dir === \u0027asc\u0027 ? nA - nB : nB - nA;\n }\n return dir === \u0027asc\u0027 ? vA.localeCompare(vB) : vB.localeCompare(vA);\n });\n\n rows.forEach(function(r) {\n var rIdx = r.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n tbody.appendChild(r);\n if (drawer) tbody.appendChild(drawer);\n });\n };\n\n window.fryTblCopy = function(id, format) {\n var el = document.getElementById(id);\n if (!el) return;\n var ths = Array.from(el.querySelectorAll(\u0027thead th\u0027)).slice(1);\n var headers = ths.map(function(h) { return h.innerText.replace(/[\u2195\u25B2\u25BC]/g, \u0027\u0027).trim(); });\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var data = rows.map(function(r) {\n return Array.from(r.children).slice(1).map(function(c) { return c.innerText.trim(); });\n });\n\n var payload = \u0027\u0027;\n if (format === \u0027json\u0027) {\n var list = data.map(function(r) {\n var o = {};\n headers.forEach(function(h, idx) { o[h] = r[idx]; });\n return o;\n });\n payload = JSON.stringify(list, null, 2);\n } else {\n payload = headers.join(\u0027,\u0027) \u002B \u0027\\n\u0027 \u002B data.map(function(r) {\n return r.map(function(val) { return \u0027\u0022\u0027 \u002B val.replace(/\u0022/g, \u0027\u0022\u0022\u0027) \u002B \u0027\u0022\u0027; }).join(\u0027,\u0027);\n }).join(\u0027\\n\u0027);\n }\n\n navigator.clipboard.writeText(payload).then(function() {\n var btn = el.querySelector(\u0027.fry-tbl-btn[onclick*=\u0022\u0027 \u002B format \u002B \u0027\u0022]\u0027);\n if (btn) {\n var prev = btn.textContent;\n btn.textContent = \u0027\u2713 Copied!\u0027;\n setTimeout(function() { btn.textContent = prev; }, 1500);\n }\n });\n };\n })();\n \u003C/script\u003E\n\u003C/div\u003E", - "TableSnapshot": { - "Title": "Array[3]", - "Label": null, - "Columns": [ - { - "Header": "Region", - "IsNumeric": false - }, - { - "Header": "NodeId", - "IsNumeric": false - }, - { - "Header": "Role", - "IsNumeric": false - }, - { - "Header": "CpuPercent", - "IsNumeric": true - }, - { - "Header": "MemoryGb", - "IsNumeric": true - }, - { - "Header": "LatencyMs", - "IsNumeric": true - }, - { - "Header": "Status", - "IsNumeric": false - } - ], - "Rows": [ - { - "Cells": [ - { - "DisplayText": "us-west-2", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-worker-02", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Compute Shard", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "78.2", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "128", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "28.1", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Under Load", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "sa-east-1", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-cache-05", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Redis In-Memory", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "88.9", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "64", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "68.5", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Degraded", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - }, - { - "Cells": [ - { - "DisplayText": "ap-northeast-1", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "node-ai-06", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Tensor Pipeline", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "92.1", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "192", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "31.2", - "IsNumeric": true, - "IsBoolean": false, - "IsNull": false - }, - { - "DisplayText": "Heavy Compute", - "IsNumeric": false, - "IsBoolean": false, - "IsNull": false - } - ] - } - ] - }, + "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Region ↕\n\n NodeId ↕\n\n Role ↕\n\n CpuPercent ↕\n\n MemoryGb ↕\n\n LatencyMs ↕\n\n Status ↕\n
\n \n us-west-2node-worker-02Compute Shard78.212828.1Under Load
\n
\n
\n
🔍 Row #1 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-west-2
\n
\n
NodeId
\n
node-worker-02
\n
\n
Role
\n
Compute Shard
\n
\n
CpuPercent
\n
78.2
\n
\n
MemoryGb
\n
128
\n
\n
LatencyMs
\n
28.1
\n
\n
Status
\n
Under Load
\n
\n
\n \n
\n
\n \n sa-east-1node-cache-05Redis In-Memory88.96468.5Degraded
\n
\n
\n
🔍 Row #2 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
sa-east-1
\n
\n
NodeId
\n
node-cache-05
\n
\n
Role
\n
Redis In-Memory
\n
\n
CpuPercent
\n
88.9
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
68.5
\n
\n
Status
\n
Degraded
\n
\n
\n \n
\n
\n \n ap-northeast-1node-ai-06Tensor Pipeline92.119231.2Heavy Compute
\n
\n
\n
🔍 Row #3 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-northeast-1
\n
\n
NodeId
\n
node-ai-06
\n
\n
Role
\n
Tensor Pipeline
\n
\n
CpuPercent
\n
92.1
\n
\n
MemoryGb
\n
192
\n
\n
LatencyMs
\n
31.2
\n
\n
Status
\n
Heavy Compute
\n
\n
\n \n
\n
\n
\n\n
\n Showing 3 of 3 rows\n FrySharp Polyglot Data\n
\n\n \n
", + "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, "HasImage": false, @@ -2265,7 +423,7 @@ "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, - "IsMarkdownPreviewMode": false, + "IsMarkdownPreviewMode": true, "IsInputCollapsed": false, "IsOutputCollapsed": false, "IsOutputScrolled": false, @@ -2286,12 +444,11 @@ { "Id": "cell-closure-code", "Type": 0, - "Language": "csharp", - "Source": "#!share --from python mean_cpu\n#!share --from python std_dev\n\nConsole.WriteLine(\u0022\uD83E\uDE90 C# Kernel: Verifying Triangular Polyglot Closure\u0022);\nConsole.WriteLine($\u0022 \u2713 Verified Python mean_cpu: {mean_cpu:F2}%\u0022);\nConsole.WriteLine($\u0022 \u2713 Verified Python std_dev: {std_dev:F2}%\u0022);\nConsole.WriteLine(\u0022 \u2713 Cross-language polyglot state synchronization verified.\u0022);", - "OutputText": "\uD83E\uDE90 C# Kernel: Verifying Triangular Polyglot Closure\n \u2713 Verified Python mean_cpu: 57.42%\n \u2713 Verified Python std_dev: 30.54%\n \u2713 Cross-language polyglot state synchronization verified.\n", - "ExecutionCount": 8, + "Source": "#!share --from python mean_cpu\n#!share --from python std_dev\n\nConsole.WriteLine(\"🪐 C# Kernel: Verifying Triangular Polyglot Closure\");\nConsole.WriteLine($\" ✓ Verified Python mean_cpu: {mean_cpu:F2}%\");\nConsole.WriteLine($\" ✓ Verified Python std_dev: {std_dev:F2}%\");\nConsole.WriteLine(\" ✓ Cross-language polyglot state synchronization verified.\");", + "OutputText": "🪐 C# Kernel: Verifying Triangular Polyglot Closure\n ✓ Verified Python mean_cpu: 57.42%\n ✓ Verified Python std_dev: 30.54%\n ✓ Cross-language polyglot state synchronization verified.\n", + "ExecutionCount": 16, "IsExecuting": false, - "ExecutionTimeText": "38 ms", + "ExecutionTimeText": "", "HasError": false, "IsMarkdownPreviewMode": false, "IsInputCollapsed": false, @@ -2311,8 +468,5 @@ "IsCodeCell": true, "IsMarkdownCell": false } - ], - "Created": "2026-10-13T15:08:27.59171Z", - "LastModified": "2026-10-13T15:08:29.718301Z", - "ExecutionCount": 8 + ] } \ No newline at end of file From b6cbd61f48efeeab45fe11f75eb84754b9d6e01b Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Wed, 14 Oct 2026 11:02:52 +0530 Subject: [PATCH 3/3] feat: add VS Code extension and multi-language support for frysharp --- .claude/launch.json | 17 + .github/workflows/ci.yml | 134 + CSharpEditorPlugin.csproj | 10 - FrySharp.Core/FrySharp.Core.csproj | 13 + .../Services/Display/InspectorBundle.cs | 76 + .../Services/Display/RichOutputMime.cs | 4 +- .../Services/Kernels/MimeOutputMapper.cs | 7 + .../Kernels/NotebookDirectiveRunner.cs | 5 +- .../Kernels/NotebookExecutionKernel.cs | 7 + .../Kernels/ProtocolKernel.Session.cs | 6 + .../Services/Kernels/ProtocolKernel.cs | 63 + .../Languages/Cpp/ClangGccDiagnosticParser.cs | 0 .../Cpp/CppBuildAndRunScriptRunner.cs | 142 +- .../Services/Languages/Cpp/CppCellProgram.cs | 233 ++ .../Services/Languages/Cpp/CppCellSplitter.cs | 712 ++++++ .../Services/Languages/Cpp/CppConsoleText.cs | 53 + .../Languages/Cpp/CppDisplayRuntime.cs | 80 + .../Services/Languages/Cpp/CppEngine.cs | 72 + .../Services}/Languages/Cpp/CppGuidance.cs | 0 .../Languages/Cpp/CppNotebookKernel.cs | 576 +++++ .../Languages/Cpp/CppPackageManager.cs | 0 .../Services}/Languages/Cpp/CppPackageMap.cs | 0 .../Cpp/CppToolchainProvider.Discovery.cs | 0 .../Languages/Cpp/CppToolchainProvider.cs | 0 .../Services/Languages/Cpp/CppValueTypes.cs | 43 + .../Languages/Cpp/CppWindowsSdkResolver.cs | 0 .../Languages/Cpp/Runtime/display.hpp | 2 +- .../Services/Languages/Cpp/Runtime/values.hpp | 845 ++++++ .../FSharp/FSharpCompilerDiagnosticParser.cs | 0 .../Languages/FSharp/FSharpDisplayRuntime.cs | 58 + .../Services/Languages/FSharp/FSharpEngine.cs | 72 + .../Languages/FSharp/FSharpGuidance.cs | 0 .../Languages/FSharp/FSharpKernelLauncher.cs | 45 + .../Languages/FSharp/FSharpPackageManager.cs | 54 + .../Languages/FSharp/FSharpScriptRunner.cs | 0 .../FSharpToolchainProvider.Discovery.cs | 0 .../FSharp/FSharpToolchainProvider.cs | 0 .../Languages/FSharp/Kernel/fry_kernel.fsx | 540 ++++ .../Languages/FSharp/Runtime/Display.fsx | 50 +- .../Languages/FSharp/Runtime/Values.fsx | 295 +++ .../Languages/Go/GoBuildAndRunScriptRunner.cs | 6 +- .../Services/Languages/Go/GoCellProgram.cs | 253 ++ .../Services/Languages/Go/GoCellSplitter.cs | 325 +++ .../Go/GoCompilerDiagnosticParser.cs | 7 +- .../Services/Languages/Go/GoConsoleText.cs | 32 + .../Services/Languages/Go/GoDisplayRuntime.cs | 130 + .../Services/Languages/Go/GoEngine.cs | 73 + .../Services}/Languages/Go/GoGuidance.cs | 0 .../Languages/Go/GoNotebookDependencies.cs | 67 + .../Services/Languages/Go/GoNotebookKernel.cs | 609 +++++ .../Languages/Go/GoPackageManager.cs | 30 +- .../Services}/Languages/Go/GoPackageMap.cs | 0 .../Go/GoToolchainProvider.Discovery.cs | 0 .../Languages/Go/GoToolchainProvider.cs | 0 .../Services/Languages/Go/GoValueTypes.cs | 43 + .../Services}/Languages/Go/Runtime/fry.go | 57 - .../Services/Languages/Go/Runtime/values.go | 738 ++++++ .../Services/Languages/HeadlessLanguages.cs | 12 + .../Java/JavaBuildAndRunScriptRunner.cs | 0 .../Java/JavaCompilerDiagnosticParser.cs | 0 .../Languages/Java/JavaDisplayRuntime.cs | 0 .../Services/Languages/Java/JavaEngine.cs | 70 + .../Services}/Languages/Java/JavaGuidance.cs | 0 .../Languages/Java/JavaKernelLauncher.cs | 0 .../Java/JavaToolchainProvider.Discovery.cs | 0 .../Languages/Java/JavaToolchainProvider.cs | 0 .../Languages/Java/Kernel/FryInspector.java | 289 +++ .../Languages/Java/Kernel/FryKernel.java | 413 ++- .../Languages/Java/MavenCentralResolver.cs | 0 .../Languages/Java/MavenPackageManager.cs | 0 .../Languages/Java/MavenPackageMap.cs | 0 .../Languages/Java/Runtime/Display.java | 141 +- .../Languages/Java/Runtime/Visualizer.java | 0 .../Languages/JavaScript/Kernel/fry_kernel.js | 5 +- .../Languages/JavaScript/Runtime/fry.js | 114 + .../Languages/Python/Runtime/fry_display.py | 147 +- .../Languages/Rust/Runtime/Cargo.toml | 0 .../Services}/Languages/Rust/Runtime/lib.rs | 150 +- .../Rust/RustBuildAndRunScriptRunner.cs | 0 .../Languages/Rust/RustCellProgram.cs | 7 +- .../Languages/Rust/RustCellSplitter.cs | 0 .../Languages/Rust/RustConsoleText.cs | 47 + .../Languages/Rust/RustDiagnosticParser.cs | 0 .../Languages/Rust/RustDirectives.cs | 0 .../Languages/Rust/RustDisplayRuntime.cs | 0 .../Services/Languages/Rust/RustEngine.cs | 72 + .../Services}/Languages/Rust/RustGuidance.cs | 0 .../Services}/Languages/Rust/RustLetNames.cs | 0 .../Rust/RustNotebookDependencies.cs | 0 .../Languages/Rust/RustNotebookKernel.cs | 80 +- .../Languages/Rust/RustPackageManager.cs | 0 .../Languages/Rust/RustPackageMap.cs | 0 .../Languages/Rust/RustProjectStager.cs | 0 .../Rust/RustToolchainProvider.Discovery.cs | 0 .../Languages/Rust/RustToolchainProvider.cs | 0 .../Languages/Rust/RustValueLiterals.cs | 0 .../Sql/SqlCompilerDiagnosticParser.cs | 0 .../Services/Languages/Sql/SqlCompletions.cs | 172 ++ .../Services/Languages/Sql/SqlEngine.cs | 78 + .../Services}/Languages/Sql/SqlGuidance.cs | 0 .../Sql/SqlNotebookKernel.CodePreparation.cs | 43 +- .../Languages/Sql/SqlNotebookKernel.cs | 121 +- .../Languages/Sql/SqlPackageManager.cs | 0 .../Languages/Sql/SqlQuickInfoProvider.cs | 15 + .../Services/Languages/Sql/SqlResultStream.cs | 149 ++ .../Languages/Sql/SqlScriptRunner.cs | 0 .../Sql/SqlToolchainProvider.Discovery.cs | 0 .../Languages/Sql/SqlToolchainProvider.cs | 0 .../Processes/ExternalOutputProcessor.cs | 41 +- .../Services/Processes/ProcessLauncher.cs | 76 +- .../Services/Toolchains/HostEnvironment.cs | 9 +- FrySharp.Sidecar/FrySharp.Sidecar.csproj | 2 + FrySharp.Sidecar/Program.cs | 1 + FrySharp.Sidecar/Protocol/Protocol.cs | 62 +- FrySharp.Sidecar/Services/CellScript.cs | 54 + .../Services/EngineInfoService.cs | 58 + FrySharp.Sidecar/Services/LanguageFeatures.cs | 109 +- FrySharp.Sidecar/Services/NotebookService.cs | 160 +- FrySharp.Sidecar/Sessions/SidecarRpc.cs | 25 +- FrySharp.Sidecar/Sessions/SidecarSession.cs | 56 +- Makefile | 14 +- Services/Languages/Cpp/CppDisplayRuntime.cs | 62 - Services/Languages/Cpp/CppLanguage.cs | 67 +- .../Cpp/CppNotebookKernel.CodePreparation.cs | 279 -- Services/Languages/Cpp/CppNotebookKernel.cs | 275 -- .../Languages/FSharp/FSharpDisplayRuntime.cs | 54 - Services/Languages/FSharp/FSharpLanguage.cs | 65 +- .../FSharpNotebookKernel.CodePreparation.cs | 164 -- .../Languages/FSharp/FSharpNotebookKernel.cs | 226 -- .../Languages/FSharp/FSharpPackageManager.cs | 70 - Services/Languages/Go/GoDisplayRuntime.cs | 93 - Services/Languages/Go/GoLanguage.cs | 68 +- .../Go/GoNotebookKernel.CodePreparation.cs | 258 -- Services/Languages/Go/GoNotebookKernel.cs | 262 -- Services/Languages/Java/JavaLanguage.cs | 65 +- Services/Languages/Rust/RustLanguage.cs | 64 +- .../Languages/Sql/SqlCompletionService.cs | 163 +- Services/Languages/Sql/SqlLanguage.cs | 74 +- Tests.Core/CppEngineTests.cs | 347 +++ Tests.Core/ExtensionFixturesTests.cs | 203 ++ Tests.Core/FSharpEngineTests.cs | 396 +++ Tests.Core/FrySharp.Core.Tests.csproj | 5 + Tests.Core/GoEngineTests.cs | 370 +++ Tests.Core/InProcessCSharpRunnerTests.cs | 9 +- Tests.Core/InspectorBundleTests.cs | 121 + Tests.Core/JavaEngineTests.cs | 297 +++ Tests.Core/ProcessLauncherTests.cs | 70 + Tests.Core/RealCppFactAttribute.cs | 24 + Tests.Core/RealFSharpFactAttribute.cs | 36 + Tests.Core/RealGoFactAttribute.cs | 35 + Tests.Core/RealJavaFactAttribute.cs | 35 + Tests.Core/RealRustFactAttribute.cs | 35 + Tests.Core/RealSqliteFactAttribute.cs | 24 + Tests.Core/RustEngineTests.cs | 351 +++ Tests.Core/SqlCompletionsTests.cs | 66 + Tests.Core/SqlEngineTests.cs | 285 +++ Tests.Core/SqlResultStreamTests.cs | 121 + Tests.Core/TestSupport.cs | 8 +- Tests.Sidecar/RealPythonFactAttribute.cs | 24 + Tests.Sidecar/SidecarHarness.cs | 23 +- Tests.Sidecar/SidecarTests.cs | 1070 ++++++++ .../CSharpCodeStudioExplorerTests.cs | 37 +- .../CSharpEditorShortcutRegistrationTests.cs | 13 + Tests/CodeStudio/CodeStudioSourceFileTests.cs | 35 +- Tests/CodeStudio/ExplorerRowListTests.cs | 26 +- Tests/CodeStudio/StudioLoadingOverlayTests.cs | 3 + .../Common/CodeStudioExternalLanguageTests.cs | 1 + .../FSharp/FSharpPackageManagerTests.cs | 36 +- .../Go/GoBuildAndRunScriptRunnerTests.cs | 4 +- Tests/Languages/Go/GoPackageManagerTests.cs | 2 +- .../Languages/Rust/RustPackagesPanelTests.cs | 1 + .../Notebooks/CSharpNotebookStudioTabTests.cs | 50 +- Tests/Notebooks/CppNotebookKernelTests.cs | 10 +- Tests/Notebooks/FSharpNotebookKernelTests.cs | 85 - Tests/Notebooks/GoNotebookKernelTests.cs | 14 +- Tests/RealCpp/CppProgramRunTests.cs | 4 +- Tests/RealCpp/CppVisualsConformanceTests.cs | 2 +- Tests/RealFSharp/FSharpProgramRunTests.cs | 26 +- Tests/RealGo/GoProgramRunTests.cs | 2 +- Tests/RealGo/GoVisualsConformanceTests.cs | 4 +- Tests/RealJava/JavaKernelTests.cs | 123 +- Tests/RealJava/JavaProgramRunTests.cs | 2 +- Tests/RealJava/JavaVisualsConformanceTests.cs | 4 +- Tests/RealJavaScript/JavaScriptKernelTests.cs | 56 + Tests/RealPython/CodeStudioPythonRunTests.cs | 1 + Tests/RealPython/PythonKernelTests.cs | 58 + Tests/RealRust/RustProgramRunTests.cs | 2 +- Tests/RealRust/RustVisualsConformanceTests.cs | 4 +- Tests/RealSql/SqlProgramRunTests.cs | 31 +- .../Services/Execution/ExternalChangeTests.cs | 22 +- .../Workspace/WorkspaceStalenessTests.cs | 29 +- Tests/TestSupport/CppFactAttribute.cs | 2 + Tests/TestSupport/RustFactAttribute.cs | 2 + Tests/Visuals/Data/InspectorChain.cs | 45 + Tests/Visuals/Data/ObjectInspectorTests.cs | 3 + .../CSharpCodeStudioViewModel.Execution.cs | 6 +- docs/adding-a-language.md | 6 +- docs/kernel-protocol.md | 9 +- docs/session-protocol.md | 39 + samples/demo.frynb | 26 +- vscode-frysharp/.gitignore | 2 + vscode-frysharp/.vscodeignore | 11 + vscode-frysharp/CHANGELOG.md | 34 + vscode-frysharp/LICENSE | 2 +- vscode-frysharp/README.md | 139 +- vscode-frysharp/esbuild.js | 43 + vscode-frysharp/eslint.config.mjs | 24 + vscode-frysharp/media/icon.png | Bin 0 -> 28438 bytes .../media/walkthrough/mix-languages.md | 12 + .../media/walkthrough/run-a-cell.md | 14 + .../media/walkthrough/run-a-script.md | 7 + .../media/walkthrough/show-output.md | 12 + vscode-frysharp/package-lock.json | 2269 ++++++++++++++++- vscode-frysharp/package.json | 424 ++- vscode-frysharp/preview/capture.js | 53 + vscode-frysharp/preview/index.html | 100 + vscode-frysharp/snippets/cpp.json | 37 + vscode-frysharp/snippets/csharp.json | 32 + vscode-frysharp/snippets/fsharp.json | 27 + vscode-frysharp/snippets/go.json | 42 + vscode-frysharp/snippets/java.json | 32 + vscode-frysharp/snippets/javascript.json | 27 + vscode-frysharp/snippets/python.json | 27 + vscode-frysharp/snippets/rust.json | 42 + vscode-frysharp/snippets/sql.json | 7 + vscode-frysharp/src/engineInfo.ts | 53 + vscode-frysharp/src/extension.ts | 486 ++-- vscode-frysharp/src/language/cellContext.ts | 53 + vscode-frysharp/src/language/cellFeatures.ts | 150 ++ vscode-frysharp/src/language/cellProblems.ts | 46 + vscode-frysharp/src/language/directives.ts | 91 + .../src/language/liveDiagnostics.ts | 118 + .../src/notebook/cellDiagnostics.ts | 42 + vscode-frysharp/src/notebook/cellStatus.ts | 27 + vscode-frysharp/src/notebook/cellStatusBar.ts | 27 + vscode-frysharp/src/notebook/format/frynb.ts | 189 ++ vscode-frysharp/src/notebook/format/index.ts | 17 + vscode-frysharp/src/notebook/format/ipynb.ts | 169 ++ vscode-frysharp/src/notebook/format/model.ts | 116 + .../src/notebook/format/outputs.ts | 452 ++++ vscode-frysharp/src/notebook/format/tabs.ts | 104 + vscode-frysharp/src/notebook/identity.ts | 33 + vscode-frysharp/src/notebook/liveOutputs.ts | 161 ++ .../src/notebook/notebookController.ts | 220 +- .../src/notebook/notebookSerializer.ts | 929 +------ .../src/notebook/outputSettings.ts | 4 + vscode-frysharp/src/notebook/runHub.ts | 174 ++ vscode-frysharp/src/notebook/scratchpad.ts | 60 + .../src/notebook/sequencedQueue.ts | 69 + vscode-frysharp/src/notebook/statusBar.ts | 64 + vscode-frysharp/src/notebook/variablesTree.ts | 112 + vscode-frysharp/src/renderer/assets.ts | 16 + vscode-frysharp/src/renderer/chooseMime.ts | 21 + vscode-frysharp/src/renderer/console.ts | 59 + vscode-frysharp/src/renderer/dom.ts | 49 + vscode-frysharp/src/renderer/html.ts | 80 + vscode-frysharp/src/renderer/htmlPrepare.ts | 67 + vscode-frysharp/src/renderer/index.ts | 60 + vscode-frysharp/src/renderer/inspector.ts | 64 + .../src/renderer/inspectorModel.ts | 52 + vscode-frysharp/src/renderer/markdown.ts | 75 + vscode-frysharp/src/renderer/render.ts | 51 + vscode-frysharp/src/renderer/styles.ts | 95 + vscode-frysharp/src/renderer/table.ts | 126 + vscode-frysharp/src/renderer/tableModel.ts | 100 + vscode-frysharp/src/renderer/tabs.ts | 249 ++ vscode-frysharp/src/renderer/tsconfig.json | 19 + vscode-frysharp/src/run/lensRules.ts | 9 + vscode-frysharp/src/run/lineEditor.ts | 66 + vscode-frysharp/src/run/resultsPage.ts | 82 + vscode-frysharp/src/run/resultsView.ts | 62 + vscode-frysharp/src/run/runCodeLens.ts | 43 + vscode-frysharp/src/run/runLinks.ts | 42 + vscode-frysharp/src/run/runTerminal.ts | 138 + vscode-frysharp/src/run/scriptRuns.ts | 228 ++ vscode-frysharp/src/shared/outputTabs.ts | 240 ++ vscode-frysharp/src/sidecar/events.ts | 30 + vscode-frysharp/src/sidecar/launcher.ts | 54 + vscode-frysharp/src/sidecar/protocol.ts | 127 + vscode-frysharp/src/sidecar/runtime.ts | 65 + vscode-frysharp/src/sidecar/sidecarClient.ts | 394 ++- .../frysharp-directives.injection.json | 38 + vscode-frysharp/test/fixtures/desktop.frynb | 361 +++ vscode-frysharp/test/fixtures/jupyter.ipynb | 143 ++ .../test/fixtures/written/after-a-run.frynb | 240 ++ .../test/fixtures/written/desktop.frynb | 361 +++ vscode-frysharp/test/integration/runTests.ts | 57 + vscode-frysharp/test/integration/suite/cpp.ts | 161 ++ .../test/integration/suite/fsharp.ts | 104 + vscode-frysharp/test/integration/suite/go.ts | 159 ++ .../test/integration/suite/index.ts | 38 + .../test/integration/suite/java.ts | 187 ++ .../test/integration/suite/language.ts | 157 ++ .../test/integration/suite/notebooks.ts | 548 ++++ .../test/integration/suite/outputs.ts | 36 + .../test/integration/suite/rust.ts | 159 ++ .../test/integration/suite/scripts.ts | 244 ++ .../test/integration/suite/skip.ts | 30 + vscode-frysharp/test/integration/suite/sql.ts | 95 + vscode-frysharp/test/tsconfig.json | 18 + vscode-frysharp/test/unit/cellContext.test.ts | 55 + .../test/unit/cellDiagnostics.test.ts | 40 + vscode-frysharp/test/unit/cellStatus.test.ts | 41 + vscode-frysharp/test/unit/directives.test.ts | 49 + vscode-frysharp/test/unit/engineInfo.test.ts | 57 + vscode-frysharp/test/unit/frynb.test.ts | 235 ++ vscode-frysharp/test/unit/grammar.test.ts | 75 + vscode-frysharp/test/unit/identity.test.ts | 39 + vscode-frysharp/test/unit/ipynb.test.ts | 62 + vscode-frysharp/test/unit/launcher.test.ts | 38 + vscode-frysharp/test/unit/lineEditor.test.ts | 63 + vscode-frysharp/test/unit/liveOutputs.test.ts | 220 ++ vscode-frysharp/test/unit/manifest.test.ts | 75 + vscode-frysharp/test/unit/outputTabs.test.ts | 103 + .../test/unit/protocol.contract.test.ts | 132 + .../test/unit/rendererModels.test.ts | 190 ++ vscode-frysharp/test/unit/resultsPage.test.ts | 41 + vscode-frysharp/test/unit/runLinks.test.ts | 42 + vscode-frysharp/test/unit/runtime.test.ts | 28 + .../test/unit/sequencedQueue.test.ts | 85 + vscode-frysharp/test/unit/serializer.test.ts | 141 + .../test/unit/sidecarClient.test.ts | 125 + .../test/unit/statusAndVariables.test.ts | 36 + vscode-frysharp/test/unit/tabs.test.ts | 79 + vscode-frysharp/test/unit/vscodeStub.ts | 36 + vscode-frysharp/tsconfig.json | 11 +- 326 files changed, 27591 insertions(+), 4256 deletions(-) create mode 100644 .claude/launch.json create mode 100644 .github/workflows/ci.yml create mode 100644 FrySharp.Core/Services/Display/InspectorBundle.cs rename {Services => FrySharp.Core/Services}/Languages/Cpp/ClangGccDiagnosticParser.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Cpp/CppBuildAndRunScriptRunner.cs (71%) create mode 100644 FrySharp.Core/Services/Languages/Cpp/CppCellProgram.cs create mode 100644 FrySharp.Core/Services/Languages/Cpp/CppCellSplitter.cs create mode 100644 FrySharp.Core/Services/Languages/Cpp/CppConsoleText.cs create mode 100644 FrySharp.Core/Services/Languages/Cpp/CppDisplayRuntime.cs create mode 100644 FrySharp.Core/Services/Languages/Cpp/CppEngine.cs rename {Services => FrySharp.Core/Services}/Languages/Cpp/CppGuidance.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Cpp/CppNotebookKernel.cs rename {Services => FrySharp.Core/Services}/Languages/Cpp/CppPackageManager.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Cpp/CppPackageMap.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Cpp/CppToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Cpp/CppToolchainProvider.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Cpp/CppValueTypes.cs rename {Services => FrySharp.Core/Services}/Languages/Cpp/CppWindowsSdkResolver.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Cpp/Runtime/display.hpp (99%) create mode 100644 FrySharp.Core/Services/Languages/Cpp/Runtime/values.hpp rename {Services => FrySharp.Core/Services}/Languages/FSharp/FSharpCompilerDiagnosticParser.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/FSharp/FSharpDisplayRuntime.cs create mode 100644 FrySharp.Core/Services/Languages/FSharp/FSharpEngine.cs rename {Services => FrySharp.Core/Services}/Languages/FSharp/FSharpGuidance.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/FSharp/FSharpKernelLauncher.cs create mode 100644 FrySharp.Core/Services/Languages/FSharp/FSharpPackageManager.cs rename {Services => FrySharp.Core/Services}/Languages/FSharp/FSharpScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/FSharp/FSharpToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/FSharp/FSharpToolchainProvider.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/FSharp/Kernel/fry_kernel.fsx rename {Services => FrySharp.Core/Services}/Languages/FSharp/Runtime/Display.fsx (97%) create mode 100644 FrySharp.Core/Services/Languages/FSharp/Runtime/Values.fsx rename {Services => FrySharp.Core/Services}/Languages/Go/GoBuildAndRunScriptRunner.cs (94%) create mode 100644 FrySharp.Core/Services/Languages/Go/GoCellProgram.cs create mode 100644 FrySharp.Core/Services/Languages/Go/GoCellSplitter.cs rename {Services => FrySharp.Core/Services}/Languages/Go/GoCompilerDiagnosticParser.cs (96%) create mode 100644 FrySharp.Core/Services/Languages/Go/GoConsoleText.cs create mode 100644 FrySharp.Core/Services/Languages/Go/GoDisplayRuntime.cs create mode 100644 FrySharp.Core/Services/Languages/Go/GoEngine.cs rename {Services => FrySharp.Core/Services}/Languages/Go/GoGuidance.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Go/GoNotebookDependencies.cs create mode 100644 FrySharp.Core/Services/Languages/Go/GoNotebookKernel.cs rename {Services => FrySharp.Core/Services}/Languages/Go/GoPackageManager.cs (78%) rename {Services => FrySharp.Core/Services}/Languages/Go/GoPackageMap.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Go/GoToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Go/GoToolchainProvider.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Go/GoValueTypes.cs rename {Services => FrySharp.Core/Services}/Languages/Go/Runtime/fry.go (97%) create mode 100644 FrySharp.Core/Services/Languages/Go/Runtime/values.go rename {Services => FrySharp.Core/Services}/Languages/Java/JavaBuildAndRunScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/JavaCompilerDiagnosticParser.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/JavaDisplayRuntime.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Java/JavaEngine.cs rename {Services => FrySharp.Core/Services}/Languages/Java/JavaGuidance.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/JavaKernelLauncher.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/JavaToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/JavaToolchainProvider.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Java/Kernel/FryInspector.java rename {Services => FrySharp.Core/Services}/Languages/Java/Kernel/FryKernel.java (57%) rename {Services => FrySharp.Core/Services}/Languages/Java/MavenCentralResolver.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/MavenPackageManager.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/MavenPackageMap.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Java/Runtime/Display.java (88%) rename {Services => FrySharp.Core/Services}/Languages/Java/Runtime/Visualizer.java (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/Runtime/Cargo.toml (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/Runtime/lib.rs (94%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustBuildAndRunScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustCellProgram.cs (94%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustCellSplitter.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Rust/RustConsoleText.cs rename {Services => FrySharp.Core/Services}/Languages/Rust/RustDiagnosticParser.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustDirectives.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustDisplayRuntime.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Rust/RustEngine.cs rename {Services => FrySharp.Core/Services}/Languages/Rust/RustGuidance.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustLetNames.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustNotebookDependencies.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustNotebookKernel.cs (88%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustPackageManager.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustPackageMap.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustProjectStager.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustToolchainProvider.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Rust/RustValueLiterals.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlCompilerDiagnosticParser.cs (100%) create mode 100644 FrySharp.Core/Services/Languages/Sql/SqlCompletions.cs create mode 100644 FrySharp.Core/Services/Languages/Sql/SqlEngine.cs rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlGuidance.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlNotebookKernel.CodePreparation.cs (75%) rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlNotebookKernel.cs (64%) rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlPackageManager.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlQuickInfoProvider.cs (91%) create mode 100644 FrySharp.Core/Services/Languages/Sql/SqlResultStream.cs rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlScriptRunner.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlToolchainProvider.Discovery.cs (100%) rename {Services => FrySharp.Core/Services}/Languages/Sql/SqlToolchainProvider.cs (100%) rename {Services => FrySharp.Core/Services}/Processes/ExternalOutputProcessor.cs (85%) create mode 100644 FrySharp.Sidecar/Services/CellScript.cs create mode 100644 FrySharp.Sidecar/Services/EngineInfoService.cs delete mode 100644 Services/Languages/Cpp/CppDisplayRuntime.cs delete mode 100644 Services/Languages/Cpp/CppNotebookKernel.CodePreparation.cs delete mode 100644 Services/Languages/Cpp/CppNotebookKernel.cs delete mode 100644 Services/Languages/FSharp/FSharpDisplayRuntime.cs delete mode 100644 Services/Languages/FSharp/FSharpNotebookKernel.CodePreparation.cs delete mode 100644 Services/Languages/FSharp/FSharpNotebookKernel.cs delete mode 100644 Services/Languages/FSharp/FSharpPackageManager.cs delete mode 100644 Services/Languages/Go/GoDisplayRuntime.cs delete mode 100644 Services/Languages/Go/GoNotebookKernel.CodePreparation.cs delete mode 100644 Services/Languages/Go/GoNotebookKernel.cs create mode 100644 Tests.Core/CppEngineTests.cs create mode 100644 Tests.Core/ExtensionFixturesTests.cs create mode 100644 Tests.Core/FSharpEngineTests.cs create mode 100644 Tests.Core/GoEngineTests.cs create mode 100644 Tests.Core/InspectorBundleTests.cs create mode 100644 Tests.Core/JavaEngineTests.cs create mode 100644 Tests.Core/ProcessLauncherTests.cs create mode 100644 Tests.Core/RealCppFactAttribute.cs create mode 100644 Tests.Core/RealFSharpFactAttribute.cs create mode 100644 Tests.Core/RealGoFactAttribute.cs create mode 100644 Tests.Core/RealJavaFactAttribute.cs create mode 100644 Tests.Core/RealRustFactAttribute.cs create mode 100644 Tests.Core/RealSqliteFactAttribute.cs create mode 100644 Tests.Core/RustEngineTests.cs create mode 100644 Tests.Core/SqlCompletionsTests.cs create mode 100644 Tests.Core/SqlEngineTests.cs create mode 100644 Tests.Core/SqlResultStreamTests.cs create mode 100644 Tests.Sidecar/RealPythonFactAttribute.cs delete mode 100644 Tests/Notebooks/FSharpNotebookKernelTests.cs create mode 100644 Tests/Visuals/Data/InspectorChain.cs create mode 100644 vscode-frysharp/CHANGELOG.md create mode 100644 vscode-frysharp/esbuild.js create mode 100644 vscode-frysharp/eslint.config.mjs create mode 100644 vscode-frysharp/media/icon.png create mode 100644 vscode-frysharp/media/walkthrough/mix-languages.md create mode 100644 vscode-frysharp/media/walkthrough/run-a-cell.md create mode 100644 vscode-frysharp/media/walkthrough/run-a-script.md create mode 100644 vscode-frysharp/media/walkthrough/show-output.md create mode 100644 vscode-frysharp/preview/capture.js create mode 100644 vscode-frysharp/preview/index.html create mode 100644 vscode-frysharp/snippets/cpp.json create mode 100644 vscode-frysharp/snippets/csharp.json create mode 100644 vscode-frysharp/snippets/fsharp.json create mode 100644 vscode-frysharp/snippets/go.json create mode 100644 vscode-frysharp/snippets/java.json create mode 100644 vscode-frysharp/snippets/javascript.json create mode 100644 vscode-frysharp/snippets/python.json create mode 100644 vscode-frysharp/snippets/rust.json create mode 100644 vscode-frysharp/snippets/sql.json create mode 100644 vscode-frysharp/src/engineInfo.ts create mode 100644 vscode-frysharp/src/language/cellContext.ts create mode 100644 vscode-frysharp/src/language/cellFeatures.ts create mode 100644 vscode-frysharp/src/language/cellProblems.ts create mode 100644 vscode-frysharp/src/language/directives.ts create mode 100644 vscode-frysharp/src/language/liveDiagnostics.ts create mode 100644 vscode-frysharp/src/notebook/cellDiagnostics.ts create mode 100644 vscode-frysharp/src/notebook/cellStatus.ts create mode 100644 vscode-frysharp/src/notebook/cellStatusBar.ts create mode 100644 vscode-frysharp/src/notebook/format/frynb.ts create mode 100644 vscode-frysharp/src/notebook/format/index.ts create mode 100644 vscode-frysharp/src/notebook/format/ipynb.ts create mode 100644 vscode-frysharp/src/notebook/format/model.ts create mode 100644 vscode-frysharp/src/notebook/format/outputs.ts create mode 100644 vscode-frysharp/src/notebook/format/tabs.ts create mode 100644 vscode-frysharp/src/notebook/identity.ts create mode 100644 vscode-frysharp/src/notebook/liveOutputs.ts create mode 100644 vscode-frysharp/src/notebook/outputSettings.ts create mode 100644 vscode-frysharp/src/notebook/runHub.ts create mode 100644 vscode-frysharp/src/notebook/scratchpad.ts create mode 100644 vscode-frysharp/src/notebook/sequencedQueue.ts create mode 100644 vscode-frysharp/src/notebook/statusBar.ts create mode 100644 vscode-frysharp/src/notebook/variablesTree.ts create mode 100644 vscode-frysharp/src/renderer/assets.ts create mode 100644 vscode-frysharp/src/renderer/chooseMime.ts create mode 100644 vscode-frysharp/src/renderer/console.ts create mode 100644 vscode-frysharp/src/renderer/dom.ts create mode 100644 vscode-frysharp/src/renderer/html.ts create mode 100644 vscode-frysharp/src/renderer/htmlPrepare.ts create mode 100644 vscode-frysharp/src/renderer/index.ts create mode 100644 vscode-frysharp/src/renderer/inspector.ts create mode 100644 vscode-frysharp/src/renderer/inspectorModel.ts create mode 100644 vscode-frysharp/src/renderer/markdown.ts create mode 100644 vscode-frysharp/src/renderer/render.ts create mode 100644 vscode-frysharp/src/renderer/styles.ts create mode 100644 vscode-frysharp/src/renderer/table.ts create mode 100644 vscode-frysharp/src/renderer/tableModel.ts create mode 100644 vscode-frysharp/src/renderer/tabs.ts create mode 100644 vscode-frysharp/src/renderer/tsconfig.json create mode 100644 vscode-frysharp/src/run/lensRules.ts create mode 100644 vscode-frysharp/src/run/lineEditor.ts create mode 100644 vscode-frysharp/src/run/resultsPage.ts create mode 100644 vscode-frysharp/src/run/resultsView.ts create mode 100644 vscode-frysharp/src/run/runCodeLens.ts create mode 100644 vscode-frysharp/src/run/runLinks.ts create mode 100644 vscode-frysharp/src/run/runTerminal.ts create mode 100644 vscode-frysharp/src/run/scriptRuns.ts create mode 100644 vscode-frysharp/src/shared/outputTabs.ts create mode 100644 vscode-frysharp/src/sidecar/events.ts create mode 100644 vscode-frysharp/src/sidecar/launcher.ts create mode 100644 vscode-frysharp/src/sidecar/runtime.ts create mode 100644 vscode-frysharp/syntaxes/frysharp-directives.injection.json create mode 100644 vscode-frysharp/test/fixtures/desktop.frynb create mode 100644 vscode-frysharp/test/fixtures/jupyter.ipynb create mode 100644 vscode-frysharp/test/fixtures/written/after-a-run.frynb create mode 100644 vscode-frysharp/test/fixtures/written/desktop.frynb create mode 100644 vscode-frysharp/test/integration/runTests.ts create mode 100644 vscode-frysharp/test/integration/suite/cpp.ts create mode 100644 vscode-frysharp/test/integration/suite/fsharp.ts create mode 100644 vscode-frysharp/test/integration/suite/go.ts create mode 100644 vscode-frysharp/test/integration/suite/index.ts create mode 100644 vscode-frysharp/test/integration/suite/java.ts create mode 100644 vscode-frysharp/test/integration/suite/language.ts create mode 100644 vscode-frysharp/test/integration/suite/notebooks.ts create mode 100644 vscode-frysharp/test/integration/suite/outputs.ts create mode 100644 vscode-frysharp/test/integration/suite/rust.ts create mode 100644 vscode-frysharp/test/integration/suite/scripts.ts create mode 100644 vscode-frysharp/test/integration/suite/skip.ts create mode 100644 vscode-frysharp/test/integration/suite/sql.ts create mode 100644 vscode-frysharp/test/tsconfig.json create mode 100644 vscode-frysharp/test/unit/cellContext.test.ts create mode 100644 vscode-frysharp/test/unit/cellDiagnostics.test.ts create mode 100644 vscode-frysharp/test/unit/cellStatus.test.ts create mode 100644 vscode-frysharp/test/unit/directives.test.ts create mode 100644 vscode-frysharp/test/unit/engineInfo.test.ts create mode 100644 vscode-frysharp/test/unit/frynb.test.ts create mode 100644 vscode-frysharp/test/unit/grammar.test.ts create mode 100644 vscode-frysharp/test/unit/identity.test.ts create mode 100644 vscode-frysharp/test/unit/ipynb.test.ts create mode 100644 vscode-frysharp/test/unit/launcher.test.ts create mode 100644 vscode-frysharp/test/unit/lineEditor.test.ts create mode 100644 vscode-frysharp/test/unit/liveOutputs.test.ts create mode 100644 vscode-frysharp/test/unit/manifest.test.ts create mode 100644 vscode-frysharp/test/unit/outputTabs.test.ts create mode 100644 vscode-frysharp/test/unit/protocol.contract.test.ts create mode 100644 vscode-frysharp/test/unit/rendererModels.test.ts create mode 100644 vscode-frysharp/test/unit/resultsPage.test.ts create mode 100644 vscode-frysharp/test/unit/runLinks.test.ts create mode 100644 vscode-frysharp/test/unit/runtime.test.ts create mode 100644 vscode-frysharp/test/unit/sequencedQueue.test.ts create mode 100644 vscode-frysharp/test/unit/serializer.test.ts create mode 100644 vscode-frysharp/test/unit/sidecarClient.test.ts create mode 100644 vscode-frysharp/test/unit/statusAndVariables.test.ts create mode 100644 vscode-frysharp/test/unit/tabs.test.ts create mode 100644 vscode-frysharp/test/unit/vscodeStub.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..5e62c889 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "renderer-preview", + "runtimeExecutable": "python3", + "runtimeArgs": [ + "-m", + "http.server", + "4173", + "--directory", + "vscode-frysharp" + ], + "port": 4173 + } + ] +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1804fe75 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,134 @@ +name: CI + +# The headless engine and the VS Code extension: what a change to either must not break. (The desktop app's own suite and +# the release gate are in release.yml.) + +on: + push: + branches: [main, Server] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + engine: + name: Engine (Core, Sidecar) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + # The sidecar's tests run real Python and Node, as the engine's users do. + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + # The Java tests run the real JDK (javac, java, JShell). + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # The Go tests run the real toolchain: every cell is built with `go build`. + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + # The Rust tests run the real toolchain: every cell is built with Cargo (a runner has no crates cached, so the first cell is slow). + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + # The SQL tests run the real sqlite3: macOS and Linux runners have it (a missing one fails there); a Windows runner + # does not, and its SQL tests are skipped with the reason. + # The F# tests run the real `dotnet fsi` (the SDK that setup-dotnet installs) and restore a small NuGet package. + - name: Core tests + run: dotnet test Tests.Core/FrySharp.Core.Tests.csproj + env: + FRY_REQUIRE_SQL: ${{ matrix.os == 'windows-latest' && '' || '1' }} + FRY_REQUIRE_FSHARP: '1' + FRY_REQUIRE_JAVA: '1' + FRY_REQUIRE_RUST: '1' + FRY_REQUIRE_GO: '1' + # a Windows runner's MSVC needs its own environment: the C++ tests are skipped there, with the reason + FRY_REQUIRE_CPP: ${{ matrix.os == 'windows-latest' && '' || '1' }} + FRY_TEST_NETWORK: '1' + + - name: Sidecar tests + run: dotnet test Tests.Sidecar/FrySharp.Sidecar.Tests.csproj + env: + FRY_REQUIRE_PYTHON: '1' + FRY_REQUIRE_SQL: ${{ matrix.os == 'windows-latest' && '' || '1' }} + FRY_REQUIRE_FSHARP: '1' + FRY_REQUIRE_JAVA: '1' + FRY_REQUIRE_RUST: '1' + FRY_REQUIRE_GO: '1' + # a Windows runner's MSVC needs its own environment: the C++ tests are skipped there, with the reason + FRY_REQUIRE_CPP: ${{ matrix.os == 'windows-latest' && '' || '1' }} + FRY_TEST_NETWORK: '1' + + extension: + name: Extension (lint, types, tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: vscode-frysharp/package-lock.json + + # The client's tests start the real engine. + - name: Build the sidecar + run: dotnet build FrySharp.Sidecar/FrySharp.Sidecar.csproj + + - name: Install + working-directory: vscode-frysharp + run: npm ci + + - name: Lint + working-directory: vscode-frysharp + run: npm run lint + + - name: Type check + working-directory: vscode-frysharp + run: npm run typecheck + + - name: Tests + working-directory: vscode-frysharp + run: npm test + env: + FRY_REQUIRE_SIDECAR: '1' + + # The tests write the notebooks the extension saves (test/fixtures/written), which Tests.Core reads back with the + # desktop's reader: they are committed, so a change in what the extension writes shows up here. + - name: The notebooks the extension writes are the committed ones + run: git diff --exit-code -- vscode-frysharp/test/fixtures + + - name: Bundle + working-directory: vscode-frysharp + run: node esbuild.js --production diff --git a/CSharpEditorPlugin.csproj b/CSharpEditorPlugin.csproj index c27fcf3c..037ce1ba 100644 --- a/CSharpEditorPlugin.csproj +++ b/CSharpEditorPlugin.csproj @@ -63,18 +63,8 @@ - - - - - - - - - - diff --git a/FrySharp.Core/FrySharp.Core.csproj b/FrySharp.Core/FrySharp.Core.csproj index d6fbe404..e40719f5 100644 --- a/FrySharp.Core/FrySharp.Core.csproj +++ b/FrySharp.Core/FrySharp.Core.csproj @@ -33,6 +33,19 @@ + + + + + + + + + + + + diff --git a/FrySharp.Core/Services/Display/InspectorBundle.cs b/FrySharp.Core/Services/Display/InspectorBundle.cs new file mode 100644 index 00000000..3c7e5c68 --- /dev/null +++ b/FrySharp.Core/Services/Display/InspectorBundle.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using PdfEditorApp.Plugins.CSharpEditor.Models; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +/// +/// The object inspector a kernel in another language sends as , read back into +/// the studio's (the writer is ). It is how Java, +/// Python and JavaScript show an object the way C# does: {"title": "People", "circular": false, "properties": +/// [{"name": "Name", "value": "Code", "isString": true}, {"name": "Another", "child": {…}}]}. +/// +/// +/// A row is a scalar (value, with isNull / isString telling how to colour it) or a nested object +/// (child). Which nodes start open follows the C# inspector: the top one, and the ones just below it unless they +/// close a cycle (circular). A node nested deeper than is shown by its title only. +/// +public static class InspectorBundle +{ + /// Levels read; a deeper object keeps its title and loses its rows (the same limit the writer applies). + public const int MaxDepth = 6; + + public static ObjectInspectorNode Read(JsonElement json) => ReadNode(json, 0); + + private static ObjectInspectorNode ReadNode(JsonElement json, int depth) + { + var title = Text(json, "title") is { Length: > 0 } named ? named : "object"; + var circular = Flag(json, "circular"); + var node = new ObjectInspectorNode(title, isExpanded: depth == 0 || (!circular && depth < 2), isExpandable: true) + { + IsCircularReference = circular + }; + + if (depth >= MaxDepth || !json.TryGetProperty("properties", out var properties) || properties.ValueKind != JsonValueKind.Array) + { + return node; + } + + foreach (var property in properties.EnumerateArray()) + { + if (property.ValueKind != JsonValueKind.Object) continue; + node.Properties.Add(ReadRow(property, depth + 1)); + } + return node; + } + + private static ObjectInspectorPropertyRow ReadRow(JsonElement property, int depth) + { + var row = new ObjectInspectorPropertyRow { Name = Text(property, "name") ?? string.Empty }; + + if (property.TryGetProperty("child", out var child) && child.ValueKind == JsonValueKind.Object) + { + row.IsComplexChild = true; + row.ChildNode = ReadNode(child, depth); + return row; + } + + if (Flag(property, "isNull")) + { + row.IsNull = true; + row.SimpleValueText = ""; + return row; + } + + row.IsString = Flag(property, "isString"); + row.SimpleValueText = property.TryGetProperty("value", out var value) + ? value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : value.GetRawText() + : string.Empty; + return row; + } + + private static string? Text(JsonElement json, string name) => + json.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static bool Flag(JsonElement json, string name) => + json.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; +} diff --git a/FrySharp.Core/Services/Display/RichOutputMime.cs b/FrySharp.Core/Services/Display/RichOutputMime.cs index e09af1fe..17bac883 100644 --- a/FrySharp.Core/Services/Display/RichOutputMime.cs +++ b/FrySharp.Core/Services/Display/RichOutputMime.cs @@ -147,7 +147,9 @@ private static JsonObject Inspector(ObjectInspectorNode node, int depth) }); } - return new JsonObject { ["title"] = node.HeaderTitle, ["properties"] = properties }; + var result = new JsonObject { ["title"] = node.HeaderTitle, ["properties"] = properties }; + if (node.IsCircularReference) result["circular"] = true; + return result; } private static string InspectorText(ObjectInspectorNode node) => diff --git a/FrySharp.Core/Services/Kernels/MimeOutputMapper.cs b/FrySharp.Core/Services/Kernels/MimeOutputMapper.cs index 2382debd..5e93c1f3 100644 --- a/FrySharp.Core/Services/Kernels/MimeOutputMapper.cs +++ b/FrySharp.Core/Services/Kernels/MimeOutputMapper.cs @@ -29,6 +29,13 @@ public static KernelOutput Map(JsonElement data, JsonElement metadata, string? d return new KernelOutput(null, new RichCellOutput { Kind = CellOutputKind.Table, TableResult = Table(table) }); } + // An object a kernel inspected itself (Java, Python and JavaScript have no .NET object to hand over): the same + // inspector the desktop builds for a C# value. + if (data.TryGetProperty(RichOutputMime.InspectorMime, out var inspector) && inspector.ValueKind == JsonValueKind.Object) + { + return new KernelOutput(null, new RichCellOutput { Kind = CellOutputKind.ObjectInspector, InspectorNode = InspectorBundle.Read(inspector) }); + } + foreach (var mime in VisualMimeTypes.VisualTypes) { if (data.TryGetProperty(mime, out var spec) && VisualMimeTypes.TryGetFamily(mime, out var family)) diff --git a/FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs b/FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs index a5149079..e49dbe35 100644 --- a/FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs +++ b/FrySharp.Core/Services/Kernels/NotebookDirectiveRunner.cs @@ -63,8 +63,9 @@ public async Task RunPackageCommandAsync( var result = await packages.RunAsync(command, toolchain, write, ct); if (result.Message is { Length: > 0 } message) write((result.Success ? "" : "❌ ") + message.TrimEnd() + "\n"); - // A new environment only takes over when the kernel next starts; until then the running one looks there too. - if (result.Success && result.AddedSearchPath is { } searchPath && kernel is ISearchPathKernel searchable && kernel.IsSessionActive) + // A new environment only takes over when the kernel next starts; until then the running one looks there too. A kernel + // that is not running yet (this is the first cell) keeps the path and has it as soon as it is. + if (result.Success && result.AddedSearchPath is { } searchPath && kernel is ISearchPathKernel searchable) { await searchable.AddSearchPathAsync(searchPath, ct); } diff --git a/FrySharp.Core/Services/Kernels/NotebookExecutionKernel.cs b/FrySharp.Core/Services/Kernels/NotebookExecutionKernel.cs index b225ea51..02cc7a12 100644 --- a/FrySharp.Core/Services/Kernels/NotebookExecutionKernel.cs +++ b/FrySharp.Core/Services/Kernels/NotebookExecutionKernel.cs @@ -31,6 +31,12 @@ public class KernelExecutionResult /// A name the code used that isn't defined (C#'s CS0103, Python's NameError), for the "run the cell that defines it" hint. public string? MissingName { get; set; } + + /// The kind of error a kernel in another language reported (Python's ZeroDivisionError), when it did. + public string? ErrorName { get; set; } + + /// The line of the cell the error was on, when the kernel said (a Python or JavaScript traceback names it). + public int? ErrorLine { get; set; } } public class NotebookExecutionKernel : INotebookKernel @@ -300,6 +306,7 @@ private async Task ExecuteCellCoreAsync( } result.ErrorMessage = root.Message; + result.ErrorName = root.GetType().Name; liveWriter.WriteLine($"\n❌ Runtime Error: {root.GetType().Name}: {root.Message}\n{root.StackTrace}"); if (root != ex && ex.InnerException != null && ex.InnerException != root) { diff --git a/FrySharp.Core/Services/Kernels/ProtocolKernel.Session.cs b/FrySharp.Core/Services/Kernels/ProtocolKernel.Session.cs index 49ecb6f7..25f1b67c 100644 --- a/FrySharp.Core/Services/Kernels/ProtocolKernel.Session.cs +++ b/FrySharp.Core/Services/Kernels/ProtocolKernel.Session.cs @@ -27,6 +27,9 @@ private sealed class Execution(string id, KernelExecutionRequest request, Cancel public string? ErrorSummary; public string? MissingDependency; public string? MissingName; + public string? ErrorName; + public int? ErrorLine; + public IReadOnlyList? Diagnostics; public string Output { @@ -217,6 +220,9 @@ private void Handle(string line) var name = Text(message, "ename"); var value = Text(message, "evalue"); target.ErrorSummary = value.Length > 0 ? $"{name}: {value}" : name; + target.ErrorName = name.Length > 0 ? name : null; + target.ErrorLine = message.TryGetProperty("line", out var errorLine) && errorLine.ValueKind == JsonValueKind.Number && errorLine.TryGetInt32(out var lineNumber) && lineNumber > 0 ? lineNumber : null; + target.Diagnostics = Diagnostics(message); target.MissingDependency = Text(message, "missingModule") is { Length: > 0 } module ? module : null; target.MissingName = Text(message, "missingName") is { Length: > 0 } missing ? missing : null; target.Write(Text(message, "traceback")); diff --git a/FrySharp.Core/Services/Kernels/ProtocolKernel.cs b/FrySharp.Core/Services/Kernels/ProtocolKernel.cs index 1ec2ec9f..0a774865 100644 --- a/FrySharp.Core/Services/Kernels/ProtocolKernel.cs +++ b/FrySharp.Core/Services/Kernels/ProtocolKernel.cs @@ -138,6 +138,9 @@ public async Task ExecuteAsync(KernelExecutionRequest req ConsoleOutput = execution.Output, MissingDependency = execution.MissingDependency, MissingName = execution.MissingName, + ErrorName = execution.ErrorName, + ErrorLine = execution.ErrorLine, + Diagnostics = execution.Diagnostics ?? Array.Empty(), Elapsed = clock.Elapsed }; } @@ -241,8 +244,17 @@ public async Task SetValueFromJsonAsync(string name, string json, CancellationTo } } + // Folders a package command asked to be added to the kernel's import path or classpath. They are kept, so a kernel that is + // not running yet (the first cell of a notebook is a package line) or that restarts gets them as soon as it is ready. + private readonly List _searchPaths = []; + public async Task AddSearchPathAsync(string path, CancellationToken ct = default) { + lock (_searchPaths) + { + if (!_searchPaths.Contains(path)) _searchPaths.Add(path); + } + if (CurrentSession is not { IsAlive: true } session || !session.Ready.IsCompletedSuccessfully) return; await _gate.WaitAsync(ct); try @@ -313,6 +325,21 @@ private async Task StartAsync(Action? onConsole, CancellationTo throw new KernelUnavailableException(ex.Message); } + // what package commands added to the path before this kernel was running + string[] paths; + lock (_searchPaths) paths = [.. _searchPaths]; + foreach (var path in paths) + { + try + { + await RequestAsync(session, id => new { type = "add_search_path", id, path }, ct); + } + catch (Exception ex) when (ex is KernelStoppedException or TimeoutException) + { + Debug.WriteLine($"[CSharpEditorPlugin] Couldn't add '{path}' to {DisplayName}: {ex.Message}"); + } + } + _executed = false; bool noteLostState; lock (_sessionLock) @@ -369,5 +396,41 @@ private static string Text(JsonElement element, string property) => ? value.GetString() ?? string.Empty : string.Empty; + // Where a failed cell went wrong, when the kernel says: "diagnostics": [{line, column, endLine, endColumn, severity, code, + // message}] on the cell's own lines and columns (1-based), e.g. an F# compile error. Without it the kernel's "line" is all + // there is. + private static IReadOnlyList? Diagnostics(JsonElement error) + { + if (error.ValueKind != JsonValueKind.Object || !error.TryGetProperty("diagnostics", out var list) || list.ValueKind != JsonValueKind.Array) return null; + + int Number(JsonElement item, string property, int fallback) => + item.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n > 0 ? n : fallback; + + var items = new List(); + foreach (var item in list.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) continue; + var line = Number(item, "line", 1); + var column = Number(item, "column", 1); + items.Add(new DiagnosticItem + { + Id = Text(item, "code"), + Message = Text(item, "message"), + Severity = Text(item, "severity") switch + { + "warning" => Microsoft.CodeAnalysis.DiagnosticSeverity.Warning, + "info" => Microsoft.CodeAnalysis.DiagnosticSeverity.Info, + _ => Microsoft.CodeAnalysis.DiagnosticSeverity.Error + }, + Line = line, + Column = column, + EndLine = Number(item, "endLine", line), + EndColumn = Number(item, "endColumn", column + 1) + }); + } + + return items.Count == 0 ? null : items; + } + private sealed class KernelStoppedException(string message) : Exception(message); } diff --git a/Services/Languages/Cpp/ClangGccDiagnosticParser.cs b/FrySharp.Core/Services/Languages/Cpp/ClangGccDiagnosticParser.cs similarity index 100% rename from Services/Languages/Cpp/ClangGccDiagnosticParser.cs rename to FrySharp.Core/Services/Languages/Cpp/ClangGccDiagnosticParser.cs diff --git a/Services/Languages/Cpp/CppBuildAndRunScriptRunner.cs b/FrySharp.Core/Services/Languages/Cpp/CppBuildAndRunScriptRunner.cs similarity index 71% rename from Services/Languages/Cpp/CppBuildAndRunScriptRunner.cs rename to FrySharp.Core/Services/Languages/Cpp/CppBuildAndRunScriptRunner.cs index 402c9022..a0f0b00d 100644 --- a/Services/Languages/Cpp/CppBuildAndRunScriptRunner.cs +++ b/FrySharp.Core/Services/Languages/Cpp/CppBuildAndRunScriptRunner.cs @@ -15,7 +15,6 @@ public async Task PlanAsync(ScriptRunContext context, Cancellatio var compilerPath = context.Toolchain.ExecutablePath; var fileName = Path.GetFileName(compilerPath.Replace('\\', '/')); var isCl = fileName.Equals("cl.exe", StringComparison.OrdinalIgnoreCase) || fileName.Equals("cl", StringComparison.OrdinalIgnoreCase); - var isClang = fileName.Contains("clang", StringComparison.OrdinalIgnoreCase); var normalizedSource = context.SourceFilePath.Replace('\\', '/'); var fileBaseName = Path.GetFileNameWithoutExtension(normalizedSource); @@ -40,76 +39,11 @@ public async Task PlanAsync(ScriptRunContext context, Cancellatio var includeDir = await CppDisplayRuntime.EnsureIncludeDirectoryAsync(outDir, ct).ConfigureAwait(false); var vcpkgInclude = FindVcpkgIncludeDirectory(context.WorkingDirectory, host); - var windowsSdk = host.IsWindows ? CppWindowsSdkResolver.Resolve(host) : null; - - var compileArgs = new List(); - if (isCl) - { - // MSVC compiler arguments - compileArgs.Add("/std:c++20"); - compileArgs.Add("/EHsc"); - compileArgs.Add("/W4"); - compileArgs.Add($"/Fe:{binPath}"); - compileArgs.Add($"/Fo:{outDir}\\"); - if (!string.IsNullOrEmpty(sourceDir)) - { - compileArgs.Add($"/I{sourceDir}"); - } - compileArgs.Add($"/I{includeDir}"); - if (!string.IsNullOrEmpty(vcpkgInclude)) - { - compileArgs.Add($"/I{vcpkgInclude}"); - } - if (windowsSdk?.HasMsvcHeaders == true) - { - foreach (var inc in windowsSdk.IncludeDirectories) - { - compileArgs.Add($"/I{inc}"); - } - } - compileArgs.Add(context.SourceFilePath); - } - else - { - // Clang / GCC compiler arguments - compileArgs.Add("-std=c++20"); - compileArgs.Add("-O2"); - compileArgs.Add("-Wall"); - - if (host.IsWindows && windowsSdk != null) - { - if (isClang && windowsSdk.HasMsvcHeaders) - { - foreach (var inc in windowsSdk.IncludeDirectories) - { - compileArgs.Add("-imsvc"); - compileArgs.Add(inc); - } - } - else if (isClang && windowsSdk.HasMinGw) - { - compileArgs.Add("--target=x86_64-w64-windows-gnu"); - compileArgs.Add($"--sysroot={windowsSdk.MinGwSysroot}"); - } - } - - if (!string.IsNullOrEmpty(sourceDir)) - { - compileArgs.Add($"-I{sourceDir}"); - } - compileArgs.Add($"-I{includeDir}"); - if (!string.IsNullOrEmpty(vcpkgInclude)) - { - compileArgs.Add($"-I{vcpkgInclude}"); - } - compileArgs.Add("-o"); - compileArgs.Add(binPath); - compileArgs.Add(context.SourceFilePath); - if (host.IsWindows) - { - compileArgs.Add("-lws2_32"); - } - } + var includeDirectories = new List(); + if (!string.IsNullOrEmpty(sourceDir)) includeDirectories.Add(sourceDir); + includeDirectories.Add(includeDir); + if (!string.IsNullOrEmpty(vcpkgInclude)) includeDirectories.Add(vcpkgInclude); + var compileArgs = CppCompilerCommand.Arguments(host, context.Toolchain, context.SourceFilePath, binPath, outDir, includeDirectories); var environment = await CppProcessEnvironment.ForAsync(host, context.Toolchain, ct); @@ -216,3 +150,69 @@ public static class CppProcessEnvironment return environment; } } + +/// The compiler's command line for one C++ source file: the same for a script and for a notebook cell. +public static class CppCompilerCommand +{ + public static bool IsCl(string compilerPath) + { + var fileName = Path.GetFileName(compilerPath.Replace('\\', '/')); + return fileName.Equals("cl.exe", StringComparison.OrdinalIgnoreCase) || fileName.Equals("cl", StringComparison.OrdinalIgnoreCase); + } + + public static IReadOnlyList Arguments(IHostEnvironment host, ToolchainInfo toolchain, string sourcePath, string binaryPath, string outDir, IReadOnlyList includeDirectories) + { + var compilerPath = toolchain.ExecutablePath; + var isCl = IsCl(compilerPath); + var isClang = Path.GetFileName(compilerPath.Replace('\\', '/')).Contains("clang", StringComparison.OrdinalIgnoreCase); + var windowsSdk = host.IsWindows ? CppWindowsSdkResolver.Resolve(host) : null; + var arguments = new List(); + + if (isCl) + { + // MSVC compiler arguments + arguments.Add("/std:c++20"); + arguments.Add("/EHsc"); + arguments.Add("/W4"); + arguments.Add($"/Fe:{binaryPath}"); + arguments.Add($"/Fo:{outDir}\\"); + foreach (var directory in includeDirectories) arguments.Add($"/I{directory}"); + if (windowsSdk?.HasMsvcHeaders == true) + { + foreach (var inc in windowsSdk.IncludeDirectories) arguments.Add($"/I{inc}"); + } + + arguments.Add(sourcePath); + return arguments; + } + + // Clang / GCC compiler arguments + arguments.Add("-std=c++20"); + arguments.Add("-O2"); + arguments.Add("-Wall"); + + if (host.IsWindows && windowsSdk != null) + { + if (isClang && windowsSdk.HasMsvcHeaders) + { + foreach (var inc in windowsSdk.IncludeDirectories) + { + arguments.Add("-imsvc"); + arguments.Add(inc); + } + } + else if (isClang && windowsSdk.HasMinGw) + { + arguments.Add("--target=x86_64-w64-windows-gnu"); + arguments.Add($"--sysroot={windowsSdk.MinGwSysroot}"); + } + } + + foreach (var directory in includeDirectories) arguments.Add($"-I{directory}"); + arguments.Add("-o"); + arguments.Add(binaryPath); + arguments.Add(sourcePath); + if (host.IsWindows) arguments.Add("-lws2_32"); + return arguments; + } +} diff --git a/FrySharp.Core/Services/Languages/Cpp/CppCellProgram.cs b/FrySharp.Core/Services/Languages/Cpp/CppCellProgram.cs new file mode 100644 index 00000000..6c741b0d --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/CppCellProgram.cs @@ -0,0 +1,233 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; + +/// A declaration an earlier notebook cell made (a function, a type, a using, a constant, a macro), kept so later cells can use it. +internal sealed record CppItem(string Kind, IReadOnlyList Keys, IReadOnlyList Names, string Text, string Masked, IReadOnlyList Members) +{ + public static CppItem From(CppChunk chunk) => new(chunk.ItemKind!, chunk.Keys, chunk.Names, chunk.Text, chunk.Masked, chunk.Members); +} + +/// A variable a cell made and the notebook kept: its type as C++ writes it, and its value as JSON. +internal sealed record CppVariable(string Name, string Type, string Json); + +/// The declarations the notebook's cells have made, in order. A cell that declares a name again takes the place of the older one. +internal sealed class CppItemStore +{ + private readonly List _items = []; + + public IReadOnlyList Items => _items; + + public void Clear() => _items.Clear(); + + public void Apply(IEnumerable cellItems) + { + foreach (var chunk in cellItems) + { + var item = CppItem.From(chunk); + if (item.Kind == "function" && item.Names.Contains("main")) continue; // a cell that is a whole program isn't kept + _items.RemoveAll(existing => existing.Keys.Any(item.Keys.Contains)); + _items.Add(item); + } + } +} + +/// A cell made into a C++ program, and the way back from the program's lines to the cell's. +internal sealed class CppCellProgram +{ + public required string Source { get; init; } + + /// For each line of the program (index 0 is line 1): the cell's line it is, or 0 for a line the cell didn't write. + public required IReadOnlyList CellLines { get; init; } + + /// The program's lines that give a variable back its value, and the variable each one gives. + public IReadOnlyDictionary RestoreLines { get; init; } = new Dictionary(); + + /// The program's lines of the last statement, with the fry::show( around it, when the program shows its value, else null. + public (int First, int Last)? ShownGenerated { get; init; } + + /// Why the cell can't be made a program (it has its own main and statements too); null when it can. + public string? Error { get; init; } + + public int? CellLineOf(int generatedLine) => generatedLine >= 1 && generatedLine <= CellLines.Count && CellLines[generatedLine - 1] > 0 ? CellLines[generatedLine - 1] : null; +} + +/// +/// Lays a cell out as a C++ program: the includes, every declaration the notebook has so far, how the notebook can show each struct (its public data +/// members), then main, which gives the variables of earlier cells back their values, runs the cell's statements (in a block of their own, +/// so int x = 5; may make a variable that an earlier cell made too) and hands the variables on to the next cell. The statements go in +/// unchanged, so a compiler message names the line and column the cell has. The last expression, when it has no ;, is shown. +/// +internal static class CppCellProgramBuilder +{ + // What a cell may use without including it. + public static readonly string[] DefaultIncludes = + [ + "#include ", "#include ", "#include ", "#include ", "#include ", "#include ", "#include ", + "#include ", "#include ", "#include ", "#include ", "#include " + ]; + + private sealed record Line(string Text, int CellLine, string? Restores = null); + + /// The notebook's includes so far, in order. + /// The variables earlier cells made, to give back. + /// Show the value of the last statement when it is an expression. + public static CppCellProgram Build(CppCell cell, IReadOnlyList earlierItems, IReadOnlyList includes, IReadOnlyList restore, bool showLast) + { + var cellKeys = cell.Items.SelectMany(i => i.Keys).ToHashSet(StringComparer.Ordinal); + var kept = earlierItems.Where(i => !i.Keys.Any(cellKeys.Contains)).ToList(); + var all = kept.Concat(cell.Items.Select(CppItem.From)).ToList(); + var body = new List(); + + foreach (var item in kept) + { + foreach (var text in item.Text.Split('\n')) body.Add(new Line(text, 0)); + body.Add(new Line(string.Empty, 0)); + } + + foreach (var item in cell.Items) + { + AddChunk(body, item); + body.Add(new Line(string.Empty, 0)); + } + + // how the notebook shows each struct: its data members, by name + foreach (var type in all.Where(i => i.Kind == "type" && i.Names.Count == 1 && i.Members.Count > 0)) + { + body.Add(new Line(Registration(type.Names[0], type.Members), 0)); + } + + body.Add(new Line(string.Empty, 0)); + (int First, int Last)? shown = null; + + if (cell.DefinesMain) + { + if (cell.Statements.Count > 0) + { + return new CppCellProgram { Source = string.Empty, CellLines = [], Error = "A cell that defines main is a whole program: it can't also have statements outside the functions." }; + } + } + else + { + var globalVars = all.Where(i => i.Kind == "var" && !Regex.IsMatch(i.Masked, @"\b(?:const|constexpr|consteval|constinit|extern)\b")).SelectMany(i => i.Names).ToHashSet(StringComparer.Ordinal); + var declaredHere = cell.Statements.SelectMany(s => s.Names).ToHashSet(StringComparer.Ordinal); + var varItemsHere = cell.Items.Where(i => i.ItemKind == "var").SelectMany(i => i.Names).ToHashSet(StringComparer.Ordinal); + + body.Add(new Line("int main() {", 0)); + // output reaches the notebook as it is written (a pipe would hold it back), so what the cell prints and what it prints to + // the error stream come in the order they ran + body.Add(new Line(" std::cout << std::unitbuf; std::setvbuf(stdout, nullptr, _IONBF, 0);", 0)); + var restored = new List(); + foreach (var variable in restore.Where(v => !varItemsHere.Contains(v.Name))) + { + if (globalVars.Contains(variable.Name)) + { + body.Add(new Line($" fry::restore_into({variable.Name}, {Quote(variable.Name)});", 0, variable.Name)); + } + else + { + body.Add(new Line($" [[maybe_unused]] {variable.Type} {variable.Name} = fry::restore<{variable.Type}>({Quote(variable.Name)});", 0, variable.Name)); + restored.Add(variable.Name); + } + } + + body.Add(new Line(" {", 0)); + var last = cell.Statements.Count > 0 ? cell.Statements[^1] : null; + foreach (var statement in cell.Statements) + { + var isLast = ReferenceEquals(statement, last); + if (isLast && showLast && CppCellSplitter.IsValueExpression(statement)) + { + var firstLine = body.Count + 1; + body.Add(new Line(" fry::show(", 0)); + AddChunk(body, statement); + body.Add(new Line(" );", 0)); + shown = (firstLine, body.Count); // body lines: the header comes before them, so these are shifted below + } + else if (isLast && !statement.EndsWithSemicolon && statement.Masked.Trim().Length > 0) + { + // an expression that isn't shown (a print, a call that returns nothing) still needs its ; (after a block it is an empty statement) + AddChunk(body, statement, closeWithSemicolon: true); + } + else + { + AddChunk(body, statement); + } + } + + // What the cell made goes on to the next cell, from inside the block, where those variables are. + var persisted = new HashSet(StringComparer.Ordinal); + foreach (var name in declaredHere) + { + if (persisted.Add(name)) body.Add(new Line($" fry::persist({Quote(name)}, {name});", 0)); + } + + body.Add(new Line(" }", 0)); + + // The ones it didn't make again (it may have changed them) and the statics declared at the top of the program. + foreach (var name in restored.Where(r => !declaredHere.Contains(r)).Concat(globalVars)) + { + if (persisted.Add(name)) body.Add(new Line($" fry::persist({Quote(name)}, {name});", 0)); + } + + body.Add(new Line(" return 0;", 0)); + body.Add(new Line("}", 0)); + } + + var source = new StringBuilder(); + var cellLines = new List(); + var restoreLines = new Dictionary(); + var header = new List(); + foreach (var include in includes.Concat(DefaultIncludes).Concat(["#include ", "#include "]).Distinct(StringComparer.Ordinal)) header.Add(include); + header.Add(string.Empty); + header.Add("using namespace fry;"); + header.Add("using namespace fry::display;"); + header.Add(string.Empty); + + foreach (var text in header) + { + source.Append(text).Append('\n'); + cellLines.Add(0); + } + + foreach (var line in body) + { + source.Append(line.Text).Append('\n'); + cellLines.Add(line.CellLine); + if (line.Restores != null) restoreLines[cellLines.Count] = line.Restores; + } + + (int, int)? shownGenerated = shown is { } range ? (range.First + header.Count, range.Last + header.Count) : null; + return new CppCellProgram { Source = source.ToString(), CellLines = cellLines, RestoreLines = restoreLines, ShownGenerated = shownGenerated }; + } + + private static void AddChunk(List body, CppChunk chunk, bool closeWithSemicolon = false) + { + var texts = chunk.Text.Split('\n'); + var masks = chunk.Masked.Split('\n'); + for (var i = 0; i < texts.Length; i++) + { + var text = texts[i]; + if (closeWithSemicolon && i == texts.Length - 1) + { + var end = CppCellSplitter.CodeEnd(masks[i]); + text = text[..end] + ";" + text[end..]; + } + + body.Add(new Line(text, chunk.StartLine + i)); + } + } + + // template <> struct fry::fields { ... }: how fry shows and keeps a struct, by naming its members + private static string Registration(string type, IReadOnlyList members) + { + var names = string.Join(", ", members.Select(m => "\"" + m + "\"")); + var calls = string.Join(" ", members.Select(m => $"f(value.{m});")); + return $"namespace fry {{ template <> struct fields<::{type}> {{ static constexpr const char* names[] = {{{names}}}; " + + $"template static void each(::{type}& value, F&& f) {{ {calls} }} " + + $"template static void each(const ::{type}& value, F&& f) {{ {calls} }} }}; }}"; + } + + private static string Quote(string name) => "\"" + name + "\""; +} diff --git a/FrySharp.Core/Services/Languages/Cpp/CppCellSplitter.cs b/FrySharp.Core/Services/Languages/Cpp/CppCellSplitter.cs new file mode 100644 index 00000000..cbb7bcb0 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/CppCellSplitter.cs @@ -0,0 +1,712 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; + +internal enum CppChunkKind +{ + Include, + Item, + Statement +} + +/// One top-level piece of a cell: an #include, a declaration (a function, a type, a using, a constant, a macro) or a statement. +/// The lines as the cell has them. +/// The same lines with comments blanked and the inside of literals blanked: what is structure and nothing else. +/// The cell's line (from 1) it begins on. +/// The piece ends with ; (a last expression without one is the value to show). +internal sealed record CppChunk(CppChunkKind Kind, string Text, string Masked, int StartLine, int LineCount, bool EndsWithSemicolon) +{ + /// "function", "type", "using", "template", "namespace", "macro" or "var" for a declaration. + public string? ItemKind { get; init; } + + /// The names a declaration makes, or the variables a statement declares. + public IReadOnlyList Names { get; init; } = []; + + /// What replaces what: a later declaration with one of these keys takes the place of this one. + public IReadOnlyList Keys { get; init; } = []; + + /// A struct's or class's public data members, in order, when the notebook can tell what they are (it can't show a type it doesn't know the members of). + public IReadOnlyList Members { get; init; } = []; +} + +/// A cell, taken apart. +internal sealed record CppCell(IReadOnlyList Includes, IReadOnlyList Items, IReadOnlyList Statements) +{ + /// The cell is a whole program: it has its own main. + public bool DefinesMain => Items.Any(i => i.ItemKind == "function" && i.Names.Contains("main")); +} + +/// +/// Takes a C++ cell apart without a compiler: comments and the inside of literals are blanked, and a piece ends at a ; or at the } that +/// closes its block, with the continuations C++ has (else, catch, while after do, the ; after a type's }). +/// +internal static partial class CppCellSplitter +{ + private static readonly HashSet Storage = new(StringComparer.Ordinal) + { + "const", "constexpr", "static", "volatile", "inline", "thread_local", "mutable", "register", "extern", "consteval", "constinit" + }; + + private static readonly HashSet NotDeclarations = new(StringComparer.Ordinal) + { + "return", "delete", "throw", "goto", "break", "continue", "case", "default", "else", "do", "try", "catch", "co_return", "co_yield", "co_await", + "if", "for", "while", "switch", "using", "typedef", "static_assert", "asm", "namespace", "new", "sizeof", "typeid", "dynamic_cast", "static_cast", + "reinterpret_cast", "const_cast", "this", "template", "public", "private", "protected" + }; + + private static readonly HashSet NotValues = new(StringComparer.Ordinal) + { + "if", "for", "while", "do", "switch", "return", "throw", "try", "catch", "else", "goto", "break", "continue", "case", "default", "using", "typedef", + "namespace", "struct", "class", "enum", "union", "template", "static_assert", "delete", "co_return", "public", "private", "protected" + }; + + // Statements that print: their results aren't a value to show. + [GeneratedRegex(@"^(?:std\s*::\s*)?(?:cout|cerr|clog|printf|puts|fputs|fprintf|putchar|print|println)\b|^fry\s*::|^display\s*::|^std\s*::\s*(?:print|println)\b")] + private static partial Regex Printing(); + + [GeneratedRegex(@"^#\s*include\s*(?[<""][^>""]+[>""])")] + private static partial Regex IncludeLine(); + + [GeneratedRegex(@"^#\s*define\s+(?[A-Za-z_]\w*)")] + private static partial Regex DefineLine(); + + [GeneratedRegex(@"^#\s*undef\s+(?[A-Za-z_]\w*)")] + private static partial Regex UndefLine(); + + [GeneratedRegex(@"^(?:struct|class|union|enum(?:\s+(?:class|struct))?)\s*(?:\[\[[^\]]*\]\]\s*)?(?[A-Za-z_]\w*)?")] + private static partial Regex TypeHead(); + + [GeneratedRegex(@"^\s*(public|private|protected)\s*:")] + private static partial Regex AccessLabel(); + + public static CppCell Split(string code) + { + var text = code.Replace("\r\n", "\n").Replace('\r', '\n'); + var masked = Mask(text); + + var includes = new List(); + var items = new List(); + var statements = new List(); + var namespaces = 0; + + var position = 0; + var counted = 0; + var line = 0; // the line (from 0) the text before `counted` ends on + int LineAt(int index) + { + for (; counted < index && counted < masked.Length; counted++) if (masked[counted] == '\n') line++; + return line; + } + + while (position < masked.Length) + { + // blank space between pieces + while (position < masked.Length && char.IsWhiteSpace(masked[position])) position++; + if (position >= masked.Length) break; + var start = position; + var startLine = LineAt(start); + + if (masked[position] == '#' && OnlySpaceBefore(masked, position)) + { + // a directive runs to the end of its line, and on past a backslash + while (position < masked.Length) + { + var end = masked.IndexOf('\n', position); + if (end < 0) { position = masked.Length; break; } + var continued = end > position && masked[end - 1] == '\\'; + position = end + 1; + if (!continued) break; + } + + AddDirective(text, masked, start, position, startLine, includes, items); + continue; + } + + var firstWord = FirstWord(masked, position); + var typeDefinition = firstWord is "struct" or "class" or "union" or "enum"; + var depth = 0; + var semicolon = false; + while (position < masked.Length) + { + var c = masked[position++]; + if (c is '(' or '[' or '{') depth++; + else if (c is ')' or ']') depth = Math.Max(0, depth - 1); + else if (c == '}') + { + depth = Math.Max(0, depth - 1); + if (depth == 0) + { + var next = NextWord(masked, position, out var at); + if (next == ";") { position = at + 1; semicolon = true; break; } + if ((firstWord == "if" && next == "else") || (firstWord == "try" && next == "catch") || firstWord == "do" || typeDefinition || next is "," or ")" or "=") continue; + break; + } + } + else if (c == ';' && depth == 0) + { + semicolon = true; + break; + } + } + + var chunkText = text[start..position]; + var chunkMasked = masked[start..position]; + var count = chunkText.TrimEnd('\n').Count(ch => ch == '\n') + 1; + Classify(chunkText.TrimEnd('\n'), chunkMasked.TrimEnd('\n'), startLine + 1, count, semicolon, items, statements, ref namespaces); + } + + return new CppCell(includes, items, statements); + } + + private static void AddDirective(string text, string masked, int start, int end, int startLine, List includes, List items) + { + var directive = text[start..end].TrimEnd('\n', '\r'); + var maskedDirective = masked[start..end].TrimEnd('\n'); + var trimmed = maskedDirective.Trim(); + var count = directive.Count(ch => ch == '\n') + 1; + if (IncludeLine().Match(directive.Trim()) is { Success: true } include) + { + includes.Add($"#include {include.Groups["path"].Value}"); + return; + } + + if (DefineLine().Match(trimmed) is { Success: true } define) + { + items.Add(new CppChunk(CppChunkKind.Item, directive, maskedDirective, startLine + 1, count, false) { ItemKind = "macro", Names = [define.Groups["name"].Value], Keys = [$"macro:{define.Groups["name"].Value}"] }); + return; + } + + if (UndefLine().Match(trimmed).Success || trimmed.StartsWith("#pragma", StringComparison.Ordinal)) + { + items.Add(new CppChunk(CppChunkKind.Item, directive, maskedDirective, startLine + 1, count, false) { ItemKind = "macro", Keys = [$"directive:{directive}"] }); + } + } + + private static void Classify(string text, string masked, int startLine, int lineCount, bool semicolon, List items, List statements, ref int namespaces) + { + var trimmed = masked.Trim(); + var word = FirstWord(trimmed, 0); + + CppChunk Item(string kind, IReadOnlyList names, IReadOnlyList keys, IReadOnlyList? members = null) => + new(CppChunkKind.Item, text, masked, startLine, lineCount, semicolon) { ItemKind = kind, Names = names, Keys = keys, Members = members ?? [] }; + + switch (word) + { + case "using": + var alias = Regex.Match(trimmed, @"^using\s+(?[A-Za-z_]\w*)\s*="); + var key = alias.Success ? $"name:{alias.Groups["name"].Value}" : "using:" + Collapse(trimmed); + items.Add(Item("using", alias.Success ? [alias.Groups["name"].Value] : [], [key])); + return; + case "typedef": + var typedefName = Regex.Match(trimmed.TrimEnd(';').TrimEnd(), @"([A-Za-z_]\w*)\s*$"); + items.Add(Item("using", typedefName.Success ? [typedefName.Groups[1].Value] : [], [typedefName.Success ? $"name:{typedefName.Groups[1].Value}" : "typedef:" + Collapse(trimmed)])); + return; + case "namespace": + // open: more of the same namespace joins it, so no piece replaces another + var spaceName = Regex.Match(trimmed, @"^namespace\s+([A-Za-z_][\w:]*)?"); + items.Add(Item("namespace", spaceName.Groups[1].Success ? [spaceName.Groups[1].Value] : [], [$"namespace:{namespaces++}"])); + return; + case "template": + items.Add(Item("template", [], ["template:" + Collapse(HeaderOf(trimmed))])); + return; + case "struct" or "class" or "union" or "enum": + if (TypeHead().Match(trimmed) is { Success: true } head && IsTypeDefinition(trimmed)) + { + var name = head.Groups["name"].Value; + items.Add(Item("type", name.Length > 0 ? [name] : [], name.Length > 0 ? [$"name:{name}"] : [$"type:{namespaces++}"], word is "struct" or "class" ? PublicMembers(text, masked, word) : [])); + return; + } + + break; + } + + if (IsFunction(trimmed, out var functionName, out var signature, out var definition)) + { + items.Add(Item("function", [functionName], [$"function:{signature}"])); + _ = definition; + return; + } + + // a declaration of something every cell may use: a constant, a static, an extern + var first = FirstWord(trimmed, 0); + if (Storage.Contains(first) && first is not ("mutable" or "register") && TryParseDeclaration(trimmed, out var globals)) + { + items.Add(Item("var", globals, globals.Select(g => $"name:{g}").ToList())); + return; + } + + var declared = TryParseDeclaration(trimmed, out var names) ? names : []; + statements.Add(new CppChunk(CppChunkKind.Statement, text, masked, startLine, lineCount, semicolon) { Names = declared }); + } + + // struct Name { ... }; / struct Name; (not "struct Name var = ...;" nor a function returning a struct) + private static bool IsTypeDefinition(string masked) + { + var brace = masked.IndexOf('{'); + var paren = masked.IndexOf('('); + var equals = masked.IndexOf('='); + if (brace >= 0 && (paren < 0 || brace < paren) && (equals < 0 || brace < equals)) return true; + return Regex.IsMatch(masked, @"^(?:struct|class|union|enum(?:\s+(?:class|struct))?)\s+[A-Za-z_]\w*\s*;\s*$"); + } + + // ── functions ─────────────────────────────────────────────────────────────────────────────────────────────────────── + + /// A function definition (int f(int a) { ... }) or a prototype (int f(int);), with its name and its signature (the name and the parameter types). + private static bool IsFunction(string masked, out string name, out string signature, out bool definition) + { + name = signature = string.Empty; + definition = false; + var open = IndexAtDepthZero(masked, '('); + if (open <= 0) return false; + var before = masked[..open].TrimEnd(); + var nameMatch = Regex.Match(before, @"(operator\s*[^\s\w(]+|operator\s+\w+|~?[A-Za-z_]\w*(?:\s*::\s*~?[A-Za-z_]\w*)*)\s*$"); + if (!nameMatch.Success) return false; + var prefix = before[..nameMatch.Index].Trim(); + var functionName = nameMatch.Groups[1].Value; + if (NotDeclarations.Contains(FirstWord(masked, 0)) || NotDeclarations.Contains(functionName)) return false; + if (prefix.Length == 0 && functionName != "main") return false; // a call, or a constructor + if (prefix.Contains('=') || prefix.Contains('.') || prefix.Contains("->")) return false; + var close = MatchingParen(masked, open); + if (close < 0) return false; + + var after = masked[(close + 1)..].TrimStart(); + // qualifiers, a trailing return type, an exception specification + var tail = Regex.Match(after, @"^(?:(?:const|volatile|noexcept(?:\s*\([^)]*\))?|override|final|&&|&)\s*)*(?:->[^;{]*)?"); + var rest = after[tail.Length..].TrimStart(); + var parameters = masked[(open + 1)..close]; + if (rest.StartsWith('{') || rest.StartsWith(':') && prefix.Length > 0) + { + name = functionName; + signature = functionName + "(" + NormalizeParameterTypes(parameters) + ")"; + definition = true; + return true; + } + + if ((rest.Length == 0 || rest == ";") && LooksLikePrototype(parameters)) + { + name = functionName; + signature = functionName + "(" + NormalizeParameterTypes(parameters) + ")"; + return true; + } + + return false; + } + + private static readonly HashSet BuiltinTypes = new(StringComparer.Ordinal) + { + "int", "long", "short", "char", "float", "double", "bool", "unsigned", "signed", "void", "size_t", "auto", "wchar_t", "char8_t", "char16_t", "char32_t", + "int8_t", "int16_t", "int32_t", "int64_t", "uint8_t", "uint16_t", "uint32_t", "uint64_t" + }; + + // Widget w(3); is a variable; int f(int); and void g(const std::string&); are prototypes. + private static bool LooksLikePrototype(string parameters) + { + var trimmed = parameters.Trim(); + if (trimmed.Length == 0 || trimmed == "void") return true; + foreach (var parameter in SplitTopLevel(trimmed, ',')) + { + var p = parameter.Trim(); + var first = FirstWord(p, 0); + if (p.Length == 0 || char.IsDigit(p[0]) || p[0] is '"' or '\'' or '-' or '(' or '{') return false; + if (BuiltinTypes.Contains(first) || first is "const" or "volatile" or "struct" or "class" or "typename" or "enum") continue; + // Type name, Type& name, std::string name: two names, or a qualified one, or a pointer or a reference + if (Regex.IsMatch(p, @"^[A-Za-z_][\w:<>,\s]*[\s*&]+[A-Za-z_]\w*\s*(=.*)?$") || p.Contains("::") || p.Contains('<') || p.Contains('*') || p.Contains('&')) continue; + return false; + } + + return true; + } + + // "const std::string& name, int count = 3" -> "const std::string&,int": what tells overloads apart + private static string NormalizeParameterTypes(string parameters) + { + var types = new List(); + foreach (var parameter in SplitTopLevel(parameters, ',')) + { + var p = parameter.Trim(); + var equals = IndexAtDepthZero(p, '='); + if (equals >= 0) p = p[..equals].Trim(); + var trailingName = Regex.Match(p, @"^(.*?[\s*&])([A-Za-z_]\w*)$"); + if (trailingName.Success && !BuiltinTypes.Contains(trailingName.Groups[2].Value) && trailingName.Groups[2].Value is not ("const" or "volatile")) p = trailingName.Groups[1].Value.Trim(); + if (p.Length > 0) types.Add(Collapse(p)); + } + + return string.Join(",", types); + } + + // ── declarations ──────────────────────────────────────────────────────────────────────────────────────────────────── + + /// Whether a statement declares variables (int x = 5;, std::vector<int> v{1, 2};, auto [a, b] = f();), and their names. + public static bool TryParseDeclaration(string masked, out List names) + { + names = []; + var text = masked.Trim().TrimEnd(';').Trim(); + if (text.Length == 0) return false; + var position = 0; + var typeTokens = 0; + + // storage and qualifiers, then the type: names joined by ::, with template arguments, pointers, references and const + while (true) + { + SkipSpace(text, ref position); + if (position >= text.Length) return false; + var word = FirstWord(text, position); + if (word.Length > 0 && NotDeclarations.Contains(word)) return false; + if (word.Length > 0 && Storage.Contains(word)) { position += word.Length; continue; } + break; + } + + var sawAuto = false; + var lastIdentifier = -1; + var lastIdentifierEnd = -1; + while (position < text.Length) + { + SkipSpace(text, ref position); + if (position >= text.Length) break; + var c = text[position]; + if (char.IsLetter(c) || c == '_') + { + var word = FirstWord(text, position); + if (word == "auto") sawAuto = true; + lastIdentifier = position; + lastIdentifierEnd = position + word.Length; + position += word.Length; + typeTokens++; + continue; + } + + if (c == ':' && position + 1 < text.Length && text[position + 1] == ':') + { + position += 2; + typeTokens--; // the name before :: was part of the type, not the declarator + if (typeTokens < 0) typeTokens = 0; + continue; + } + + if (c == '<') + { + var close = MatchingAngle(text, position); + if (close < 0) return false; + position = close + 1; + continue; + } + + if (c is '*' or '&') { position++; continue; } + if (c == '[' && sawAuto) + { + // a structured binding: auto [a, b] = f(); + var close = text.IndexOf(']', position); + if (close < 0) return false; + names = text[(position + 1)..close].Split(',').Select(n => n.Trim()).Where(n => n.Length > 0 && n != "_").ToList(); + return names.Count > 0; + } + + break; // the declarator's end: = ( { [ ; , + } + + if (lastIdentifier < 0 || typeTokens < 2) return false; + // the last name before the end is the declarator's, the rest was the type + var firstName = text[lastIdentifier..lastIdentifierEnd]; + if (BuiltinTypes.Contains(firstName) && !sawAuto) return false; // "int" alone: a type with nothing declared + if (position < text.Length && text[position] is not ('=' or '(' or '{' or '[' or ',' or ';' or ':')) return false; + names.Add(firstName); + + // more declarators: int a = 1, b = 2; + var remaining = text[position..]; + var depth = 0; + for (var i = 0; i < remaining.Length; i++) + { + var ch = remaining[i]; + if (ch is '(' or '{' or '[' or '<') depth++; + else if (ch is ')' or '}' or ']' or '>') depth = Math.Max(0, depth - 1); + else if (ch == ',' && depth == 0) + { + var match = Regex.Match(remaining[(i + 1)..], @"^\s*[*&]*\s*([A-Za-z_]\w*)"); + if (match.Success) names.Add(match.Groups[1].Value); + } + } + + return names.Count > 0; + } + + // The public data members of a struct or class, in order: the notebook can show a type whose members it knows. + private static IReadOnlyList PublicMembers(string text, string masked, string word) + { + var open = masked.IndexOf('{'); + var close = masked.LastIndexOf('}'); + if (open < 0 || close <= open) return []; + if (masked[..open].Contains('<') && masked[..open].Contains("template")) return []; + var inner = masked[(open + 1)..close]; + var members = new List(); + var isPublic = word == "struct"; + var position = 0; + while (position < inner.Length) + { + while (position < inner.Length && char.IsWhiteSpace(inner[position])) position++; + if (position >= inner.Length) break; + var start = position; + var depth = 0; + while (position < inner.Length) + { + var c = inner[position++]; + if (c is '(' or '[' or '{') depth++; + else if (c is ')' or ']') depth = Math.Max(0, depth - 1); + else if (c == '}') + { + depth = Math.Max(0, depth - 1); + if (depth == 0) + { + // a method's body or a nested type: ends at }, or at the ; after it + var next = NextWord(inner, position, out var at); + if (next == ";") position = at + 1; + break; + } + } + else if (c == ';' && depth == 0) break; + } + + var member = inner[start..position].Trim(); + var label = AccessLabel().Match(member); + if (label.Success) + { + isPublic = label.Groups[1].Value == "public"; + member = member[label.Length..].Trim(); + if (member.Length == 0) continue; + } + + if (!isPublic || !member.EndsWith(';')) continue; + var memberWord = FirstWord(member, 0); + if (memberWord is "using" or "typedef" or "friend" or "static" or "template" or "enum" or "struct" or "class" or "union" or "virtual" or "explicit" or "operator" or "constexpr" or "inline") + { + if (memberWord is "struct" or "class" or "union" or "enum" && !member.Contains('{')) { } + continue; + } + + if (member.Contains('~') || member.Contains(':') && !member.Contains("::") || HasParenthesisOutsideAngles(member)) continue; + // a const or reference member can't be read back into: the type isn't shown as a record + if (Regex.IsMatch(member, @"\bconst\b") || Regex.IsMatch(member, @"[A-Za-z_>\]]\s*&\s*[A-Za-z_]")) return []; + if (TryParseDeclaration(member, out var names)) members.AddRange(names); + } + + return members; + } + + private static bool HasParenthesisOutsideAngles(string text) + { + var angle = 0; + foreach (var c in text) + { + if (c == '<') angle++; + else if (c == '>') angle = Math.Max(0, angle - 1); + else if (c == '(' && angle == 0) return true; + } + + return false; + } + + // ── what a statement is ───────────────────────────────────────────────────────────────────────────────────────────── + + /// + /// Whether the last statement, which has no ;, is an expression whose value could be shown (x * 2, v.size(), a bare name), as + /// against a declaration, an assignment, something that prints. A call that returns nothing looks like one: the build tells. + /// + public static bool IsValueExpression(CppChunk statement) + { + if (statement.EndsWithSemicolon) return false; + var masked = statement.Masked.Trim(); + if (masked.Length == 0 || masked[0] == '{') return false; // nothing, or a block + if (NotValues.Contains(FirstWord(masked, 0))) return false; + if (Printing().IsMatch(masked)) return false; + if (TryParseDeclaration(masked, out _)) return false; + + var depth = 0; + for (var i = 0; i < masked.Length; i++) + { + var c = masked[i]; + if (c is '(' or '{' or '[') depth++; + else if (c is ')' or '}' or ']') depth = Math.Max(0, depth - 1); + if (depth != 0) continue; + var next = i + 1 < masked.Length ? masked[i + 1] : '\0'; + var previous = i > 0 ? masked[i - 1] : '\0'; + if (c == '=' && next != '=' && (previous is not ('=' or '!' or '<' or '>') || (i > 1 && masked.AsSpan(i - 2, 2) is "<<" or ">>"))) return false; + if (c is '+' or '-' && next == c && masked[(i + 2)..].Trim().Length == 0) return false; + if (c is '+' or '-' && next == c && i == 0) return false; + } + + return true; + } + + // ── masking and small scanners ────────────────────────────────────────────────────────────────────────────────────── + + /// The text with every comment and the inside of every string, character and raw string literal turned to spaces (line breaks stay). + public static string Mask(string text) + { + var result = new StringBuilder(text.Length); + var state = 0; // 0 code, 1 line comment, 2 block comment, 3 string, 4 char + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + var next = i + 1 < text.Length ? text[i + 1] : '\0'; + if (c == '\n') + { + if (state is 1 or 3 or 4) state = 0; + result.Append('\n'); + continue; + } + + switch (state) + { + case 0: + if (c == '/' && next == '/') { state = 1; result.Append(" "); i++; } + else if (c == '/' && next == '*') { state = 2; result.Append(" "); i++; } + else if (c == '"' && i > 0 && text[i - 1] == 'R' && RawStringEnd(text, i) is var end && end > i) + { + // R"delim( ... )delim": blank the inside, keep the line breaks + for (var k = i; k < end; k++) result.Append(text[k] == '\n' ? '\n' : k == i || k == end - 1 ? '"' : ' '); + i = end - 1; + } + else if (c == '"') { state = 3; result.Append(c); } + else if (c == '\'' && !(i > 0 && char.IsDigit(text[i - 1]) && Uri.IsHexDigit(next))) { state = 4; result.Append(c); } + else result.Append(c); + break; + case 1: + result.Append(' '); + break; + case 2: + if (c == '*' && next == '/') { state = 0; result.Append(" "); i++; } + else result.Append(' '); + break; + case 3 or 4: + if (c == '\\' && next != '\n' && next != '\0') { result.Append(" "); i++; } + else if ((state == 3 && c == '"') || (state == 4 && c == '\'')) { state = 0; result.Append(c); } + else result.Append(' '); + break; + } + } + + return result.ToString(); + } + + // Where R"delim(...)delim" ends (the index after its closing quote), or -1 when it doesn't. + private static int RawStringEnd(string text, int quote) + { + var open = text.IndexOf('(', quote); + if (open < 0 || open - quote > 17) return -1; + var delimiter = text[(quote + 1)..open]; + if (delimiter.Any(ch => char.IsWhiteSpace(ch) || ch is ')' or '\\')) return -1; + var closing = ")" + delimiter + "\""; + var end = text.IndexOf(closing, open + 1, StringComparison.Ordinal); + return end < 0 ? -1 : end + closing.Length; + } + + private static bool OnlySpaceBefore(string text, int position) + { + for (var i = position - 1; i >= 0 && text[i] != '\n'; i--) + { + if (!char.IsWhiteSpace(text[i])) return false; + } + + return true; + } + + private static string FirstWord(string text, int position) + { + var end = position; + while (end < text.Length && (char.IsLetterOrDigit(text[end]) || text[end] == '_')) end++; + return text[position..end]; + } + + // The next thing after position that isn't space: ";" or a word or a single character, and where it is. + private static string NextWord(string text, int position, out int at) + { + at = position; + while (at < text.Length && char.IsWhiteSpace(text[at])) at++; + if (at >= text.Length) return string.Empty; + if (char.IsLetter(text[at]) || text[at] == '_') return FirstWord(text, at); + return text[at].ToString(); + } + + private static void SkipSpace(string text, ref int position) + { + while (position < text.Length && char.IsWhiteSpace(text[position])) position++; + } + + private static int IndexAtDepthZero(string text, char target) + { + var depth = 0; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == target && depth == 0) return i; + if (c is '(' or '[' or '{') depth++; + else if (c is ')' or ']' or '}') depth = Math.Max(0, depth - 1); + } + + return -1; + } + + private static int MatchingParen(string text, int open) + { + var depth = 0; + for (var i = open; i < text.Length; i++) + { + if (text[i] == '(') depth++; + else if (text[i] == ')' && --depth == 0) return i; + } + + return -1; + } + + private static int MatchingAngle(string text, int open) + { + var depth = 0; + for (var i = open; i < text.Length; i++) + { + if (text[i] == '<') depth++; + else if (text[i] == '>' && --depth == 0) return i; + else if (text[i] is ';' or '{' or '}') return -1; + } + + return -1; + } + + private static List SplitTopLevel(string text, char separator) + { + var parts = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c is '(' or '[' or '{' or '<') depth++; + else if (c is ')' or ']' or '}' or '>') depth = Math.Max(0, depth - 1); + else if (c == separator && depth == 0) + { + parts.Add(text[start..i]); + start = i + 1; + } + } + + parts.Add(text[start..]); + return parts; + } + + private static string HeaderOf(string masked) + { + var brace = masked.IndexOf('{'); + var semicolon = masked.IndexOf(';'); + var end = new[] { brace, semicolon }.Where(i => i >= 0).DefaultIfEmpty(masked.Length).Min(); + return masked[..end]; + } + + private static string Collapse(string text) => string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + /// Where code ends on a line: its length without trailing spaces (the line is given masked, so a trailing comment is spaces). + public static int CodeEnd(string maskedLine) + { + var end = maskedLine.Length; + while (end > 0 && char.IsWhiteSpace(maskedLine[end - 1])) end--; + return end; + } +} diff --git a/FrySharp.Core/Services/Languages/Cpp/CppConsoleText.cs b/FrySharp.Core/Services/Languages/Cpp/CppConsoleText.cs new file mode 100644 index 00000000..4045214c --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/CppConsoleText.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; + +/// +/// What the compiler says, in the cell's own terms: it names the generated program's file and its lines (/tmp/.../cell.cpp:12:5:, +/// cell.cpp(12,5) for MSVC, and the quoted 12 | code lines), and the user wrote a cell, so the file becomes cell and the +/// lines the cell's own (cell:3:5:). +/// +internal static partial class CppConsoleText +{ + public const string ProgramFileName = "cell.cpp"; + + [GeneratedRegex(@"(?:[^\s:""(<>]*[\\/])?cell\.cpp(?::(?\d+)(?::(?\d+))?|\((?\d+)(?:,(?\d+))?\))?")] + private static partial Regex Position(); + + // " 12 | int x = ..." in a compiler's quoted source + [GeneratedRegex(@"^(\s*)(\d+)(\s+\|)")] + private static partial Regex Gutter(); + + [GeneratedRegex(@"\A(?:In file included from [^\n]*\n)+")] + private static partial Regex IncludeChain(); + + public static string Positions(string text, CppCellProgram program) + { + if (text.Length == 0 || !text.Contains(ProgramFileName, StringComparison.Ordinal)) return text; + return Position().Replace(text, match => + { + var lineGroup = match.Groups["line"].Success ? match.Groups["line"] : match.Groups["mline"]; + if (!lineGroup.Success) return "cell"; + var line = program.CellLineOf(int.Parse(lineGroup.Value)); + if (line == null) return "an earlier cell"; + var column = match.Groups["column"].Success ? match.Groups["column"] : match.Groups["mcolumn"]; + return column.Success ? $"cell:{line}:{column.Value}" : $"cell:{line}"; + }); + } + + /// The compiler's messages for a program that didn't build: at the cell's positions, with the quoted lines' numbers the cell's. + public static string Build(string text, CppCellProgram program) + { + // "In file included from ..." introduces a warning from a header: not about the cell + var lines = Positions(text.Replace("\r\n", "\n"), program).Split('\n').Where(l => !l.StartsWith("In file included from", StringComparison.Ordinal) && !l.StartsWith(" from ", StringComparison.Ordinal)).ToArray(); + for (var i = 0; i < lines.Length; i++) + { + var match = Gutter().Match(lines[i]); + if (!match.Success) continue; + if (program.CellLineOf(int.Parse(match.Groups[2].Value)) is not { } cellLine) continue; + lines[i] = match.Groups[1].Value + cellLine.ToString().PadLeft(match.Groups[2].Length) + lines[i][(match.Groups[1].Length + match.Groups[2].Length)..]; + } + + return string.Join('\n', lines).Trim(); + } +} diff --git a/FrySharp.Core/Services/Languages/Cpp/CppDisplayRuntime.cs b/FrySharp.Core/Services/Languages/Cpp/CppDisplayRuntime.cs new file mode 100644 index 00000000..3f9aee3c --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/CppDisplayRuntime.cs @@ -0,0 +1,80 @@ +using System.IO; +using System.Reflection; +using System.Text; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; + +/// +/// Provides zero-configuration runtime support for C++ scripts and algorithms in FrySharp, +/// staging <fry/display.hpp> and "display.hpp" into the compiler include path. +/// User scripts can call canonical visuals (fry::line_chart, fry::tree, etc.), +/// tables, HTML, images, and handles with interactive Results (.DUMP) deck rendering. +/// +public static class CppDisplayRuntime +{ + private const string ResourcePrefix = "CppRuntime."; + + private static readonly SemaphoreSlim IncludeGate = new(1, 1); + + public static string DisplayHppContent => RuntimeFiles().FirstOrDefault(f => f.Name == "display.hpp").Content ?? string.Empty; + + /// The headers of the runtime (display.hpp for the charts, tables and visuals, values.hpp for showing, keeping and sharing values), by name. + private static IReadOnlyList<(string Name, string Content)> RuntimeFiles() + { + var assembly = typeof(CppDisplayRuntime).Assembly; + var files = new List<(string, string)>(); + foreach (var resource in assembly.GetManifestResourceNames().Where(n => n.StartsWith(ResourcePrefix, StringComparison.Ordinal)).Order(StringComparer.Ordinal)) + { + using var stream = assembly.GetManifestResourceStream(resource)!; + using var reader = new StreamReader(stream, Encoding.UTF8); + files.Add((resource[ResourcePrefix.Length..], reader.ReadToEnd())); + } + + if (files.Count > 0) return files; + + // Fallback to the local source files if they are not compiled into the assembly + var folder = Path.Combine(AppContext.BaseDirectory, "Services", "Languages", "Cpp", "Runtime"); + return Directory.Exists(folder) ? Directory.GetFiles(folder, "*.hpp").Order(StringComparer.Ordinal).Select(f => (Path.GetFileName(f), File.ReadAllText(f))).ToList() : []; + } + + private static async Task WriteIfChangedAsync(string path, string content, CancellationToken ct) + { + if (File.Exists(path) && await File.ReadAllTextAsync(path, ct).ConfigureAwait(false) == content) return; + await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false); + } + + /// + /// Stages the headers as <fry/display.hpp> and <fry/values.hpp> (and "display.hpp", "fry_display.hpp" for what was written + /// against them) in an include folder under , and returns it. Written only when a header changed. + /// + public static async Task EnsureIncludeDirectoryAsync(string buildDir, CancellationToken ct = default) + { + var includeDir = Path.Combine(buildDir, "include"); + var fryIncludeDir = Path.Combine(includeDir, "fry"); + + await IncludeGate.WaitAsync(ct).ConfigureAwait(false); + try + { + Directory.CreateDirectory(fryIncludeDir); + foreach (var (name, content) in RuntimeFiles()) + { + await WriteIfChangedAsync(Path.Combine(fryIncludeDir, name), content, ct).ConfigureAwait(false); + if (name == "display.hpp") + { + await WriteIfChangedAsync(Path.Combine(includeDir, "display.hpp"), content, ct).ConfigureAwait(false); + await WriteIfChangedAsync(Path.Combine(includeDir, "fry_display.hpp"), content, ct).ConfigureAwait(false); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Gracefully ignore filesystem contention + } + finally + { + IncludeGate.Release(); + } + + return includeDir; + } +} diff --git a/FrySharp.Core/Services/Languages/Cpp/CppEngine.cs b/FrySharp.Core/Services/Languages/Cpp/CppEngine.cs new file mode 100644 index 00000000..09a78ead --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/CppEngine.cs @@ -0,0 +1,72 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; + +/// +/// C++, compiled with clang++, g++ or MSVC cl.exe and run natively for source files: a two-phase build and run, the compiler's messages +/// in Problems, interactive standard input, and packages from vcpkg. Notebook cells are built as small programs and run, with what earlier +/// cells declared and made carried over (see ). +/// +public sealed class CppEngine : LanguageEngine +{ + private static readonly string[] CppAliases = ["cpp", "c++", "cc", "cplusplus"]; + private static readonly string[] CppExtensions = [".cpp", ".cc", ".cxx", ".hpp", ".h"]; + + public CppEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + CppRoot = Path.Combine(services.BaseDirectory, "cpp"); + var toolchain = new CppToolchainProvider(services.Host, services.Processes, services.ToolchainSettings, CppRoot); + CppToolchain = toolchain; + ScriptRunner = new CppBuildAndRunScriptRunner(services.Host); + Packages = new CppPackageManager(toolchain, services.Processes, services.Host); + NotebookKernels = new DelegateKernelFactory(context => + new CppNotebookKernel(toolchain, services.Processes, services.Host, CppRoot, context)); + } + + /// Where the engine keeps what it builds: the staged headers and the notebooks' cells. + public string CppRoot { get; } + + public CppToolchainProvider CppToolchain { get; } + + public override string Id => LanguageIds.Cpp; + public override string DisplayName => "C++"; + public override JupyterLanguageInfo Jupyter { get; } = new("cpp", "C++", "c++", "c++", ".cpp", "text/x-c++src"); + public override string ShortName => "CPP"; + public override IReadOnlyList Aliases => CppAliases; + public override IReadOnlyList FileExtensions => CppExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + public override bool IsCompiled => true; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | + LanguageCapabilities.QuickInfo | + LanguageCapabilities.Completion | + LanguageCapabilities.Debugging | + LanguageCapabilities.Breakpoints | + LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | + LanguageCapabilities.Packages; + + public override string IconKind => "LanguageCpp"; + public override string AccentHex => "#00599C"; + public override string LineCommentPrefix => "//"; + public override string RuntimeDescription => "C++ (Clang / GCC / MSVC)"; + + public override string NewFileTemplate => + "// Runs with the C++ compiler (clang++, g++, or cl.exe) installed on this computer (F5).\n" + + "#include \n\n" + + "int main() {\n" + + " std::cout << \"Hello from C++20!\" << std::endl;\n" + + " return 0;\n" + + "}\n"; + + public override IToolchainProvider Toolchain => CppToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new ClangGccDiagnosticParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/Cpp/CppGuidance.cs b/FrySharp.Core/Services/Languages/Cpp/CppGuidance.cs similarity index 100% rename from Services/Languages/Cpp/CppGuidance.cs rename to FrySharp.Core/Services/Languages/Cpp/CppGuidance.cs diff --git a/FrySharp.Core/Services/Languages/Cpp/CppNotebookKernel.cs b/FrySharp.Core/Services/Languages/Cpp/CppNotebookKernel.cs new file mode 100644 index 00000000..f85641a3 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/CppNotebookKernel.cs @@ -0,0 +1,576 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; +using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; + +/// +/// Notebook kernel for C++. Each cell is built with the C++ compiler as a small program and run: the includes and declarations earlier cells made are +/// written in front of it, the variables of plain data they made are given back their values, and the ones this cell makes are handed on as JSON, with +/// their types, for the next one (see ). The last expression of a cell, when it has no ;, is shown, errors are on +/// the cell's own lines, and values move to and from other languages as JSON. +/// +public sealed partial class CppNotebookKernel : INotebookKernel +{ + [GeneratedRegex(@"\bfry\s*::\s*(?:input|password)\b")] + private static partial Regex AsksForInput(); + + [GeneratedRegex(@"\b(?:error: )?use of undeclared identifier '(?[A-Za-z_]\w*)'|'(?[A-Za-z_]\w*)' was not declared in this scope|identifier ""(?[A-Za-z_]\w*)"" is undefined")] + private static partial Regex UndeclaredName(); + + // libstdc++: "terminate called after throwing an instance of 'std::runtime_error'" then " what(): boom"; libc++: "libc++abi: terminating due to uncaught exception of type std::runtime_error: boom" + [GeneratedRegex(@"terminate called after throwing an instance of '(?[^']+)'\s*(?:\r?\n\s*what\(\):\s*(?.*))?|terminating due to uncaught exception of type (?[\w<>]+(?:::[\w<>]+)*)(?::\s*(?.*))?")] + private static partial Regex UncaughtException(); + + private readonly CppToolchainProvider _toolchain; + private readonly IProcessLauncher _processes; + private readonly IHostEnvironment _host; + private readonly string _cppRoot; + private readonly KernelCreationContext _context; + private readonly string _moduleDirectory; + private readonly object _stateLock = new(); + + private readonly CppItemStore _items = new(); + private readonly List _includes = []; + private readonly Dictionary _variables = new(StringComparer.Ordinal); + private readonly Dictionary _unkept = new(StringComparer.Ordinal); + private readonly Dictionary _shared = new(StringComparer.Ordinal); + private int _executionCount; + private bool _isDisposed; + + public CppNotebookKernel( + CppToolchainProvider toolchain, + IProcessLauncher processes, + IHostEnvironment host, + string cppRoot, + KernelCreationContext context) + { + _toolchain = toolchain ?? throw new ArgumentNullException(nameof(toolchain)); + _processes = processes ?? throw new ArgumentNullException(nameof(processes)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + _cppRoot = cppRoot ?? throw new ArgumentNullException(nameof(cppRoot)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _moduleDirectory = Path.Combine(cppRoot, "notebook", "cells", Guid.NewGuid().ToString("N")[..12]); + } + + public string LanguageId => LanguageIds.Cpp; + public string DisplayName => "C++20 (Clang / GCC / MSVC)"; + public bool CanForceStop => true; + + public bool IsSessionActive + { + get + { + lock (_stateLock) return _executionCount > 0 || _variables.Count > 0 || _items.Items.Count > 0; + } + } + + private sealed record Build(CppCellProgram Program, string BinaryPath, IReadOnlyList Dropped, IReadOnlyDictionary Environment); + + private sealed record BuildOutcome(Build? Build, KernelExecutionResult? Failure); + + public async Task ExecuteAsync(KernelExecutionRequest request, CancellationToken ct) + { + var clock = Stopwatch.StartNew(); + lock (_stateLock) _executionCount++; + + var workingFolder = _context.WorkingDirectory(); + if (string.IsNullOrWhiteSpace(workingFolder) || !Directory.Exists(workingFolder)) workingFolder = _host.HomeDirectory; + + var resolution = await _toolchain.ResolveAsync(new ToolchainQuery(workingFolder, _context.WorkspaceRoot?.Invoke()), ct).ConfigureAwait(false); + if (!resolution.IsFound || resolution.Toolchain == null) + { + var guidance = resolution.Missing ?? CppGuidance.NotInstalled(_host); + var text = guidance.ToText(); + request.OnConsole?.Invoke(text + "\n"); + return new KernelExecutionResult { Success = false, ErrorMessage = guidance.Summary, ConsoleOutput = text, Elapsed = clock.Elapsed }; + } + + var cell = CppCellSplitter.Split(request.Code); + try + { + Directory.CreateDirectory(_moduleDirectory); + var outcome = await BuildAsync(cell, resolution.Toolchain, workingFolder!, request, clock, ct).ConfigureAwait(false); + if (outcome.Failure != null) + { + if (!outcome.Failure.WasCancelled && outcome.Failure.ConsoleOutput.Length > 0) request.OnConsole?.Invoke(outcome.Failure.ConsoleOutput + "\n"); + return outcome.Failure; + } + + return await RunProgramAsync(request, outcome.Build!, workingFolder!, clock, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return Cancelled(clock); + } + catch (ProcessStartException ex) + { + request.OnConsole?.Invoke(ex.Message + "\n"); + return Failure(ex.Message, ex.Message, clock); + } + } + + private async Task BuildAsync(CppCell cell, ToolchainInfo toolchain, string workingFolder, KernelExecutionRequest request, Stopwatch clock, CancellationToken ct) + { + var sourcePath = Path.Combine(_moduleDirectory, CppConsoleText.ProgramFileName); + var isCl = CppCompilerCommand.IsCl(toolchain.ExecutablePath); + var binaryPath = Path.Combine(_moduleDirectory, _host.IsWindows || isCl ? "cell.exe" : "cell"); + var includeDirectory = await CppDisplayRuntime.EnsureIncludeDirectoryAsync(_cppRoot, ct).ConfigureAwait(false); + var includeDirectories = new List { workingFolder, includeDirectory }; + if (CppBuildAndRunScriptRunner.FindVcpkgIncludeDirectory(workingFolder, _host) is { } vcpkg) includeDirectories.Add(vcpkg); + var environment = await CppProcessEnvironment.ForAsync(_host, toolchain, ct).ConfigureAwait(false); + + var includes = new List(); + lock (_stateLock) + { + foreach (var include in cell.Includes) if (!_includes.Contains(include)) _includes.Add(include); + includes.AddRange(_includes); + } + + var showLast = true; + var dropped = new HashSet(StringComparer.Ordinal); + for (var attempt = 0; attempt < 4; attempt++) + { + CppItem[] items; + CppVariable[] variables; + lock (_stateLock) + { + items = [.. _items.Items]; + variables = _variables.Values.Where(v => !dropped.Contains(v.Name)).ToArray(); + } + + var program = CppCellProgramBuilder.Build(cell, items, includes, variables, showLast); + if (program.Error != null) return new BuildOutcome(null, Failure(program.Error, program.Error, clock)); + + await File.WriteAllTextAsync(sourcePath, program.Source, new UTF8Encoding(false), ct).ConfigureAwait(false); + var output = new StringBuilder(); + int exitCode; + using (var process = _processes.Start(new ProcessStartSpec + { + FileName = toolchain.ExecutablePath, + Arguments = [.. CppCompilerCommand.Arguments(_host, toolchain, sourcePath, binaryPath, _moduleDirectory, includeDirectories)], + WorkingDirectory = _moduleDirectory, + Environment = environment, + MergeStandardError = true + }, text => { lock (output) output.Append(text); }, text => { lock (output) output.Append(text); })) + { + // No limit of the kernel's own: how long a cell may take is the studio's ExecutionTimeoutSeconds setting, which arrives as the cancellation token. + exitCode = await process.WaitForExitOrKillAsync(ct).ConfigureAwait(false); + } + + string buildText; + lock (output) buildText = output.ToString(); + if (exitCode == 0) + { + lock (_stateLock) + { + foreach (var name in dropped) + { + _variables.Remove(name); + _unkept[name] = "its value couldn't be given back to the cell that follows"; + } + + _items.Apply(cell.Items); + } + + return new BuildOutcome(new Build(program, binaryPath, [.. dropped], environment), null); + } + + var parsed = new ClangGccDiagnosticParser().Parse(buildText, sourcePath); + var errors = parsed.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + + // The cell's last expression couldn't be shown (it has no value: a call that returns nothing): run it as it is. + if (showLast && program.ShownGenerated is { } shown && errors.Any(d => d.Line >= shown.First && d.Line <= shown.Last)) + { + showLast = false; + continue; + } + + // A variable an earlier cell made that can't be given back (its type isn't one this cell can write): the cell goes on without it. + var unrestorable = errors.Select(d => program.RestoreLines.GetValueOrDefault(d.Line)).Where(n => n != null && !dropped.Contains(n)).Select(n => n!).Distinct().ToList(); + if (unrestorable.Count > 0) + { + dropped.UnionWith(unrestorable); + continue; + } + + return new BuildOutcome(null, BuildFailure(program, buildText, parsed, clock)); + } + + return new BuildOutcome(null, Failure("The cell didn't build.", "The cell didn't build.", clock)); + } + + private KernelExecutionResult BuildFailure(CppCellProgram program, string output, DiagnosticParseResult parsed, Stopwatch clock) + { + var shown = CppConsoleText.Build(output, program) + UnkeptHint(output); + var diagnostics = MapToCell(parsed.Diagnostics, program); + var summary = diagnostics.FirstOrDefault(d => d.Severity == DiagnosticSeverity.Error)?.Message + ?? shown.Split('\n').FirstOrDefault(l => l.Length > 0) + ?? "The build failed"; + return new KernelExecutionResult + { + Success = false, + ErrorMessage = summary, + ConsoleOutput = shown, + Diagnostics = diagnostics, + MissingDependency = parsed.MissingDependency, + Elapsed = clock.Elapsed + }; + } + + // "use of undeclared identifier 'x'" about a variable an earlier cell made but the notebook couldn't keep says why it's gone. + private string UnkeptHint(string output) + { + List<(string Name, string Why)> found; + lock (_stateLock) + { + found = UndeclaredName().Matches(output) + .Select(m => m.Groups["name"].Success ? m.Groups["name"].Value : m.Groups["name2"].Success ? m.Groups["name2"].Value : m.Groups["name3"].Value) + .Distinct().Where(_unkept.ContainsKey).Select(n => (n, _unkept[n])).ToList(); + } + + return string.Concat(found.Select(f => $"\nnote: {f.Name} was made in an earlier cell, but the notebook couldn't keep it: {f.Why}. Make it again in this cell, or share it as data with fry::share(\"{f.Name}\", {f.Name}).")); + } + + // Puts a message on the line of the cell it is about; one from the scaffolding or an earlier cell's code has no line here. + private static IReadOnlyList MapToCell(IReadOnlyList diagnostics, CppCellProgram program) + { + var mapped = new List(diagnostics.Count); + foreach (var d in diagnostics) + { + var line = program.CellLineOf(d.Line); + var endLine = program.CellLineOf(d.EndLine); + mapped.Add(new DiagnosticItem + { + Id = d.Id, + Severity = d.Severity, + Message = line == null ? "In code from an earlier cell: " + d.Message : d.Message, + Line = line ?? 1, + Column = line == null ? 1 : d.Column, + EndLine = endLine ?? line ?? 1, + EndColumn = line == null ? 1 : d.EndColumn + }); + } + + return mapped; + } + + private async Task RunProgramAsync(KernelExecutionRequest request, Build build, string workingFolder, Stopwatch clock, CancellationToken ct) + { + var console = new StringBuilder(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var visuals = new ExternalVisualSession(); + var processor = new ExternalOutputProcessor( + onConsoleText: text => + { + lock (console) console.Append(text); + request.OnConsole?.Invoke(text); + }, + onRichOutput: bundle => request.OnRichOutput?.Invoke(bundle), + onShare: (name, json) => + { + lock (_stateLock) _shared[name] = json; + }, + visuals: visuals.Visuals, + onInputRequest: (prompt, password) => _ = AnswerInputAsync(started.Task, request, prompt, password, ct)); + + // The variables the cells before gave, and the ones this cell hands on, go through two files. + var varsIn = Path.Combine(_moduleDirectory, "variables-in.jsonl"); + var varsOut = Path.Combine(_moduleDirectory, "variables-out.jsonl"); + WriteVariablesIn(varsIn, build.Dropped); + TryDelete(varsOut); + + var environment = new Dictionary(build.Environment, StringComparer.Ordinal) + { + ["FRY_VARS_IN"] = varsIn, + ["FRY_VARS_OUT"] = varsOut, + ["FRY_INPUT_REQUESTS"] = "1" + }; + + IManagedProcess? running = null; + try + { + running = _processes.Start(visuals.Apply(new ProcessStartSpec + { + FileName = build.BinaryPath, + Arguments = Array.Empty(), + WorkingDirectory = workingFolder, + Environment = environment, + // std::cout and std::cerr in one cell read in the order they ran. + MergeStandardError = true + }), processor.ProcessChunk, processor.ProcessChunk); + started.SetResult(running); + + // A plain read of std::cin can't ask anyone for a line, so a program that does only that sees the end of its input rather than + // waiting for good; one that calls fry::input is asked for each line as it comes. + if (!(request.OnInputRequest != null && AsksForInput().IsMatch(build.Program.Source))) running.CloseInput(); + + var exitCode = await running.WaitForExitOrKillAsync(ct).ConfigureAwait(false); + + ct.ThrowIfCancellationRequested(); + processor.Flush(); + string consoleText; + lock (console) consoleText = console.ToString(); + + if (exitCode == 0) + { + var kept = KeepVariables(varsOut); + if (kept.Length > 0) + { + request.OnConsole?.Invoke(kept); + consoleText += kept; + } + + return new KernelExecutionResult { Success = true, ConsoleOutput = consoleText, Elapsed = clock.Elapsed }; + } + + return new KernelExecutionResult + { + Success = false, + ConsoleOutput = consoleText, + ErrorMessage = FailureMessage(exitCode, consoleText), + Elapsed = clock.Elapsed + }; + } + catch (OperationCanceledException) + { + return Cancelled(clock); + } + finally + { + running?.Dispose(); + } + } + + // An exception nobody caught, a crash: C++ has no line to give, so the cause is named. + private static string FailureMessage(int exitCode, string console) + { + if (UncaughtException().Match(console) is { Success: true } uncaught) + { + var type = uncaught.Groups["type"].Success ? uncaught.Groups["type"].Value : uncaught.Groups["type2"].Value; + var what = (uncaught.Groups["what"].Success ? uncaught.Groups["what"].Value : uncaught.Groups["what2"].Value).Trim(); + return what.Length > 0 ? $"{type}: {what}" : type; + } + + var signal = exitCode > 128 ? exitCode - 128 : exitCode < 0 ? -exitCode : 0; + return signal switch + { + 11 => "The program crashed (segmentation fault)", + 6 => "The program was aborted", + 8 => "The program crashed (arithmetic exception)", + 9 => "The program was killed", + _ => $"The program exited with code {exitCode}" + }; + } + + // The program asked for a line: the notebook's answer is written to it, and no answer (cancelled, or nobody to ask) ends its input. + private static async Task AnswerInputAsync(Task started, KernelExecutionRequest request, string prompt, bool password, CancellationToken ct) + { + IManagedProcess process; + try + { + process = await started.ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return; + } + + string? answer = null; + try + { + if (request.OnInputRequest is { } ask) answer = await ask(prompt, password, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException) + { + // Stopped, or the host can't ask: the same as no answer. + } + + if (answer == null) process.CloseInput(); + else await process.WriteInputAsync(answer + "\n", CancellationToken.None).ConfigureAwait(false); + } + + private void WriteVariablesIn(string path, IReadOnlyCollection dropped) + { + CppVariable[] variables; + lock (_stateLock) variables = _variables.Values.Where(v => !dropped.Contains(v.Name)).ToArray(); + var lines = variables.Select(v => JsonSerializer.Serialize(new { name = v.Name, json = JsonDocument.Parse(v.Json).RootElement })); + File.WriteAllText(path, string.Join('\n', lines) + "\n", new UTF8Encoding(false)); + } + + // The cell ran to its end: the variables it handed on replace the ones of the same names from before. Returns a note about any that + // couldn't be kept and that the cell might have expected to be, or an empty string. + private string KeepVariables(string varsOut) + { + var notes = new StringBuilder(); + try + { + lock (_stateLock) + { + foreach (var line in File.Exists(varsOut) ? File.ReadAllLines(varsOut) : []) + { + if (string.IsNullOrWhiteSpace(line)) continue; + using var entry = JsonDocument.Parse(line); + var root = entry.RootElement; + var name = root.GetProperty("name").GetString() ?? string.Empty; + if (root.TryGetProperty("skipped", out var skipped)) + { + var wasKept = _variables.Remove(name); + var why = skipped.GetString() ?? "it can't be saved"; + var quiet = root.TryGetProperty("quiet", out var q) && q.ValueKind == JsonValueKind.True; + var isNew = _unkept.TryAdd(name, why); + _unkept[name] = why; + if (!quiet && (wasKept || isNew)) notes.Append($"note: {name} isn't kept for later cells: {why}.\n"); + } + else + { + _variables[name] = new CppVariable(name, root.GetProperty("type").GetString() ?? "auto", root.GetProperty("json").GetRawText()); + _unkept.Remove(name); + } + } + } + } + catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException) + { + // A file that can't be read leaves the variables as they were before this cell. + } + + return notes.ToString(); + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + } + + private static KernelExecutionResult Failure(string message, string console, Stopwatch clock) => + new() { Success = false, ErrorMessage = message, ConsoleOutput = console, Elapsed = clock.Elapsed }; + + private static KernelExecutionResult Cancelled(Stopwatch clock) => new() + { + WasCancelled = true, + ErrorMessage = "Cell execution was cancelled.", + Elapsed = clock.Elapsed + }; + + public Task> GetVariablesAsync(CancellationToken ct) + { + var list = new List(); + lock (_stateLock) + { + foreach (var variable in _variables.Values) + { + list.Add(new NotebookVariableInfo + { + Name = variable.Name, + TypeName = variable.Type, + ValueDisplay = variable.Json.Length > 120 ? variable.Json[..120] + "…" : variable.Json, + Kind = "Variable", + Kernel = "C++" + }); + } + + foreach (var item in _items.Items.Where(i => i.Kind is "function" or "type" && i.Names.Count > 0)) + { + foreach (var name in item.Names) + { + list.Add(new NotebookVariableInfo + { + Name = name, + TypeName = item.Kind == "type" ? "type" : "function", + ValueDisplay = item.Text.Split('\n')[0].TrimEnd('{', ' '), + Kind = item.Kind == "type" ? "Type" : "Function", + Kernel = "C++" + }); + } + } + } + + return Task.FromResult>(list); + } + + public Task GetValueJsonAsync(string name, CancellationToken ct) + { + lock (_stateLock) + { + if (_variables.TryGetValue(name, out var variable)) return Task.FromResult(variable.Json); + if (_shared.TryGetValue(name, out var shared)) return Task.FromResult(shared); + } + + throw new KernelValueException($"C++ kernel has no variable named '{name}' that it can hand on. A variable of a type that can't be saved (a pointer, an array, a struct the notebook can't list the members of) can be shared as data with fry::share(\"{name}\", {name})."); + } + + public Task SetValueFromJsonAsync(string name, string json, CancellationToken ct) + { + if (!Regex.IsMatch(name, @"^[A-Za-z_]\w*$") || CppKeywords.Contains(name)) + { + throw new KernelValueException($"'{name}' can't be a C++ variable name."); + } + + try + { + using var _ = JsonDocument.Parse(json); + } + catch (JsonException) + { + throw new KernelValueException("The value isn't valid JSON."); + } + + lock (_stateLock) + { + _variables[name] = new CppVariable(name, CppValueTypes.Infer(json), json); + _unkept.Remove(name); + } + + return Task.CompletedTask; + } + + private static readonly HashSet CppKeywords = new(StringComparer.Ordinal) + { + "alignas", "alignof", "and", "asm", "auto", "bool", "break", "case", "catch", "char", "class", "const", "constexpr", "continue", "default", "delete", "do", + "double", "else", "enum", "explicit", "export", "extern", "false", "float", "for", "friend", "goto", "if", "inline", "int", "long", "mutable", "namespace", + "new", "noexcept", "not", "nullptr", "operator", "or", "private", "protected", "public", "register", "return", "short", "signed", "sizeof", "static", + "struct", "switch", "template", "this", "throw", "true", "try", "typedef", "typeid", "typename", "union", "unsigned", "using", "virtual", "void", "volatile", "while", "xor" + }; + + public void HardReset() + { + lock (_stateLock) + { + _items.Clear(); + _includes.Clear(); + _variables.Clear(); + _unkept.Clear(); + _shared.Clear(); + _executionCount = 0; + } + } + + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + HardReset(); + try + { + if (Directory.Exists(_moduleDirectory)) Directory.Delete(_moduleDirectory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Left behind for the next clean-up. + } + } +} diff --git a/Services/Languages/Cpp/CppPackageManager.cs b/FrySharp.Core/Services/Languages/Cpp/CppPackageManager.cs similarity index 100% rename from Services/Languages/Cpp/CppPackageManager.cs rename to FrySharp.Core/Services/Languages/Cpp/CppPackageManager.cs diff --git a/Services/Languages/Cpp/CppPackageMap.cs b/FrySharp.Core/Services/Languages/Cpp/CppPackageMap.cs similarity index 100% rename from Services/Languages/Cpp/CppPackageMap.cs rename to FrySharp.Core/Services/Languages/Cpp/CppPackageMap.cs diff --git a/Services/Languages/Cpp/CppToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/Cpp/CppToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/Cpp/CppToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/Cpp/CppToolchainProvider.Discovery.cs diff --git a/Services/Languages/Cpp/CppToolchainProvider.cs b/FrySharp.Core/Services/Languages/Cpp/CppToolchainProvider.cs similarity index 100% rename from Services/Languages/Cpp/CppToolchainProvider.cs rename to FrySharp.Core/Services/Languages/Cpp/CppToolchainProvider.cs diff --git a/FrySharp.Core/Services/Languages/Cpp/CppValueTypes.cs b/FrySharp.Core/Services/Languages/Cpp/CppValueTypes.cs new file mode 100644 index 00000000..ef8ea9fc --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/CppValueTypes.cs @@ -0,0 +1,43 @@ +using System.Text.Json; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; + +/// The C++ type a value from another language gets (#!share --from csharp rows), from its JSON: what C++ would call it, and no more. +internal static class CppValueTypes +{ + public static string Infer(string json) + { + try + { + using var document = JsonDocument.Parse(json); + return TypeOf(document.RootElement); + } + catch (JsonException) + { + return "fry::Json"; + } + } + + private static string TypeOf(JsonElement value) => value.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => "bool", + JsonValueKind.String => "std::string", + JsonValueKind.Number => value.GetRawText().Any(c => c is '.' or 'e' or 'E') || !value.TryGetInt64(out _) ? "double" : "long long", + JsonValueKind.Array => ArrayType(value), + _ => "fry::Json" // an object, or null: C++ has no type for "anything", so the value stays JSON the cell can read (j["name"].as_string()) + }; + + // A list of one kind of thing is a vector of it (numbers: whole, or double when any isn't); a mixed one stays JSON. + private static string ArrayType(JsonElement array) + { + var types = array.EnumerateArray().Select(TypeOf).Distinct().ToList(); + return types.Count switch + { + 0 => "std::vector", + 1 when types[0] == "fry::Json" => "fry::Json", + 1 => "std::vector<" + types[0] + ">", + 2 when types.Contains("long long") && types.Contains("double") => "std::vector", + _ => "fry::Json" + }; + } +} diff --git a/Services/Languages/Cpp/CppWindowsSdkResolver.cs b/FrySharp.Core/Services/Languages/Cpp/CppWindowsSdkResolver.cs similarity index 100% rename from Services/Languages/Cpp/CppWindowsSdkResolver.cs rename to FrySharp.Core/Services/Languages/Cpp/CppWindowsSdkResolver.cs diff --git a/Services/Languages/Cpp/Runtime/display.hpp b/FrySharp.Core/Services/Languages/Cpp/Runtime/display.hpp similarity index 99% rename from Services/Languages/Cpp/Runtime/display.hpp rename to FrySharp.Core/Services/Languages/Cpp/Runtime/display.hpp index 84fa57b8..c988e76c 100644 --- a/Services/Languages/Cpp/Runtime/display.hpp +++ b/FrySharp.Core/Services/Languages/Cpp/Runtime/display.hpp @@ -1719,7 +1719,7 @@ class TreeVisualizer { class GraphVisualizer { std::string title_; - bool directed_{true}; + [[maybe_unused]] bool directed_{true}; std::map> adj_; public: diff --git a/FrySharp.Core/Services/Languages/Cpp/Runtime/values.hpp b/FrySharp.Core/Services/Languages/Cpp/Runtime/values.hpp new file mode 100644 index 00000000..681f154d --- /dev/null +++ b/FrySharp.Core/Services/Languages/Cpp/Runtime/values.hpp @@ -0,0 +1,845 @@ +#pragma once +// What a notebook cell does with a value: show it (text, a table, the Object Explorer), keep it for the next cell, share it with another +// language, ask the notebook for a line of input. Staged beside display.hpp as . +#ifndef FRY_VALUES_HPP_INCLUDED +#define FRY_VALUES_HPP_INCLUDED + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fry { + +// A struct the cell defines (or a type that wants to be shown with its members) names them by specialising this: +// template <> struct fry::fields { static constexpr const char* names[] = {"name", "age"}; +// template static void each(Person& p, F&& f) { f(p.name); f(p.age); } +// template static void each(const Person& p, F&& f) { f(p.name); f(p.age); } }; +// The notebook writes this for the structs a cell defines, from their public data members. +template +struct fields; + +namespace values { + +template +concept registered = requires { fields>::names; }; + +// ── The name of a type, as C++ writes it ────────────────────────────────────────────────────────────────────────── + +template +constexpr const char* pretty_function() { +#if defined(_MSC_VER) + return __FUNCSIG__; +#else + return __PRETTY_FUNCTION__; +#endif +} + +inline void replace_all(std::string& text, const std::string& from, const std::string& to) { + for (std::size_t at = text.find(from); at != std::string::npos; at = text.find(from, at + to.size())) text.replace(at, from.size(), to); +} + +template +inline std::string type_name() { + std::string p = pretty_function(); + std::string name; +#if defined(_MSC_VER) + auto a = p.find("pretty_function<"); + auto b = p.rfind(">(void)"); + if (a != std::string::npos && b != std::string::npos) name = p.substr(a + 16, b - a - 16); + for (const char* keyword : {"class ", "struct ", "enum ", "union "}) replace_all(name, keyword, ""); +#else + auto a = p.find("T = "); + auto b = p.rfind(']'); + if (a != std::string::npos && b != std::string::npos && b > a) name = p.substr(a + 4, b - a - 4); +#endif + if (name.empty()) name = typeid(T).name(); + replace_all(name, "std::__1::", "std::"); + replace_all(name, "std::__cxx11::", "std::"); + replace_all(name, "std::basic_string, std::allocator >", "std::string"); + replace_all(name, "std::basic_string", "std::string"); + replace_all(name, "std::basic_string,std::allocator >", "std::string"); + return name; +} + +// ── JSON: a value as a tree, written out and read back ──────────────────────────────────────────────────────────── + +struct Json { + enum Kind { Null, Bool, Int, Float, String, Array, Object } kind = Null; + bool b = false; + long long i = 0; + double d = 0; + std::string s; + std::vector items; // an array's elements, an object's values + std::vector keys; // an object's keys, alongside its values + std::string type; // an object that is a struct, pair or tuple: its type + bool is_map = false; // an object that is a map + std::size_t total = 0; // an array cut short for showing: how many it had + bool cut = false; + + static Json make(Kind k) { Json j; j.kind = k; return j; } + static Json text(std::string v) { Json j = make(String); j.s = std::move(v); return j; } + static Json integer(long long v) { Json j = make(Int); j.i = v; return j; } + static Json real(double v) { Json j = make(Float); j.d = v; return j; } + static Json boolean(bool v) { Json j = make(Bool); j.b = v; return j; } + void add(std::string key, Json value) { keys.push_back(std::move(key)); items.push_back(std::move(value)); } + const Json* find(const std::string& key) const { + for (std::size_t n = 0; n < keys.size(); ++n) if (keys[n] == key) return &items[n]; + return nullptr; + } + bool scalar() const { return kind != Array && kind != Object; } + + // For a cell that was handed a value from another language (an object, a mixed list): read it by key or position. + const Json& operator[](const std::string& key) const { static const Json none; const Json* found = find(key); return found ? *found : none; } + const Json& operator[](const char* key) const { return (*this)[std::string(key)]; } + const Json& operator[](std::size_t index) const { static const Json none; return index < items.size() ? items[index] : none; } + std::size_t size() const { return items.size(); } + bool is_null() const { return kind == Null; } + std::string as_string() const { return kind == String ? s : kind == Bool ? (b ? "true" : "false") : kind == Int ? std::to_string(i) : kind == Float ? number_text_of(d) : kind == Null ? std::string() : std::string(""); } + long long as_int() const { return kind == Int ? i : kind == Float ? static_cast(d) : kind == Bool ? (b ? 1 : 0) : 0; } + double as_double() const { return kind == Float ? d : kind == Int ? static_cast(i) : 0.0; } + bool as_bool() const { return kind == Bool ? b : kind == Int ? i != 0 : false; } + +private: + static std::string number_text_of(double v) { + char buffer[40]; + std::snprintf(buffer, sizeof buffer, "%.15g", v); + return buffer; + } +}; + +} // namespace values + +// A value handed over from another language that has no C++ type of its own (an object, a mixed list). +using Json = values::Json; + +namespace values { + +inline std::string number_text(double v) { + char buffer[40]; + std::snprintf(buffer, sizeof buffer, "%.15g", v); + std::string out = buffer; + if (out.find_first_of(".eEn") == std::string::npos) out += ".0"; + return out; +} + +inline void write_json(const Json& j, std::string& out) { + switch (j.kind) { + case Json::Null: out += "null"; break; + case Json::Bool: out += j.b ? "true" : "false"; break; + case Json::Int: out += std::to_string(j.i); break; + case Json::Float: out += number_text(j.d); break; + case Json::String: out += detail::escape_json(j.s); break; + case Json::Array: + out += '['; + for (std::size_t n = 0; n < j.items.size(); ++n) { + if (n > 0) out += ','; + write_json(j.items[n], out); + } + out += ']'; + break; + case Json::Object: + out += '{'; + for (std::size_t n = 0; n < j.items.size(); ++n) { + if (n > 0) out += ','; + out += detail::escape_json(j.keys[n]); + out += ':'; + write_json(j.items[n], out); + } + out += '}'; + break; + } +} + +inline std::string dump_json(const Json& j) { + std::string out; + write_json(j, out); + return out; +} + +class JsonParser { +public: + explicit JsonParser(std::string_view text) : text_(text) {} + + bool parse(Json& out) { + skip(); + if (!value(out, 0)) return false; + skip(); + return pos_ == text_.size(); + } + +private: + std::string_view text_; + std::size_t pos_ = 0; + + void skip() { while (pos_ < text_.size() && (text_[pos_] == ' ' || text_[pos_] == '\t' || text_[pos_] == '\n' || text_[pos_] == '\r')) ++pos_; } + bool literal(const char* word) { + std::size_t n = std::char_traits::length(word); + if (text_.compare(pos_, n, word) != 0) return false; + pos_ += n; + return true; + } + + bool value(Json& out, int depth) { + if (depth > 200 || pos_ >= text_.size()) return false; + char c = text_[pos_]; + if (c == '{') return object(out, depth); + if (c == '[') return array(out, depth); + if (c == '"') { out = Json::make(Json::String); return string(out.s); } + if (c == 't') { out = Json::boolean(true); return literal("true"); } + if (c == 'f') { out = Json::boolean(false); return literal("false"); } + if (c == 'n') { out = Json::make(Json::Null); return literal("null"); } + return number(out); + } + + bool number(Json& out) { + std::size_t start = pos_; + bool real = false; + if (pos_ < text_.size() && (text_[pos_] == '-' || text_[pos_] == '+')) ++pos_; + while (pos_ < text_.size() && ((text_[pos_] >= '0' && text_[pos_] <= '9') || text_[pos_] == '.' || text_[pos_] == 'e' || text_[pos_] == 'E' || text_[pos_] == '-' || text_[pos_] == '+')) { + if (text_[pos_] == '.' || text_[pos_] == 'e' || text_[pos_] == 'E') real = true; + ++pos_; + } + if (pos_ == start) return false; + std::string token(text_.substr(start, pos_ - start)); + char* end = nullptr; + if (!real) { + errno = 0; + long long v = std::strtoll(token.c_str(), &end, 10); + if (errno == 0 && end && *end == '\0') { out = Json::integer(v); return true; } + } + double d = std::strtod(token.c_str(), &end); + if (!end || *end != '\0') return false; + out = Json::real(d); + return true; + } + + static void utf8(std::string& out, unsigned code) { + if (code < 0x80) out += static_cast(code); + else if (code < 0x800) { out += static_cast(0xC0 | (code >> 6)); out += static_cast(0x80 | (code & 0x3F)); } + else if (code < 0x10000) { out += static_cast(0xE0 | (code >> 12)); out += static_cast(0x80 | ((code >> 6) & 0x3F)); out += static_cast(0x80 | (code & 0x3F)); } + else { out += static_cast(0xF0 | (code >> 18)); out += static_cast(0x80 | ((code >> 12) & 0x3F)); out += static_cast(0x80 | ((code >> 6) & 0x3F)); out += static_cast(0x80 | (code & 0x3F)); } + } + + bool hex4(unsigned& code) { + if (pos_ + 4 > text_.size()) return false; + code = 0; + for (int n = 0; n < 4; ++n) { + char h = text_[pos_++]; + code <<= 4; + if (h >= '0' && h <= '9') code |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') code |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') code |= static_cast(h - 'A' + 10); + else return false; + } + return true; + } + + bool string(std::string& out) { + ++pos_; // the opening quote + while (pos_ < text_.size()) { + char c = text_[pos_++]; + if (c == '"') return true; + if (c != '\\') { out += c; continue; } + if (pos_ >= text_.size()) return false; + char e = text_[pos_++]; + switch (e) { + case 'n': out += '\n'; break; + case 't': out += '\t'; break; + case 'r': out += '\r'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'u': { + unsigned code = 0; + if (!hex4(code)) return false; + if (code >= 0xD800 && code < 0xDC00 && pos_ + 1 < text_.size() && text_[pos_] == '\\' && text_[pos_ + 1] == 'u') { + pos_ += 2; + unsigned low = 0; + if (!hex4(low)) return false; + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); + } + utf8(out, code); + break; + } + default: out += e; break; + } + } + return false; + } + + bool array(Json& out, int depth) { + out = Json::make(Json::Array); + ++pos_; + skip(); + if (pos_ < text_.size() && text_[pos_] == ']') { ++pos_; return true; } + while (true) { + skip(); + Json item; + if (!value(item, depth + 1)) return false; + out.items.push_back(std::move(item)); + skip(); + if (pos_ >= text_.size()) return false; + if (text_[pos_] == ',') { ++pos_; continue; } + if (text_[pos_] == ']') { ++pos_; return true; } + return false; + } + } + + bool object(Json& out, int depth) { + out = Json::make(Json::Object); + ++pos_; + skip(); + if (pos_ < text_.size() && text_[pos_] == '}') { ++pos_; return true; } + while (true) { + skip(); + if (pos_ >= text_.size() || text_[pos_] != '"') return false; + std::string key; + if (!string(key)) return false; + skip(); + if (pos_ >= text_.size() || text_[pos_] != ':') return false; + ++pos_; + skip(); + Json item; + if (!value(item, depth + 1)) return false; + out.add(std::move(key), std::move(item)); + skip(); + if (pos_ >= text_.size()) return false; + if (text_[pos_] == ',') { ++pos_; continue; } + if (text_[pos_] == '}') { ++pos_; return true; } + return false; + } + } +}; + +inline bool parse_json(std::string_view text, Json& out) { return JsonParser(text).parse(out); } + +// ── Kinds of type ──────────────────────────────────────────────────────────────────────────────────────────────────── + +template struct is_optional : std::false_type {}; +template struct is_optional> : std::true_type {}; +template struct is_pair : std::false_type {}; +template struct is_pair> : std::true_type {}; +template struct is_tuple : std::false_type {}; +template struct is_tuple> : std::true_type {}; +template struct is_smart_pointer : std::false_type {}; +template struct is_smart_pointer> : std::true_type {}; +template struct is_smart_pointer> : std::true_type {}; +template struct is_std_array : std::false_type {}; +template struct is_std_array> : std::true_type {}; + +template +concept string_like = std::is_convertible_v && !std::is_pointer_v && !std::is_array_v; + +template +concept c_string = std::is_same_v, const char*> || std::is_same_v, char*>; + +template +concept map_like = requires { typename T::key_type; typename T::mapped_type; } && requires(const T& t) { std::begin(t); std::end(t); }; + +template +concept sequence_like = requires(const T& t) { std::begin(t); std::end(t); } && !string_like && !map_like; + +// What a variable can be read back into: a container that clears and takes elements (not a C array). +template +concept container_like = sequence_like && !std::is_array_v && requires { typename T::value_type; } && requires(T& t) { t.clear(); }; + +template +concept appendable = requires(T& t, typename T::value_type v) { t.push_back(v); }; + +template +concept insertable = requires(T& t, typename T::value_type v) { t.insert(v); }; + +// A value that can be shown as a tree (as much as it can: what has no way to be shown becomes text) and a value that can also be +// read back from one (what a variable needs to be kept for the next cell). +template +constexpr bool persistable() { + using U = std::remove_cv_t>; + if constexpr (std::is_same_v || std::is_same_v || std::is_arithmetic_v || std::is_enum_v) return true; + else if constexpr (std::is_same_v) return true; + else if constexpr (is_optional::value) return persistable() && std::is_default_constructible_v; + else if constexpr (is_pair::value) return persistable() && persistable() && std::is_default_constructible_v; + else if constexpr (is_tuple::value) return std::is_default_constructible_v; // refined where it is used + else if constexpr (is_std_array::value) return persistable(); + else if constexpr (map_like) { + using K = typename U::key_type; + return (std::is_same_v || std::is_arithmetic_v) && persistable() && std::is_default_constructible_v; + } + else if constexpr (container_like) return (appendable || insertable) && persistable() && std::is_default_constructible_v; + else if constexpr (registered) return std::is_default_constructible_v; + else return false; +} + +// ── A value as a tree ──────────────────────────────────────────────────────────────────────────────────────────────────── + +template +inline Json to_tree(const T& value, std::size_t limit, int depth = 0); + +inline std::size_t strnlen_portable(const char* text, std::size_t most) { + std::size_t n = 0; + while (n < most && text[n] != '\0') ++n; + return n; +} + +template +inline std::string key_text(const T& key) { + if constexpr (std::is_arithmetic_v && !std::is_same_v) { + if constexpr (std::is_floating_point_v) return number_text(static_cast(key)); + else return std::to_string(key); + } else if constexpr (std::is_same_v) return key ? "true" : "false"; + else if constexpr (string_like) return std::string(std::string_view(key)); + else return "?"; +} + +template +inline Json to_tree(const T& value, std::size_t limit, int depth) { + using U = std::remove_cv_t; + if (depth > 8) return Json::text("…"); + if constexpr (std::is_same_v) return value; + else if constexpr (std::is_same_v) return Json::boolean(value); + else if constexpr (std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v) { + if constexpr (std::is_same_v) return Json::text(std::string(1, value)); + else return Json::integer(static_cast(value)); + } + else if constexpr (std::is_floating_point_v) { + double d = static_cast(value); + return std::isfinite(d) ? Json::real(d) : Json::make(Json::Null); + } + else if constexpr (std::is_integral_v) return Json::integer(static_cast(value)); + else if constexpr (std::is_enum_v) return Json::integer(static_cast(value)); + else if constexpr (std::is_null_pointer_v) return Json::make(Json::Null); + else if constexpr (c_string) return value == nullptr ? Json::make(Json::Null) : Json::text(std::string(value)); + else if constexpr (std::is_array_v && std::is_same_v>, char>) return Json::text(std::string(value, strnlen_portable(value, std::extent_v))); + else if constexpr (string_like) return Json::text(std::string(std::string_view(value))); + else if constexpr (is_optional::value) return value.has_value() ? to_tree(*value, limit, depth + 1) : Json::make(Json::Null); + else if constexpr (is_smart_pointer::value) return value ? to_tree(*value, limit, depth + 1) : Json::make(Json::Null); + else if constexpr (std::is_pointer_v) { + if (value == nullptr) return Json::make(Json::Null); + if constexpr (std::is_void_v> || std::is_function_v>) return Json::text(""); + else return to_tree(*value, limit, depth + 1); + } + else if constexpr (is_pair::value) { + Json o = Json::make(Json::Object); + o.type = type_name(); + o.add("first", to_tree(value.first, limit, depth + 1)); + o.add("second", to_tree(value.second, limit, depth + 1)); + return o; + } + else if constexpr (is_tuple::value) { + Json o = Json::make(Json::Object); + o.type = type_name(); + std::size_t n = 0; + std::apply([&](const auto&... element) { ((o.add(std::to_string(n++), to_tree(element, limit, depth + 1))), ...); }, value); + return o; + } + else if constexpr (registered) { + Json o = Json::make(Json::Object); + o.type = type_name(); + std::size_t n = 0; + fields::each(value, [&](const auto& member) { o.add(fields::names[n++], to_tree(member, limit, depth + 1)); }); + return o; + } + else if constexpr (map_like) { + Json o = Json::make(Json::Object); + o.is_map = true; + std::size_t n = 0; + for (const auto& entry : value) { + if (n++ >= limit) { o.cut = true; break; } + o.add(key_text(entry.first), to_tree(entry.second, limit, depth + 1)); + } + o.total = std::size(value); + return o; + } + else if constexpr (sequence_like) { + Json a = Json::make(Json::Array); + std::size_t n = 0; + for (const auto& item : value) { + if (n++ >= limit) { a.cut = true; break; } + a.items.push_back(to_tree(item, limit, depth + 1)); + } + a.total = static_cast(std::distance(std::begin(value), std::end(value))); + return a; + } + else if constexpr (requires(std::ostream& os, const U& v) { os << v; }) { + std::ostringstream text; + text << value; + return Json::text(text.str()); + } + else return Json::text("<" + type_name() + ">"); +} + +// ── A value read back from a tree ──────────────────────────────────────────────────────────────────────────────────────── + +template +inline bool from_tree(T& out, const Json& j); + +template +inline bool key_from_text(T& key, const std::string& text) { + if constexpr (std::is_same_v) { key = text; return true; } + else if constexpr (std::is_same_v) { key = text == "true"; return text == "true" || text == "false"; } + else if constexpr (std::is_floating_point_v) { char* end = nullptr; key = static_cast(std::strtod(text.c_str(), &end)); return end && *end == '\0'; } + else if constexpr (std::is_arithmetic_v) { char* end = nullptr; key = static_cast(std::strtoll(text.c_str(), &end, 10)); return end && *end == '\0'; } + else return false; +} + +template +inline bool from_tree(T& out, const Json& j) { + using U = std::remove_cv_t; + if constexpr (std::is_same_v) { out = j; return true; } + else if constexpr (std::is_same_v) { if (j.kind != Json::Bool) return false; out = j.b; return true; } + else if constexpr (std::is_same_v) { if (j.kind != Json::String || j.s.size() != 1) return false; out = j.s[0]; return true; } + else if constexpr (std::is_floating_point_v) { + if (j.kind == Json::Null) { out = std::numeric_limits::quiet_NaN(); return true; } + if (j.kind == Json::Float) { out = static_cast(j.d); return true; } + if (j.kind == Json::Int) { out = static_cast(j.i); return true; } + return false; + } + else if constexpr (std::is_integral_v) { + if (j.kind == Json::Int) { out = static_cast(j.i); return true; } + if (j.kind == Json::Float) { out = static_cast(j.d); return true; } + return false; + } + else if constexpr (std::is_enum_v) { if (j.kind != Json::Int) return false; out = static_cast(j.i); return true; } + else if constexpr (std::is_same_v) { if (j.kind != Json::String) return false; out = j.s; return true; } + else if constexpr (is_optional::value) { + if (j.kind == Json::Null) { out.reset(); return true; } + typename U::value_type v{}; + if (!from_tree(v, j)) return false; + out = std::move(v); + return true; + } + else if constexpr (is_pair::value) { + if (j.kind != Json::Object) return false; + auto* first = j.find("first"); + auto* second = j.find("second"); + return first && second && from_tree(out.first, *first) && from_tree(out.second, *second); + } + else if constexpr (is_tuple::value) { + if (j.kind != Json::Object) return false; + std::size_t n = 0; + bool ok = true; + std::apply([&](auto&... element) { ((ok = ok && [&] { auto* item = j.find(std::to_string(n++)); return item && from_tree(element, *item); }()), ...); }, out); + return ok; + } + else if constexpr (is_std_array::value) { + if (j.kind != Json::Array || j.items.size() != out.size()) return false; + for (std::size_t n = 0; n < out.size(); ++n) if (!from_tree(out[n], j.items[n])) return false; + return true; + } + else if constexpr (registered) { + if (j.kind != Json::Object) return false; + std::size_t n = 0; + bool ok = true; + fields::each(out, [&](auto& member) { auto* item = j.find(fields::names[n++]); ok = ok && item && from_tree(member, *item); }); + return ok; + } + else if constexpr (map_like) { + if (j.kind != Json::Object) return false; + out.clear(); + for (std::size_t n = 0; n < j.items.size(); ++n) { + typename U::key_type key{}; + typename U::mapped_type mapped{}; + if (!key_from_text(key, j.keys[n]) || !from_tree(mapped, j.items[n])) return false; + out.emplace(std::move(key), std::move(mapped)); + } + return true; + } + else if constexpr (container_like) { + if (j.kind != Json::Array) return false; + out.clear(); + for (const auto& item : j.items) { + typename U::value_type element{}; + if (!from_tree(element, item)) return false; + if constexpr (appendable) out.push_back(std::move(element)); + else out.insert(std::move(element)); + } + return true; + } + else return false; +} + +// ── Showing a value ────────────────────────────────────────────────────────────────────────────────────────────────────── + +constexpr std::size_t max_inline = 50; +constexpr std::size_t max_table_rows = 1000; +constexpr std::size_t max_inspect_items = 100; +constexpr int max_inspect_depth = 3; + +inline std::string scalar_text(const Json& j, bool quote) { + switch (j.kind) { + case Json::Null: return "null"; + case Json::Bool: return j.b ? "true" : "false"; + case Json::Int: return std::to_string(j.i); + case Json::Float: { char buffer[40]; std::snprintf(buffer, sizeof buffer, "%.15g", j.d); return buffer; } + case Json::String: return quote ? detail::escape_json(j.s) : j.s; + default: return dump_json(j); + } +} + +inline void emit_mime(const std::string& data, const std::string& plain_fallback = "") { + std::string line = "__FRY_DISPLAY__ {\"type\":\"display\",\"data\":{" + data; + if (!plain_fallback.empty()) line += ",\"text/plain\":" + detail::escape_json(plain_fallback); + line += "},\"metadata\":{}}"; + detail::emit_protocol(line); +} + +inline void emit_table(const std::string& title, const std::vector& columns, const std::vector>& rows, std::size_t total) { + std::string cols = "["; + std::string numeric = "["; + for (std::size_t c = 0; c < columns.size(); ++c) { + if (c > 0) { cols += ','; numeric += ','; } + cols += detail::escape_json(columns[c]); + bool seen = false, all = true; + for (const auto& row : rows) { + const Json& cell = row[c]; + if (cell.kind == Json::Int || cell.kind == Json::Float) seen = true; + else if (cell.kind != Json::Null) all = false; + } + numeric += (seen && all) ? "true" : "false"; + } + cols += ']'; + numeric += ']'; + std::string body = "["; + for (std::size_t r = 0; r < rows.size(); ++r) { + if (r > 0) body += ','; + body += '['; + for (std::size_t c = 0; c < rows[r].size(); ++c) { + if (c > 0) body += ','; + const Json& cell = rows[r][c]; + // a cell is one value: a number, a bool or text; anything bigger is its text + if (cell.scalar()) write_json(cell, body); + else body += detail::escape_json(dump_json(cell)); + } + body += ']'; + } + body += ']'; + emit_mime("\"" + TABLE_MIME + "\":{\"title\":" + detail::escape_json(title) + ",\"columns\":" + cols + ",\"numeric\":" + numeric + ",\"rows\":" + body + + ",\"totalRows\":" + std::to_string(total) + ",\"totalColumns\":" + std::to_string(columns.size()) + "}"); +} + +inline std::string inspector_json(const Json& j, int depth, const std::string& title); + +inline std::string inspector_property(const std::string& name, const Json& value, int depth) { + std::string out = "{\"name\":" + detail::escape_json(name); + if (value.scalar()) { + out += ",\"value\":" + detail::escape_json(scalar_text(value, false)) + ",\"isNull\":" + (value.kind == Json::Null ? "true" : "false") + + ",\"isString\":" + (value.kind == Json::String ? "true" : "false"); + } else if (depth >= max_inspect_depth) { + out += ",\"isNull\":false,\"isString\":false,\"child\":{\"title\":" + detail::escape_json(value.kind == Json::Array ? "list" : value.type.empty() ? "map" : value.type) + ",\"properties\":[]}"; + } else { + out += ",\"isNull\":false,\"isString\":false,\"child\":" + inspector_json(value, depth + 1, ""); + } + return out + "}"; +} + +inline std::string inspector_json(const Json& j, int depth, const std::string& title) { + std::string name = !title.empty() ? title : !j.type.empty() ? j.type : j.kind == Json::Array ? "list" : "map"; + std::string out = "{\"title\":" + detail::escape_json(name) + ",\"properties\":["; + std::size_t shown = 0; + for (std::size_t n = 0; n < j.items.size() && shown < max_inspect_items; ++n, ++shown) { + if (shown > 0) out += ','; + out += inspector_property(j.kind == Json::Array ? "[" + std::to_string(n) + "]" : j.keys[n], j.items[n], depth); + } + if (j.items.size() > max_inspect_items || j.cut) { + if (shown > 0) out += ','; + out += "{\"name\":\"…\",\"value\":" + detail::escape_json("(more than " + std::to_string(max_inspect_items) + " shown)") + ",\"isNull\":false,\"isString\":false}"; + } + return out + "]}"; +} + +inline void show_tree(const Json& j, const std::string& title) { + if (j.scalar()) { + std::cout << scalar_text(j, true) << std::endl; + return; + } + if (j.kind == Json::Array) { + if (j.items.empty() && !j.cut) { std::cout << "[ ]" << std::endl; return; } + bool all_scalar = true, all_records = !j.items.empty(); + for (const auto& item : j.items) { + if (!item.scalar()) all_scalar = false; + if (item.kind != Json::Object) all_records = false; + } + if (all_scalar && !j.cut && j.items.size() <= max_inline) { + std::string line = "[ "; + for (std::size_t n = 0; n < j.items.size(); ++n) { + if (n > 0) line += ", "; + line += scalar_text(j.items[n], false); + } + std::cout << line << " ]" << std::endl; + return; + } + std::size_t total = j.total ? j.total : j.items.size(); + std::vector> rows; + if (all_records) { + std::vector columns; + for (const auto& record : j.items) for (const auto& key : record.keys) if (std::find(columns.begin(), columns.end(), key) == columns.end()) columns.push_back(key); + for (const auto& record : j.items) { + std::vector row; + for (const auto& column : columns) { const Json* cell = record.find(column); row.push_back(cell ? *cell : Json()); } + rows.push_back(std::move(row)); + } + emit_table(title, columns, rows, total); + } else { + for (std::size_t n = 0; n < j.items.size(); ++n) rows.push_back({Json::integer(static_cast(n)), j.items[n]}); + emit_table(title, {"index", "value"}, rows, total); + } + return; + } + // an object + if (j.is_map) { + if (j.items.empty()) { std::cout << "{ }" << std::endl; return; } + std::vector> rows; + for (std::size_t n = 0; n < j.items.size(); ++n) rows.push_back({Json::text(j.keys[n]), j.items[n]}); + emit_table(title, {"key", "value"}, rows, j.total ? j.total : j.items.size()); + return; + } + emit_mime("\"application/vnd.fry.inspector+json\":" + inspector_json(j, 0, ""), dump_json(j)); +} + +} // namespace values + +// Shows a value as a notebook cell's last expression is shown: a number or text as text, a short list on one line, a list of +// structs or maps as a table, a struct as the Object Explorer. +template +inline void show(const T& value) { + values::show_tree(values::to_tree(value, values::max_table_rows), ""); +} + +inline void markdown(std::string_view text) { + values::emit_mime("\"text/markdown\":" + detail::escape_json(text), std::string(text)); +} + +// ── Keeping variables for the next cell ────────────────────────────────────────────────────────────────────────────────── + +namespace values { + +inline std::mutex& persist_mutex() { static std::mutex m; return m; } + +inline const std::unordered_map& restored() { + static const std::unordered_map all = [] { + std::unordered_map map; + const char* path = std::getenv("FRY_VARS_IN"); + if (!path) return map; + std::ifstream file(path); + std::string line; + while (std::getline(file, line)) { + Json entry; + if (!parse_json(line, entry) || entry.kind != Json::Object) continue; + const Json* name = entry.find("name"); + const Json* value = entry.find("json"); + if (name && value && name->kind == Json::String) map.emplace(name->s, *value); + } + return map; + }(); + return all; +} + +} // namespace values + +// A variable the cells before made: its value (a cell's start gives these back; it isn't called by hand). +template +inline T restore(const char* name) { + T value{}; + auto& all = values::restored(); + auto found = all.find(name); + if (found != all.end()) values::from_tree(value, found->second); + return value; +} + +template +inline void restore_into(T& target, const char* name) { + auto& all = values::restored(); + auto found = all.find(name); + if (found != all.end()) values::from_tree(target, found->second); +} + +// Records a variable the cell made, for the next cell: its name, its type as C++ writes it, and its value as JSON. A value that +// wouldn't come back the same (a pointer, an array, a type the notebook doesn't know the members of) is recorded as skipped, with why. +template +inline void persist(const char* name, const T& value) { + const char* path = std::getenv("FRY_VARS_OUT"); + if (!path) return; + using U = std::remove_cv_t>; + std::string type = values::type_name(); + std::string line = "{\"name\":" + detail::escape_json(name) + ",\"type\":" + detail::escape_json(type); + std::string skipped; + bool quiet = false; + if constexpr (values::persistable()) { + values::Json tree = values::to_tree(value, static_cast(-1)); + std::string text = values::dump_json(tree); + values::Json parsed; + U back{}; + if (values::parse_json(text, parsed) && values::from_tree(back, parsed) && values::dump_json(values::to_tree(back, static_cast(-1))) == text) { + line += ",\"json\":" + text; + } else { + skipped = "it doesn't come back the same when it is saved"; + } + } else { + skipped = "its type (" + type + ") isn't one that can be saved: numbers, text, lists, maps, optionals and structs of those can"; + quiet = type.find("lambda") != std::string::npos || type.find("(unnamed") != std::string::npos || type.find("$_") != std::string::npos; + } + if (!skipped.empty()) line += ",\"skipped\":" + detail::escape_json(skipped) + (quiet ? ",\"quiet\":true" : ""); + line += "}\n"; + std::lock_guard lock(values::persist_mutex()); + std::ofstream file(path, std::ios::app | std::ios::binary); + file << line; +} + +// Offers a value to the cells of other languages (#!share --from cpp name): it goes as JSON. +template +inline void share(const char* name, const T& value) { + using U = std::remove_cv_t>; + if constexpr (values::persistable()) { + std::string json = values::dump_json(values::to_tree(value, static_cast(-1))); + detail::emit_protocol("__FRY_SHARE__ {\"name\":" + detail::escape_json(name) + ",\"json\":" + detail::escape_json(json) + "}"); + } else { + std::cout << "⚠️ " << name << " can't be shared: " << values::type_name() << " isn't a type that can be sent as JSON" << std::endl; + } +} + +// ── Input ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +namespace values { + +inline std::optional ask(std::string_view prompt, bool secret) { + static bool ended = false; + if (ended) return std::nullopt; + if (std::getenv("FRY_INPUT_REQUESTS")) { + detail::emit_protocol("__FRY_INPUT__ {\"prompt\":" + detail::escape_json(prompt) + ",\"password\":" + (secret ? "true" : "false") + "}"); + } else { + std::cout << prompt << std::flush; + } + std::string line; + if (!std::getline(std::cin, line)) { + ended = true; + return std::nullopt; + } + if (!line.empty() && line.back() == '\r') line.pop_back(); + return line; +} + +} // namespace values + +// Asks for a line of input and returns it, without its line break; nothing is the end of input. In a notebook cell the notebook shows +// the prompt and the user answers there (as for input() in Python); a plain std::cin read can't ask, and sees the end of input at +// once. In a program run on its own the prompt is printed and the line is read from the terminal. +inline std::optional input(std::string_view prompt = "") { return values::ask(prompt, false); } + +// input() for a secret: what is typed is not shown. +inline std::optional password(std::string_view prompt = "") { return values::ask(prompt, true); } + +} // namespace fry + +#endif // FRY_VALUES_HPP_INCLUDED diff --git a/Services/Languages/FSharp/FSharpCompilerDiagnosticParser.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpCompilerDiagnosticParser.cs similarity index 100% rename from Services/Languages/FSharp/FSharpCompilerDiagnosticParser.cs rename to FrySharp.Core/Services/Languages/FSharp/FSharpCompilerDiagnosticParser.cs diff --git a/FrySharp.Core/Services/Languages/FSharp/FSharpDisplayRuntime.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpDisplayRuntime.cs new file mode 100644 index 00000000..8aa72707 --- /dev/null +++ b/FrySharp.Core/Services/Languages/FSharp/FSharpDisplayRuntime.cs @@ -0,0 +1,58 @@ +using System.Text; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; + +/// +/// The display runtime of F# scripts: Display.fsx (Display.table, Display.lineChart, Display.markdown +/// ...) and the Values.fsx it loads, which says how a value is shown. A script run loads them (--load); the +/// notebook kernel loads them into its session. They are embedded in this assembly and written to the folder a run uses. +/// +public static class FSharpDisplayRuntime +{ + /// The embedded resources that make up the runtime ("FSharpRuntime.Display.fsx" ...). + public const string ResourcePrefix = "FSharpRuntime."; + + /// The files of the runtime: name and text. + public static IReadOnlyDictionary Files { get; } = ReadFiles(); + + /// The text of Display.fsx. + public static string DisplayFsxContent => Files.GetValueOrDefault("Display.fsx", string.Empty); + + private static Dictionary ReadFiles() + { + var assembly = typeof(FSharpDisplayRuntime).Assembly; + var files = new Dictionary(StringComparer.Ordinal); + foreach (var resource in assembly.GetManifestResourceNames().Where(n => n.StartsWith(ResourcePrefix, StringComparison.Ordinal))) + { + using var stream = assembly.GetManifestResourceStream(resource)!; + using var reader = new StreamReader(stream, Encoding.UTF8); + files[resource[ResourcePrefix.Length..]] = reader.ReadToEnd(); + } + + return files; + } + + /// + /// Writes the runtime into and buildDir/fry, where a script's --load and + /// --lib find it, and returns the second. + /// + public static async Task EnsureDisplayPackageAsync(string buildDir, CancellationToken ct = default) + { + var displayDir = Path.Combine(buildDir, "fry"); + try + { + Directory.CreateDirectory(displayDir); + foreach (var (name, content) in Files) + { + await File.WriteAllTextAsync(Path.Combine(displayDir, name), content, ct).ConfigureAwait(false); + await File.WriteAllTextAsync(Path.Combine(buildDir, name), content, ct).ConfigureAwait(false); + } + } + catch (IOException) + { + // A folder that can't be written fails the run with its own message. + } + + return displayDir; + } +} diff --git a/FrySharp.Core/Services/Languages/FSharp/FSharpEngine.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpEngine.cs new file mode 100644 index 00000000..51db1e29 --- /dev/null +++ b/FrySharp.Core/Services/Languages/FSharp/FSharpEngine.cs @@ -0,0 +1,72 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; + +/// +/// F#, run by the .NET SDK's F# Interactive (dotnet fsi): .fsx files run like dotnet fsi file.fsx; notebook +/// cells run in a kernel process whose evaluation session keeps what they define, shows the value of a cell's last +/// expression, and shares values with the other languages' kernels. +/// +public sealed class FSharpEngine : LanguageEngine +{ + private static readonly string[] FSharpAliases = ["fsharp", "fs", "fsx"]; + private static readonly string[] FSharpExtensions = [".fsx", ".fs"]; + + public FSharpEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + var toolchain = new FSharpToolchainProvider(services.Host, services.Processes, services.ToolchainSettings); + FSharpToolchain = toolchain; + ScriptRunner = new FSharpScriptRunner(); + Packages = new FSharpPackageManager(); + + // Each notebook gets its own kernel program, started with its first F# cell. + var launcher = new FSharpKernelLauncher(toolchain, services.Host, Path.Combine(services.BaseDirectory, "fsharp", "kernel")); + NotebookKernels = new DelegateKernelFactory(context => + new ProtocolKernel(LanguageIds.FSharp, "F#", launcher, services.Processes, context)); + } + + public FSharpToolchainProvider FSharpToolchain { get; } + + public override string Id => LanguageIds.FSharp; + public override string DisplayName => "F#"; + public override JupyterLanguageInfo Jupyter { get; } = new("fsharp", "F#", "fsharp", "fsharp", ".fsx", "text/x-fsharp"); + public override string ShortName => "FS"; + public override IReadOnlyList Aliases => FSharpAliases; + public override IReadOnlyList FileExtensions => FSharpExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | + LanguageCapabilities.QuickInfo | + LanguageCapabilities.Completion | + LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | + LanguageCapabilities.Packages | + LanguageCapabilities.Formatting; + + public override string IconKind => "FunctionVariant"; + public override string AccentHex => "#30B9DB"; + public override string LineCommentPrefix => "//"; + public override string RuntimeDescription => "F# (.NET 10 fsi)"; + + public override string NewFileTemplate => + "// Runs with F# Interactive (.NET SDK dotnet fsi) on this computer (F5).\n" + + "open System\n\n" + + "printfn \"🚀 Hello from F# in FrySharp!\"\n\n" + + "let numbers = [ 1 .. 10 ]\n" + + "let sumOfSquares =\n" + + " numbers\n" + + " |> List.map (fun x -> x * x)\n" + + " |> List.sum\n\n" + + "printfn \"Sum of squares: %d\" sumOfSquares\n"; + + public override IToolchainProvider Toolchain => FSharpToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new FSharpCompilerDiagnosticParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/FSharp/FSharpGuidance.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpGuidance.cs similarity index 100% rename from Services/Languages/FSharp/FSharpGuidance.cs rename to FrySharp.Core/Services/Languages/FSharp/FSharpGuidance.cs diff --git a/FrySharp.Core/Services/Languages/FSharp/FSharpKernelLauncher.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpKernelLauncher.cs new file mode 100644 index 00000000..20831f78 --- /dev/null +++ b/FrySharp.Core/Services/Languages/FSharp/FSharpKernelLauncher.cs @@ -0,0 +1,45 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; + +/// +/// Starts the notebook's F# kernel: fry_kernel.fsx, run by the F# Interactive of the .NET SDK a notebook would use. +/// The script hosts the F# compiler's own evaluation session, so what one cell defines the next one sees. +/// +public sealed class FSharpKernelLauncher(FSharpToolchainProvider toolchains, IHostEnvironment host, string kernelFilesRoot) : IKernelLauncher +{ + /// The embedded resources that make up the kernel ("FSharpKernel.fry_kernel.fsx", "FSharpKernel.Display.fsx" ...). + public const string ResourcePrefix = "FSharpKernel."; + + public async Task PrepareAsync(KernelCreationContext context, CancellationToken ct) + { + var folder = context.WorkingDirectory(); + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) folder = host.HomeDirectory; + + var resolution = await toolchains.ResolveAsync(new ToolchainQuery(folder, context.WorkspaceRoot?.Invoke()), ct).ConfigureAwait(false); + if (resolution.Toolchain is not { } fsharp) + { + throw new KernelUnavailableException((resolution.Missing ?? FSharpGuidance.NotInstalled(host)).ToText()); + } + + var kernelFolder = EmbeddedKernelFiles.Extract(typeof(FSharpKernelLauncher).Assembly, ResourcePrefix, kernelFilesRoot); + var isDirectFsi = Path.GetFileNameWithoutExtension(fsharp.ExecutablePath).Equals("fsi", StringComparison.OrdinalIgnoreCase); + + // fsi finds FSharp.Compiler.Service.dll for the script's #r in its own folder, so the session is always the compiler of + // the fsi that runs it, whichever SDK that is. + var arguments = new List(); + if (!isDirectFsi) arguments.Add("fsi"); + arguments.AddRange(["--nologo", Path.Combine(kernelFolder, "fry_kernel.fsx"), "--", "--cwd", folder]); + + return new KernelLaunchSpec( + new ProcessStartSpec + { + FileName = fsharp.ExecutablePath, + Arguments = arguments, + WorkingDirectory = folder + }, + fsharp.DisplayName); + } +} diff --git a/FrySharp.Core/Services/Languages/FSharp/FSharpPackageManager.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpPackageManager.cs new file mode 100644 index 00000000..5a61ec3c --- /dev/null +++ b/FrySharp.Core/Services/Languages/FSharp/FSharpPackageManager.cs @@ -0,0 +1,54 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.CSharp; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; + +/// +/// NuGet packages for F#. F# Interactive loads them itself: #r "nuget: Package, 1.2.3" at the top of a script or a +/// notebook cell restores and references the package when it runs, so no line is for the notebook to run beforehand +/// ( claims none), and a package that is missing is "installed" by putting that line in the +/// file. (The kernel also reads %nuget install Package as the same line.) +/// +public sealed class FSharpPackageManager : IPackageManager +{ + private readonly NuGetPackageManager _inner; + + public FSharpPackageManager(NuGetPackageManager? inner = null) + { + _inner = inner ?? new NuGetPackageManager(); + } + + public string ToolName => "NuGet"; + + /// No line is a package command to run beforehand: fsi loads #r "nuget:" lines as the code runs. + public bool TryParseDirective(string line, out PackageCommand command) + { + command = new PackageCommand(string.Empty, Array.Empty()); + return false; + } + + public PackageCommand InstallCommand(string package) + { + var trimmed = package.Trim(); + return new PackageCommand($"#r \"nuget: {trimmed}\"", ["install", trimmed]); + } + + public string PackageForMissingDependency(string missingName) => + _inner.PackageForMissingDependency(missingName); + + public Task RunAsync( + PackageCommand command, + ToolchainInfo toolchain, + Action output, + CancellationToken ct = default) + { + // The package belongs to the file: its reference is a line at the top, loaded when the file runs. + var package = command.Arguments.Count > 1 ? string.Join(", ", command.Arguments.Skip(1)) : string.Empty; + if (package.Length == 0) return Task.FromResult(new PackageCommandResult(false, "Name the package: #r \"nuget: Package\".")); + + var line = $"#r \"nuget: {package}\""; + output($"F# loads NuGet packages from a line in the file: {line}\n"); + return Task.FromResult(new PackageCommandResult(true, DirectiveToInsert: line)); + } +} diff --git a/Services/Languages/FSharp/FSharpScriptRunner.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpScriptRunner.cs similarity index 100% rename from Services/Languages/FSharp/FSharpScriptRunner.cs rename to FrySharp.Core/Services/Languages/FSharp/FSharpScriptRunner.cs diff --git a/Services/Languages/FSharp/FSharpToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/FSharp/FSharpToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/FSharp/FSharpToolchainProvider.Discovery.cs diff --git a/Services/Languages/FSharp/FSharpToolchainProvider.cs b/FrySharp.Core/Services/Languages/FSharp/FSharpToolchainProvider.cs similarity index 100% rename from Services/Languages/FSharp/FSharpToolchainProvider.cs rename to FrySharp.Core/Services/Languages/FSharp/FSharpToolchainProvider.cs diff --git a/FrySharp.Core/Services/Languages/FSharp/Kernel/fry_kernel.fsx b/FrySharp.Core/Services/Languages/FSharp/Kernel/fry_kernel.fsx new file mode 100644 index 00000000..683b58e4 --- /dev/null +++ b/FrySharp.Core/Services/Languages/FSharp/Kernel/fry_kernel.fsx @@ -0,0 +1,540 @@ +// The Fry kernel for F#: speaks the Fry kernel protocol (docs/kernel-protocol.md) on stdin and stdout, and runs the cells of a +// notebook in one F# Interactive evaluation session, so what a cell defines the next one sees, as in `dotnet fsi`. +// +// dotnet fsi --nologo fry_kernel.fsx -- --cwd +// +// The session is the F# compiler's own (FSharp.Compiler.Service.dll, which ships in the .NET SDK beside fsi, and which fsi +// finds for `#r` by itself, so it is always the one of the fsi that runs this). A cell's +// console output, its Console.ReadLine, and the displays of Display.fsx (Display.table, Display.lineChart ...) are +// routed to the protocol; the protocol itself keeps a private copy of the real stdout. +#r "FSharp.Compiler.Service.dll" +#load "Values.fsx" +#nowarn "3886" // a list of tuples written one per line reads as intended + +open System +open System.Collections +open System.Collections.Concurrent +open System.Collections.Generic +open System.Globalization +open System.IO +open System.Reflection +open System.Text +open System.Text.Json +open System.Text.Json.Nodes +open System.Threading +open FSharp.Compiler.Diagnostics +open FSharp.Compiler.Interactive.Shell +open FSharp.Reflection +open Fry.Values + +// ─────────────────────────────────────────────────────────────────────────────────────────────── protocol output + +let private protocolOut = new StreamWriter(Console.OpenStandardOutput(), UTF8Encoding(false)) +protocolOut.AutoFlush <- true + +let private sendLock = obj () + +/// One message, one line. System.Text.Json escapes everything outside ASCII, so a line never breaks inside a character. +let private sendRaw (message: JsonObject) = + lock sendLock (fun () -> protocolOut.Write(message.ToJsonString() + "\n")) + +// ─────────────────────────────────────────────────────────────────────────────────────────── the cell's console output + +let mutable private currentId = "" + +/// What a cell prints: text is batched (8 KB or 50 ms), and the display lines of Display.fsx become protocol messages in +/// their place in the output, so text, tables and charts stay in the order the cell made them. +type private Hub() = + let gate = obj () + let text = StringBuilder() + let mutable textName = "stdout" + + let flushText () = + if text.Length > 0 then + sendRaw (jobj [ "type", jstr "stream"; "id", jstr currentId; "name", jstr textName; "text", jstr (text.ToString()) ]) + text.Clear() |> ignore + + member _.Text(name: string, s: string) = + lock gate (fun () -> + if name <> textName then + flushText () + textName <- name + text.Append(s) |> ignore + if text.Length >= 8192 then flushText ()) + + /// A protocol message made by the cell (a display): after the text before it. + member _.Message(message: JsonObject) = + lock gate (fun () -> + flushText () + message.["id"] <- jstr currentId + sendRaw message) + + member _.Flush() = lock gate flushText + +let private hub = Hub() + +let private markers = [| "__FRY_DISPLAY__"; "{\"type\":" |] + +let private couldBeMarker (partial: string) = + partial.Length = 0 || markers |> Array.exists (fun m -> m.StartsWith(partial, StringComparison.Ordinal) || partial.StartsWith(m, StringComparison.Ordinal)) + +let private displayTypes = set [ "display"; "update_display"; "subscribe"; "unsubscribe" ] + +/// The message in a line the cell's code printed ("__FRY_DISPLAY__ {...}" or a bare typed message), or None for ordinary text. +let private protocolLine (line: string) : JsonObject option = + let trimmed = line.Trim() + let payload = + if trimmed.StartsWith("__FRY_DISPLAY__", StringComparison.Ordinal) then trimmed.Substring("__FRY_DISPLAY__".Length).Trim() + elif trimmed.StartsWith("{\"type\":", StringComparison.Ordinal) then trimmed + else "" + if payload.Length = 0 then None + else + try + match JsonNode.Parse(payload) with + | :? JsonObject as o -> + let kind = match o.["type"] with | null -> "display" | t -> t.GetValue() + if displayTypes.Contains kind then + o.["type"] <- jstr kind + Some o + else None + | _ -> None + with _ -> None + +type private CellWriter(name: string) = + inherit TextWriter() + let partial = StringBuilder() + + override _.Encoding = UTF8Encoding(false) :> Encoding + + override _.Write(c: char) = base.Write(c) + + override _.Write(s: string) = + if not (isNull s) && s.Length > 0 then + lock partial (fun () -> + for c in s do + partial.Append(c) |> ignore + if c = '\n' then + let line = partial.ToString(0, partial.Length - 1).TrimEnd('\r') + partial.Clear() |> ignore + match (if name = "stdout" then protocolLine line else None) with + | Some message -> hub.Message(message) + | None -> hub.Text(name, line + "\n") + // an interactive prompt ("Name? ") has no newline: show it unless it may still turn out to be a display line + if partial.Length > 0 && (name <> "stdout" || not (couldBeMarker (partial.ToString()))) then + hub.Text(name, partial.ToString()) + partial.Clear() |> ignore) + + override this.Write(buffer: char[], index: int, count: int) = this.Write(String(buffer, index, count)) + + override _.Flush() = + lock partial (fun () -> + if partial.Length > 0 then + hub.Text(name, partial.ToString()) + partial.Clear() |> ignore) + hub.Flush() + +// ─────────────────────────────────────────────────────────────────────────────────────────────────────── input + +let private inputReplies = new BlockingCollection() + +/// Console.ReadLine in a cell: the studio is asked for a line, and its answer (or the end of input) comes back on stdin. +type private CellInput() = + inherit TextReader() + let mutable buffer = "" + + member private _.Ask() : string option = + hub.Flush() + sendRaw (jobj [ "type", jstr "input_request"; "id", jstr currentId; "prompt", jstr ""; "password", jbool false ]) + inputReplies.Take() + + override this.ReadLine() = + if buffer.Length > 0 then + let line = buffer + buffer <- "" + line + else + match this.Ask() with + | Some line -> line + | None -> null + + override this.Read() = + if buffer.Length = 0 then + match this.Ask() with + | Some line -> buffer <- line + "\n" + | None -> buffer <- "" + if buffer.Length = 0 then -1 + else + let c = buffer.[0] + buffer <- buffer.Substring(1) + int c + + override this.Peek() = if buffer.Length > 0 then int buffer.[0] else -1 + +// ─────────────────────────────────────────────────────────────────────────────────────── values: JSON in and out (#!share) + +let rec private toJson (o: obj) (depth: int) : JsonNode = + match o with + | null -> null + | :? string as s -> jstr s + | :? bool as b -> jbool b + | :? int as n -> JsonValue.Create n + | :? int64 as n -> JsonValue.Create n + | :? int16 as n -> JsonValue.Create(int n) + | :? byte as n -> JsonValue.Create(int n) + | :? uint32 as n -> JsonValue.Create(int64 n) + | :? float as f -> if Double.IsFinite f then JsonValue.Create f else null + | :? float32 as f -> if Single.IsFinite f then JsonValue.Create(float f) else null + | :? decimal as d -> JsonValue.Create d + | :? char as c -> jstr (string c) + | :? DateTime as d -> jstr (d.ToString("o", invariant)) + | :? DateTimeOffset as d -> jstr (d.ToString("o", invariant)) + | _ when o.GetType().IsEnum -> jstr (o.ToString()) + | :? IFormattable as f when isScalarType (o.GetType()) -> jstr (f.ToString(null, invariant)) + | _ when depth > 12 -> jstr (oneLine o) + | _ -> + let t = o.GetType() + if t.IsGenericType && t.GetGenericTypeDefinition() = typedefof> then toJson (t.GetProperty("Value").GetValue(o)) (depth + 1) + elif isDictionary o then + let obj = JsonObject() + for (k, v) in entriesOf o do obj.[scalarText k] <- toJson v (depth + 1) + upcast obj + elif FSharpType.IsRecord(t, true) then + let obj = JsonObject() + for f in FSharpType.GetRecordFields(t, true) do obj.[f.Name] <- toJson (f.GetValue(o)) (depth + 1) + upcast obj + elif FSharpType.IsTuple t then upcast jarr (FSharpValue.GetTupleFields o |> Array.map (fun v -> toJson v (depth + 1))) + elif FSharpType.IsUnion(t, true) && not (t.IsGenericType && t.GetGenericTypeDefinition() = typedefof>) then + let case, fields = FSharpValue.GetUnionFields(o, t, true) + if fields.Length = 0 then jstr case.Name + else + let obj = JsonObject() + obj.["case"] <- jstr case.Name + Array.iter2 (fun (f: PropertyInfo) v -> obj.[f.Name] <- toJson v (depth + 1)) (case.GetFields()) fields + upcast obj + elif isCollection o then upcast jarr ((o :?> IEnumerable) |> Seq.cast |> Seq.truncate 100000 |> Seq.map (fun v -> toJson v (depth + 1)) |> Array.ofSeq) + elif FSharpType.IsFunction t then failwithf "a function (%s) is not data, so it can't be shared" (friendly t) + else + let obj = JsonObject() + for (name, get) in publicMembers t do obj.[name] <- toJson (get o) (depth + 1) + upcast obj + +let private fsString (s: string) = + let sb = StringBuilder("\"") + for c in s do + match c with + | '"' -> sb.Append("\\\"") |> ignore + | '\\' -> sb.Append("\\\\") |> ignore + | '\n' -> sb.Append("\\n") |> ignore + | '\r' -> sb.Append("\\r") |> ignore + | '\t' -> sb.Append("\\t") |> ignore + | c when c < ' ' || c = '
' || c = '
' -> sb.AppendFormat("\\u{0:x4}", int c) |> ignore + | c -> sb.Append(c) |> ignore + sb.Append("\"").ToString() + +let private isIdentifier (name: string) = + name.Length > 0 && (Char.IsLetter name.[0] || name.[0] = '_') && name |> Seq.forall (fun c -> Char.IsLetterOrDigit c || c = '_' || c = '\'') + +let private bindingName (name: string) = if isIdentifier name then name else "``" + name.Replace("``", "") + "``" + +/// F# source for a JSON value: numbers, strings, bools, arrays (typed by what they hold) and objects (as `dict`), so that a +/// value shared from another language is an ordinary F# value the next cell can use. +let rec private literal (e: JsonElement) : string = + match e.ValueKind with + | JsonValueKind.Null | JsonValueKind.Undefined -> "Unchecked.defaultof" + | JsonValueKind.True -> "true" + | JsonValueKind.False -> "false" + | JsonValueKind.String -> fsString (e.GetString()) + | JsonValueKind.Number -> + match e.TryGetInt32() with + | true, n -> string n + | _ -> + match e.TryGetInt64() with + | true, n -> string n + "L" + | _ -> let d = e.GetDouble() in (if Double.IsFinite d then d.ToString("R", invariant) + (if d = Math.Floor d && abs d < 1e15 then ".0" else "") else "nan") + | JsonValueKind.Array -> + let items = e.EnumerateArray() |> List.ofSeq + let kinds = items |> List.map (fun i -> i.ValueKind) |> List.distinct + let allNumbers = not items.IsEmpty && kinds = [ JsonValueKind.Number ] + if items.IsEmpty then "[||] : obj[]" + elif allNumbers && items |> List.forall (fun i -> fst (i.TryGetInt32())) then "[| " + String.Join("; ", items |> List.map literal) + " |]" + elif allNumbers && items |> List.forall (fun i -> fst (i.TryGetInt64())) then "[| " + String.Join("; ", items |> List.map (fun i -> string (i.GetInt64()) + "L")) + " |]" + elif allNumbers then "[| " + String.Join("; ", items |> List.map (fun i -> let d = i.GetDouble() in d.ToString("R", invariant) + (if d = Math.Floor d && abs d < 1e15 then ".0" else ""))) + " |]" + elif kinds = [ JsonValueKind.String ] || kinds = [ JsonValueKind.True ] || kinds = [ JsonValueKind.False ] || (kinds |> List.forall (fun k -> k = JsonValueKind.True || k = JsonValueKind.False)) then + "[| " + String.Join("; ", items |> List.map literal) + " |]" + elif kinds = [ JsonValueKind.Object ] then "[| " + String.Join("; ", items |> List.map literal) + " |]" + elif kinds = [ JsonValueKind.Array ] then "[| " + String.Join("; ", items |> List.map literal) + " |]" + else "[| " + String.Join("; ", items |> List.map (fun i -> "box (" + literal i + ")")) + " |]" + | _ -> + let pairs = e.EnumerateObject() |> Seq.map (fun p -> sprintf "(%s, box (%s))" (fsString p.Name) (literal p.Value)) |> List.ofSeq + if pairs.IsEmpty then "dict (Seq.empty : seq)" else "dict [ " + String.Join("; ", pairs) + " ]" + +// ───────────────────────────────────────────────────────────────────────────────────────────────────── the session + +let private argAfter (name: string) = + let args = fsi.CommandLineArgs + match Array.tryFindIndex ((=) name) args with + | Some i when i + 1 < args.Length -> Some args.[i + 1] + | _ -> None + +match argAfter "--cwd" with +| Some folder when Directory.Exists folder -> Directory.SetCurrentDirectory folder +| _ -> () + +let private cellOut = new CellWriter("stdout") +let private cellErr = new CellWriter("stderr") +let private cellIn = new CellInput() +Console.SetOut cellOut +Console.SetError cellErr +Console.SetIn cellIn + +// events the studio sends for the visuals a cell listens to: Display.fsx drains this queue +let private events = ConcurrentQueue() +AppDomain.CurrentDomain.SetData("fry.events", events) + +let private session = + let argv = [| "fsi.exe"; "--noninteractive"; "--nologo"; "--gui-" |] + FsiEvaluationSession.Create(FsiEvaluationSession.GetDefaultConfiguration(), argv, new StringReader(""), new StringWriter(), new StringWriter(), collectible = false) + +let private mainThread = Thread.CurrentThread + +/// Runs F# in the session: the value of its last expression (when it has one), and what the compiler said. +let private eval (code: string) = + let result, diagnostics = session.EvalInteractionNonThrowing(code) + result, diagnostics + +let private isError (d: FSharpDiagnostic) = d.Severity = FSharpDiagnosticSeverity.Error + +let private diagnosticJson (d: FSharpDiagnostic) : JsonNode = + upcast jobj [ "severity", jstr (if isError d then "error" else "warning") + "code", jstr d.ErrorNumberText + "message", jstr (d.Message.Trim()) + "line", jint d.StartLine + "column", jint (d.StartColumn + 1) + "endLine", jint d.EndLine + "endColumn", jint (d.EndColumn + 1) ] + +let private prelude = + [ "open System"; "open System.IO"; "open System.Collections.Generic"; "open System.Linq" ] |> String.concat "\n" + +eval prelude |> ignore + +let mutable private displayLoaded = false + +let private displayFsx = Path.Combine(__SOURCE_DIRECTORY__, "Display.fsx") + +/// Display.fsx (Display.table, Display.lineChart ...) is compiled in the first time a cell mentions it: it takes a moment. +let private ensureDisplay (code: string) = + if not displayLoaded && (code.Contains "Display" || code.Contains "Fry.") && File.Exists displayFsx then + displayLoaded <- true + let result, diagnostics = eval (sprintf "#load %s\nopen Fry" (fsString (displayFsx.Replace('\\', '/')))) + match result with + | Choice2Of2 _ -> + displayLoaded <- false + let text = String.Join("\n", diagnostics |> Array.filter isError |> Array.map (fun d -> d.Message)) + hub.Text("stderr", "Display.fsx did not load: " + text + "\n") + | _ -> () + +/// `%nuget install Package [version]` is the Jupyter way to say what fsi says as `#r "nuget: Package, version"`: the same line, in +/// the same place, so the lines of the cell stay where they were. +let private nugetMagic = System.Text.RegularExpressions.Regex(@"^\s*[%!#]nuget\s+(?:install|add)\s+([A-Za-z0-9_.\-]+)(?:\s+(\S+))?\s*$", System.Text.RegularExpressions.RegexOptions.IgnoreCase) + +let private withNuGetMagic (code: string) = + code.Split('\n') + |> Array.map (fun line -> + let m = nugetMagic.Match(line.TrimEnd('\r')) + if not m.Success then line + elif m.Groups.[2].Success then sprintf "#r \"nuget: %s, %s\"" m.Groups.[1].Value m.Groups.[2].Value + else sprintf "#r \"nuget: %s\"" m.Groups.[1].Value) + |> String.concat "\n" + +let private firstLine (text: string) = (text.Split('\n').[0]).Trim() + +/// A runtime exception's text without the plumbing of the session, and the line of the cell it went through. +let private exceptionReport (ex: exn) : string * string * string * int option = + let ex = (match ex with | :? TargetInvocationException as t when not (isNull t.InnerException) -> t.InnerException | _ -> ex) + let lines = ex.ToString().Split('\n') |> Array.map (fun l -> l.TrimEnd('\r')) + let shown = lines |> Array.takeWhile (fun l -> not (l.TrimStart().StartsWith("at System.Reflection."))) + let cleaned = shown |> Array.map (fun l -> System.Text.RegularExpressions.Regex.Replace(l, @" in .*?input\.fsx:line (\d+)", " in cell:line $1")) + let own = + lines |> Array.choose (fun l -> + let m = System.Text.RegularExpressions.Regex.Match(l, @"StartupCode\$FSI_\d+.*? in .*?:line (\d+)") + if m.Success then Some(int m.Groups.[1].Value) else None) + ex.GetType().Name, ex.Message, String.Join("\n", cleaned), (if own.Length > 0 then Some own.[own.Length - 1] else None) + +let private warningsText (diagnostics: FSharpDiagnostic[]) = + diagnostics + |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Warning) + |> Array.map (fun d -> sprintf "(%d,%d): warning %s: %s\n" d.StartLine (d.StartColumn + 1) d.ErrorNumberText (firstLine d.Message)) + |> String.concat "" + +let mutable private interrupted = false +let mutable private executing = false +let mutable private executionCount = 0 + +let private sendReply (id: string) (status: string) (extra: (string * JsonNode) list) = + hub.Flush() + sendRaw (jobj ([ "type", jstr "reply"; "id", jstr id; "status", jstr status ] @ extra)) + +let private execute (id: string) (code: string) = + currentId <- id + interrupted <- false + executing <- true + executionCount <- executionCount + 1 + let mutable status = "ok" + try + try + let code = withNuGetMagic code + ensureDisplay code + let mutable result, diagnostics = eval code + // `failwith "x"` as the last expression has a generic type that FSI tolerates and the evaluation API does not + if (match result with Choice2Of2 _ -> diagnostics |> Array.exists (fun d -> isError d && d.ErrorNumber = 30) | _ -> false) then + let retried, retriedDiagnostics = eval (code + "\n()") + result <- retried + diagnostics <- retriedDiagnostics |> Array.filter (fun d -> d.ErrorNumber <> 20) + + hub.Text("stderr", warningsText diagnostics) + match result with + | Choice1Of2 value -> + match value with + | Some v -> + match bundleOf v.ReflectionValue v.ReflectionType with + | Some data -> hub.Message(jobj [ "type", jstr "display"; "data", data; "metadata", jobj [] ]) + | None -> () + | None -> () + | Choice2Of2 ex -> + status <- "error" + let errors = diagnostics |> Array.filter isError + if interrupted then status <- "interrupted" + elif errors.Length > 0 then + let first = errors.[0] + let trace = errors |> Array.map (fun d -> sprintf "(%d,%d): error %s: %s" d.StartLine (d.StartColumn + 1) d.ErrorNumberText (d.Message.Trim())) |> String.concat "\n" + hub.Message(jobj [ "type", jstr "error"; "ename", jstr first.ErrorNumberText; "evalue", jstr (first.Message.Trim()) + "traceback", jstr (trace + "\n"); "line", jint first.StartLine + "diagnostics", jarr (errors |> Array.map diagnosticJson) ]) + else + let name, message, trace, line = exceptionReport ex + hub.Message(jobj ([ "type", jstr "error"; "ename", jstr name; "evalue", jstr message; "traceback", jstr (trace + "\n") ] + @ (match line with Some l -> [ "line", jint l ] | None -> []))) + with + | :? ThreadInterruptedException -> status <- "interrupted" + | ex -> + status <- "error" + hub.Message(jobj [ "type", jstr "error"; "ename", jstr (ex.GetType().Name); "evalue", jstr ex.Message; "traceback", jstr (ex.ToString() + "\n") ]) + finally + executing <- false + // an interrupt that came as the cell ended is still pending on this thread: let it fire here, not in the next cell + (try Thread.Sleep 1 with :? ThreadInterruptedException -> ()) + cellOut.Flush() + cellErr.Flush() + sendReply id status (if status = "ok" then [ "executionCount", jint executionCount ] else []) + +// ──────────────────────────────────────────────────────────────────────────────────────────────── other requests + +let private boundValues () = + session.GetBoundValues() |> List.filter (fun b -> b.Name <> "it") + +let private variables (id: string) = + let list = + boundValues () + |> List.map (fun b -> + let v = b.Value + let isFunction = FSharpType.IsFunction v.ReflectionType + let value = (if isFunction then friendly v.ReflectionType else oneLine v.ReflectionValue) + upcast jobj [ "name", jstr b.Name; "type", jstr (friendly v.ReflectionType); "value", jstr value; "kind", jstr (if isFunction then "Function" else "Variable") ] : JsonNode) + sendReply id "ok" [ "variables", jarr list ] + +let private failReply (id: string) (message: string) = sendReply id "error" [ "message", jstr message ] + +let private getValue (id: string) (name: string) = + match boundValues () |> List.tryFind (fun b -> b.Name = name) with + | None -> failReply id (sprintf "F# has no variable named '%s'." name) + | Some b -> + try + let json = (match toJson b.Value.ReflectionValue 0 with | null -> "null" | node -> node.ToJsonString()) + sendReply id "ok" [ "json", jstr json ] + with ex -> failReply id ex.Message + +let private setValue (id: string) (name: string) (json: string) = + try + use document = JsonDocument.Parse(json) + let source = sprintf "let %s = %s" (bindingName name) (literal document.RootElement) + let result, diagnostics = eval source + match result with + | Choice2Of2 _ -> failReply id (sprintf "F# could not take '%s': %s" name (String.Join(" ", diagnostics |> Array.filter isError |> Array.map (fun d -> firstLine d.Message)))) + | _ -> sendReply id "ok" [] + with ex -> failReply id ex.Message + +/// The events of the studio that came while no cell was running: their callbacks run now, and what they print belongs to the +/// cell that showed the visual (the event says which). +let private drainEvents () = + let mutable line = Unchecked.defaultof + while events.TryDequeue(&line) do + if displayLoaded then + currentId <- (try JsonDocument.Parse(line).RootElement.GetProperty("id").GetString() with _ -> currentId) + eval (sprintf "Fry.Display.FeedEvent %s" (fsString line)) |> ignore + cellOut.Flush() + cellErr.Flush() + +// ────────────────────────────────────────────────────────────────────────────────────────────────── the request loop + +let private requests = new BlockingCollection() + +let private stdinReader = + Thread((fun () -> + let input = new StreamReader(Console.OpenStandardInput(), UTF8Encoding(false)) + let mutable line = input.ReadLine() + while not (isNull line) do + if line.Trim().Length > 0 then + try + let message = JsonDocument.Parse(line).RootElement.Clone() + let text (n: string) = (match message.TryGetProperty n with | true, v when v.ValueKind = JsonValueKind.String -> v.GetString() | _ -> "") + match text "type" with + | "interrupt" -> + // Thread.Interrupt ends a cell that waits (a sleep, a read) and keeps the session; a cell that only + // computes can't be stopped from here, and the studio ends the kernel after its grace period + if executing then + interrupted <- true + mainThread.Interrupt() + | "input_reply" -> + let value = (match message.TryGetProperty "value" with | true, v when v.ValueKind = JsonValueKind.String -> Some(v.GetString()) | _ -> None) + inputReplies.Add value + | "event" -> + let display = text "display_id" + let ev = (match message.TryGetProperty "event" with | true, v -> v.GetRawText() | _ -> "{}") + events.Enqueue(sprintf "{\"id\":%s,\"display_id\":%s,\"event\":%s}" (JsonSerializer.Serialize(text "id")) (JsonSerializer.Serialize display) ev) + | _ -> requests.Add message + with _ -> () + line <- input.ReadLine() + // the studio closed stdin: end + inputReplies.Add None + requests.CompleteAdding()), IsBackground = true) + +let private version = + let core = typeof>.Assembly.GetName().Version + sprintf "F# %d.%d (.NET %s)" core.Major core.Minor (Environment.Version.ToString(3)) + +let private run () = + sendRaw (jobj [ "type", jstr "ready"; "language", jstr "fsharp"; "version", jstr version; "executable", jstr (Environment.ProcessPath |> Option.ofObj |> Option.defaultValue "dotnet") ]) + stdinReader.Start() + let mutable go = true + while go do + try + let mutable message = Unchecked.defaultof + if requests.TryTake(&message, 200) then + let text (n: string) = (match message.TryGetProperty n with | true, v when v.ValueKind = JsonValueKind.String -> v.GetString() | _ -> "") + let id = text "id" + match text "type" with + | "execute" -> execute id (text "code") + | "variables" -> variables id + | "get_value" -> getValue id (text "name") + | "set_value" -> setValue id (text "name") (text "json") + | "shutdown" -> go <- false + | _ -> () + elif requests.IsCompleted then go <- false + else drainEvents () + with + | :? ThreadInterruptedException -> () + hub.Flush() + exit 0 + +run () diff --git a/Services/Languages/FSharp/Runtime/Display.fsx b/FrySharp.Core/Services/Languages/FSharp/Runtime/Display.fsx similarity index 97% rename from Services/Languages/FSharp/Runtime/Display.fsx rename to FrySharp.Core/Services/Languages/FSharp/Runtime/Display.fsx index 2d1177ae..56174e83 100644 --- a/Services/Languages/FSharp/Runtime/Display.fsx +++ b/FrySharp.Core/Services/Languages/FSharp/Runtime/Display.fsx @@ -1,3 +1,5 @@ +#load "Values.fsx" + namespace Fry open System @@ -99,6 +101,16 @@ module private Detail = ) with _ -> () + // A kernel hosting this file puts the studio's events for the visuals a cell listens to in this queue; they run on the + // cell's own thread, when it asks (Display.wait, Display.process_events) or the kernel is idle. + let drainHostEvents () = + match AppDomain.CurrentDomain.GetData("fry.events") with + | :? System.Collections.Concurrent.ConcurrentQueue as queue -> + let mutable line = Unchecked.defaultof + while queue.TryDequeue(&line) do + dispatchEventLine line + | _ -> () + let mutable private socketInitialized = 0 let ensureEventSocket() = @@ -648,23 +660,39 @@ type Display private () = static member json(jsonString: string, ?title: string) = Display.Json(jsonString, ?title = title) + static member private Emit(data: System.Text.Json.Nodes.JsonObject) = + let message = Values.jobj [ "type", Values.jstr "display"; "data", data; "metadata", Values.jobj [] ] + Detail.emitProtocol ("__FRY_DISPLAY__ " + message.ToJsonString()) + + /// Shows a value the way the last expression of a cell is shown: a table of records, the Object Explorer's tree, or text. + static member Show(value: obj, ?title: string) : unit = + match Values.bundleOf value (if isNull value then typeof else value.GetType()) with + | Some data -> Display.Emit data + | None -> () + + static member show(value: obj, ?title: string) = Display.Show(value, ?title = title) + + /// Shows a value and returns it, so it can sit in a pipeline. static member Dump(value: obj, ?title: string) = - try - let t = defaultArg title "Data" - let options = JsonSerializerOptions(WriteIndented = true) - let j = JsonSerializer.Serialize(value, options) - Display.Json(j, t) - with _ -> - printfn "%A" value + Display.Show(value, ?title = title) value static member dump(value: obj, ?title: string) = Display.Dump(value, ?title = title) + /// A collection as the studio's table: sortable, filterable, copyable. A collection of records has a column for each + /// field, one of numbers or text a column of its own. static member Table(value: obj, ?title: string) = - Display.Dump(value, ?title = title) |> ignore + match Values.tableBundle value title with + | Some data -> Display.Emit data + | None -> Display.Show(value) static member table(value: obj, ?title: string) = Display.Table(value, ?title = title) + static member Markdown(text: string) = + Display.Emit(Values.jobj [ "text/markdown", Values.jstr text; "text/plain", Values.jstr text ]) + + static member markdown(text: string) = Display.Markdown(text) + // ── Canonical Visual API ────────────────────────────────────────────── // 1. Line Chart @@ -1537,12 +1565,18 @@ type Display private () = let sw = System.Diagnostics.Stopwatch.StartNew() let limit = seconds * 1000.0 while sw.Elapsed.TotalMilliseconds < limit do + Detail.drainHostEvents() System.Threading.Thread.Sleep(25) static member wait(seconds: float) = Display.Wait(seconds) + /// One event of the studio (a click, a selection, a step on a visual the cell listens to), run now: for a kernel hosting + /// this file, which hands it over when the cell has ended. + static member FeedEvent(line: string) = Detail.dispatchEventLine line + static member ProcessEvents() = Detail.ensureEventSocket() + Detail.drainHostEvents() stdout.Flush() static member process_events() = Display.ProcessEvents() diff --git a/FrySharp.Core/Services/Languages/FSharp/Runtime/Values.fsx b/FrySharp.Core/Services/Languages/FSharp/Runtime/Values.fsx new file mode 100644 index 00000000..2a04f3ca --- /dev/null +++ b/FrySharp.Core/Services/Languages/FSharp/Runtime/Values.fsx @@ -0,0 +1,295 @@ +// How a value is shown in a notebook: text for a scalar, one line for a short list, the studio's table for a collection of +// records, and the Object Explorer's tree for anything else. Used by the F# kernel (fry_kernel.fsx) for the value a cell +// ends with, and by Display.fsx (Display.Table, Display.Dump). The shapes are the ones the Python, JavaScript and Java +// kernels produce (docs/kernel-protocol.md, "MIME bundles"). +#nowarn "3886" // a list of tuples written one per line reads as intended + +namespace Fry + +open System +open System.Collections +open System.Collections.Generic +open System.Globalization +open System.Reflection +open System.Text +open System.Text.Json.Nodes +open FSharp.Reflection + +module Values = + + let jstr (s: string) : JsonNode = JsonValue.Create(s) + let jint (n: int) : JsonNode = JsonValue.Create(n) + let jbool (b: bool) : JsonNode = JsonValue.Create(b) + + let jobj (pairs: (string * JsonNode) list) : JsonObject = + let o = JsonObject() + for (k, v) in pairs do o.[k] <- v + o + + let jarr (items: JsonNode seq) : JsonArray = + let a = JsonArray() + for i in items do a.Add(i) + a + + // ─────────────────────────────────────────────────────────────────────────────────────────────── values: text, tree, table + + let invariant = CultureInfo.InvariantCulture + + let maxInline = 50 + let maxItems = 100 + let maxDepth = 3 + let maxRows = 1000 + + let isScalarType (t: Type) = + t.IsPrimitive || t.IsEnum || t = typeof || t = typeof || t = typeof || t = typeof + || t = typeof || t = typeof || t = typeof || t = typeof + || t = typeof || t = typeof + + let isScalar (o: obj) = isNull o || isScalarType (o.GetType()) + + let scalarText (o: obj) = + match o with + | null -> "null" + | :? string as s -> s + | :? bool as b -> if b then "true" else "false" + | :? float as f -> (if Double.IsFinite f && f = Math.Floor f && abs f < 1e15 then f.ToString("0.0", invariant) else f.ToString("R", invariant)) + | :? float32 as f -> f.ToString("R", invariant) + | :? DateTime as d -> d.ToString("yyyy-MM-dd HH:mm:ss", invariant) + | :? IFormattable as f -> f.ToString(null, invariant) + | _ -> o.ToString() + + let typeNames = + dict [ typeof, "int"; typeof, "int64"; typeof, "float"; typeof, "float32"; typeof, "string" + typeof, "bool"; typeof, "char"; typeof, "byte"; typeof, "int16"; typeof, "decimal" + typeof, "obj"; typeof, "unit"; typeof, "uint"; typeof, "uint64"; typeof, "sbyte" ] + + /// A type as F# writes it: int, string list, (int * string)[], Map. + let rec friendly (t: Type) : string = + match typeNames.TryGetValue t with + | true, name -> name + | _ -> + if t.IsArray then friendly (t.GetElementType()) + "[]" + elif t.Name.Contains "AnonymousType" then + // an anonymous record: {| Name: string; Age: int |} + let fields = t.GetProperties() |> Array.map (fun p -> p.Name + ": " + friendly p.PropertyType) + "{| " + String.Join("; ", fields) + " |}" + elif t.IsGenericType then + let def = t.GetGenericTypeDefinition() + let args = t.GetGenericArguments() + if def = typedefof> then friendly args.[0] + " list" + elif def = typedefof> then friendly args.[0] + " option" + elif def = typedefof> then friendly args.[0] + " set" + elif def = typedefof> then "Map<" + String.Join(", ", args |> Array.map friendly) + ">" + elif t.Name.Contains "@" || t.Name.StartsWith "Generated" then "seq<" + friendly args.[0] + ">" + elif FSharpType.IsTuple t then String.Join(" * ", args |> Array.map friendly) + else + let name = t.Name + let name = (match name.IndexOf('`') with | -1 -> name | i -> name.Substring(0, i)) + name + "<" + String.Join(",", args |> Array.map friendly) + ">" + elif FSharpType.IsTuple t then String.Join(" * ", FSharpType.GetTupleElements t |> Array.map friendly) + else t.Name + + /// What a value reads as on one line, for hosts that can't draw it any better. + let plain (o: obj) = + if isNull o then "null" + else + let text = (try sprintf "%A" o with _ -> o.ToString()) + if text.Length > 2000 then text.Substring(0, 2000) + " ..." else text + + let oneLine (o: obj) = + let text = (if isScalar o then scalarText o else (plain o).Replace("\r", " ").Replace("\n", " ")) + let text = System.Text.RegularExpressions.Regex.Replace(text, @"\s{2,}", " ") + if text.Length > 80 then text.Substring(0, 80) + "…" else text + + let isCollection (o: obj) = (match o with | :? string -> false | :? IDictionary -> false | :? IEnumerable -> true | _ -> false) + + let isDictionary (o: obj) = + match o with + | :? IDictionary -> true + | null -> false + | _ -> + let t = o.GetType() + FSharpType.IsRecord(t, true) |> not + && (t.IsGenericType && (let d = t.GetGenericTypeDefinition() in d = typedefof>) + || t.GetInterfaces() |> Array.exists (fun i -> i.IsGenericType && i.GetGenericTypeDefinition() = typedefof>)) + + /// The key/value entries of a Map or dictionary. + let entriesOf (o: obj) : (obj * obj) list = + [ for item in (o :?> IEnumerable) do + if not (isNull item) then + let t = item.GetType() + let key = t.GetProperty("Key") + let value = t.GetProperty("Value") + if not (isNull key) && not (isNull value) then yield key.GetValue(item), value.GetValue(item) ] + + let publicMembers (t: Type) = + let props = + t.GetProperties(BindingFlags.Public ||| BindingFlags.Instance) + |> Array.filter (fun p -> p.CanRead && p.GetIndexParameters().Length = 0) + |> Array.map (fun p -> p.Name, (fun (o: obj) -> try p.GetValue(o) with _ -> null)) + let fields = + t.GetFields(BindingFlags.Public ||| BindingFlags.Instance) + |> Array.map (fun f -> f.Name, (fun (o: obj) -> try f.GetValue(o) with _ -> null)) + Array.append props fields + + /// The named members of an object: record fields, tuple items, union fields, or the public properties of a class. + let membersOf (o: obj) : (string * obj) list = + let t = o.GetType() + if FSharpType.IsRecord(t, true) then [ for f in FSharpType.GetRecordFields(t, true) -> f.Name, f.GetValue(o) ] + elif FSharpType.IsTuple t then FSharpValue.GetTupleFields o |> Array.mapi (fun i v -> sprintf "Item%d" (i + 1), v) |> List.ofArray + elif FSharpType.IsUnion(t, true) then + let case, fields = FSharpValue.GetUnionFields(o, t, true) + List.zip (case.GetFields() |> List.ofArray) (List.ofArray fields) |> List.map (fun (f, v) -> f.Name, v) + else [ for (name, get) in publicMembers t -> name, get o ] + + let titleOf (o: obj) = + let t = o.GetType() + if FSharpType.IsUnion(t, true) && not (t.IsGenericType && t.GetGenericTypeDefinition() = typedefof>) then + let case, _ = FSharpValue.GetUnionFields(o, t, true) + case.Name + else friendly t + + /// The studio's Object Explorer data for a value: {title, circular?, properties: [{name, value, isNull, isString, child}]}. + let rec inspector (o: obj) (depth: int) (ancestors: obj list) : JsonObject = + let title = titleOf o + // A reference back to an object above is listed again (flagged), down to the depth limit, as the desktop's C# inspector does. + let circular = ancestors |> List.exists (fun a -> obj.ReferenceEquals(a, o)) + let entries : (string * obj) list * int = + if isDictionary o then + let all = entriesOf o + (all |> List.truncate maxItems |> List.map (fun (k, v) -> scalarText (if isScalar k then k else box (oneLine k)), v)), all.Length + elif isCollection o then + let taken = (o :?> IEnumerable) |> Seq.cast |> Seq.truncate (maxItems + 1) |> List.ofSeq + (taken |> List.truncate maxItems |> List.mapi (fun i v -> sprintf "[%d]" i, v)), taken.Length + else + let all = membersOf o + (all |> List.truncate maxItems), all.Length + let rows, total = entries + let property (name: string, value: obj) : JsonNode = + if isScalar value then + upcast jobj [ "name", jstr name; "value", jstr (scalarText value); "isNull", jbool (isNull value); "isString", jbool (value :? string) ] + elif depth >= maxDepth then + upcast jobj [ "name", jstr name; "child", jobj [ "title", jstr (titleOf value); "properties", jarr [] ] ] + else + upcast jobj [ "name", jstr name; "child", inspector value (depth + 1) (o :: ancestors) ] + let shown = rows |> List.map property + let more = + if total > maxItems then [ (upcast jobj [ "name", jstr "…"; "value", jstr (sprintf "(more than %d shown)" maxItems); "isNull", jbool false; "isString", jbool false ] : JsonNode) ] + else [] + jobj ([ "title", jstr title ] @ (if circular then [ "circular", jbool true ] else []) @ [ "properties", jarr (shown @ more) ]) + + let isNumericValue (o: obj) = + match o with + | :? int | :? int64 | :? float | :? float32 | :? decimal | :? byte | :? int16 | :? uint32 | :? uint64 | :? sbyte | :? uint16 -> true + | _ -> false + + /// The columns of a table of objects: a record's fields, a tuple's items or a class's public properties. + let columnsOf (sample: obj) : (string * (obj -> obj)) list = + let t = sample.GetType() + if FSharpType.IsRecord(t, true) then [ for f in FSharpType.GetRecordFields(t, true) -> f.Name, (fun (o: obj) -> f.GetValue(o)) ] + elif FSharpType.IsTuple t then [ for i in 0 .. FSharpType.GetTupleElements(t).Length - 1 -> sprintf "Item%d" (i + 1), (fun (o: obj) -> (FSharpValue.GetTupleFields o).[i]) ] + elif FSharpType.IsUnion(t, true) then [] + else [ for (name, get) in publicMembers t -> name, get ] + + /// A collection of objects as the studio's table, or None when it is not tabular. + let tableOf (items: obj list) (total: int) (title: string) : JsonObject option = + match items |> List.tryFind (fun i -> not (isNull i)) with + | Some sample when not (isScalar sample) && not (isCollection sample) && not (isDictionary sample) -> + let columns = columnsOf sample + if columns.IsEmpty then None + else + let sampleType = sample.GetType() + let rows = + items |> List.truncate maxRows |> List.map (fun item -> + columns |> List.map (fun (_, get) -> + if isNull item || item.GetType() <> sampleType then null else (try get item with _ -> null))) + let numeric = columns |> List.mapi (fun c _ -> rows |> List.forall (fun r -> isNull r.[c] || isNumericValue r.[c]) && rows |> List.exists (fun r -> not (isNull r.[c]))) + Some(jobj [ "title", jstr title + "columns", jarr (columns |> List.map (fun (n, _) -> jstr n)) + "numeric", jarr (numeric |> List.map jbool) + "rows", jarr (rows |> List.map (fun r -> upcast jarr (r |> List.map (fun v -> jstr (if isNull v then "" else oneLine v))))) + "totalRows", jint (max total rows.Length) + "totalColumns", jint columns.Length ]) + | _ -> None + + let INSPECTOR_MIME = "application/vnd.fry.inspector+json" + let TABLE_MIME = "application/vnd.fry.table+json" + + // (in an F# Interactive session a type's full name has the session's assembly in front: "FSI_0004.Fry.DisplayHandle") + let isHandle (t: Type) = t.FullName <> null && (t.FullName.EndsWith "Fry.DisplayHandle" || t.FullName.EndsWith "Fry.CanvasVisualizer") + + let elementTypeOf (t: Type) = + if t.IsArray then t.GetElementType() + else + t.GetInterfaces() |> Array.append [| t |] + |> Array.tryPick (fun i -> if i.IsGenericType && i.GetGenericTypeDefinition() = typedefof> then Some(i.GetGenericArguments().[0]) else None) + |> Option.defaultValue typeof + + /// The bundle for the value a cell ends with, or None when there is nothing to show (unit, a handle the cell already displayed). + let bundleOf (o: obj) (t: Type) : JsonObject option = + let text (s: string) = Some(jobj [ "text/plain", jstr s ]) + if t = typeof then None + elif isNull o then text "null" + elif isHandle t then None + elif isScalar o then text (scalarText o) + elif FSharpType.IsFunction t || o :? Delegate then text (friendly t) + elif isDictionary o then + Some(jobj [ INSPECTOR_MIME, inspector o 0 []; "text/plain", jstr (plain o) ]) + elif isCollection o then + let taken = (o :?> IEnumerable) |> Seq.cast |> Seq.truncate (maxRows + 1) |> List.ofSeq + let total = + match o with + | :? ICollection as c -> c.Count + | _ -> + let count = o.GetType().GetProperty("Count") + if not (isNull count) && count.PropertyType = typeof then count.GetValue(o) :?> int else taken.Length + let title = sprintf "%s (%d)" (friendly (elementTypeOf (o.GetType()))) total + if taken.IsEmpty then text "[ ]" + elif taken.Length <= maxInline && taken |> List.forall isScalar then + text ("[ " + String.Join(", ", taken |> List.map (fun v -> if v :? string then "\"" + (v :?> string) + "\"" else scalarText v)) + " ]") + else + match tableOf taken total title with + | Some table -> Some(jobj [ TABLE_MIME, table; "text/plain", jstr title ]) + | None -> Some(jobj [ INSPECTOR_MIME, inspector o 0 []; "text/plain", jstr (plain o) ]) + else + let t = o.GetType() + // a union case without data (None, Red) reads best as its name + let members = membersOf o + // a union case without data (None, Red), or with a little scalar data (Some 5, Circle 2.0), reads best as it is written + if FSharpType.IsUnion(t, true) && (members.IsEmpty || (members.Length <= 3 && members |> List.forall (fun (_, v) -> isScalar v))) then text (plain o) + elif members.IsEmpty then text (plain o) + else Some(jobj [ INSPECTOR_MIME, inspector o 0 []; "text/plain", jstr (plain o) ]) + + /// A collection as the studio's table: its records' fields as columns, or a single column of scalars. None when the value + /// is not a collection. + let tableBundle (o: obj) (title: string option) : JsonObject option = + if isNull o || not (isCollection o || isDictionary o) then None + else + let items = (o :?> IEnumerable) |> Seq.cast |> Seq.truncate (maxRows + 1) |> List.ofSeq + let total = + match o with + | :? ICollection as c -> c.Count + | _ -> items.Length + let named = defaultArg title (sprintf "%s (%d)" (friendly (elementTypeOf (o.GetType()))) total) + let table = + if isDictionary o then + let entries = entriesOf o |> List.truncate maxRows + Some(jobj [ "title", jstr named + "columns", jarr [ jstr "Key"; jstr "Value" ] + "numeric", jarr [ jbool false; jbool false ] + "rows", jarr (entries |> List.map (fun (k, v) -> (upcast jarr [ jstr (oneLine k); jstr (oneLine v) ] : JsonNode))) + "totalRows", jint (max total entries.Length) + "totalColumns", jint 2 ]) + else + match tableOf items total named with + | Some t -> Some t + | None when items |> List.forall isScalar -> + let shown = items |> List.truncate maxRows + Some(jobj [ "title", jstr named + "columns", jarr [ jstr "Value" ] + "numeric", jarr [ jbool (not shown.IsEmpty && shown |> List.forall isNumericValue) ] + "rows", jarr (shown |> List.map (fun v -> (upcast jarr [ jstr (if isNull v then "" else scalarText v) ] : JsonNode))) + "totalRows", jint (max total shown.Length) + "totalColumns", jint 1 ]) + | None -> None + table |> Option.map (fun t -> jobj [ TABLE_MIME, t; "text/plain", jstr named ]) diff --git a/Services/Languages/Go/GoBuildAndRunScriptRunner.cs b/FrySharp.Core/Services/Languages/Go/GoBuildAndRunScriptRunner.cs similarity index 94% rename from Services/Languages/Go/GoBuildAndRunScriptRunner.cs rename to FrySharp.Core/Services/Languages/Go/GoBuildAndRunScriptRunner.cs index 530a1bd8..da5c73b5 100644 --- a/Services/Languages/Go/GoBuildAndRunScriptRunner.cs +++ b/FrySharp.Core/Services/Languages/Go/GoBuildAndRunScriptRunner.cs @@ -8,7 +8,7 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; /// Phase 1 (Build step): go build -o <outDir>/bin file.go (stops immediately on compilation error). /// Phase 2 (Run step): <outDir>/bin with interactive terminal standard input streaming. /// -public sealed class GoBuildAndRunScriptRunner(IHostEnvironment host) : IScriptRunner +public sealed class GoBuildAndRunScriptRunner(IHostEnvironment host, string goRoot) : IScriptRunner { public async Task PlanAsync(ScriptRunContext context, CancellationToken ct = default) { @@ -49,8 +49,8 @@ public async Task PlanAsync(ScriptRunContext context, Cancellatio }; var buildEnv = new Dictionary(StringComparer.Ordinal); - var goCache = Path.Combine(outDir, ".gocache"); - buildEnv["GOCACHE"] = goCache; + // One build cache for every script and notebook cell: the standard library is built once, not once per file. + buildEnv["GOCACHE"] = Path.Combine(goRoot, "cache"); if (File.Exists(Path.Combine(sourceDir, "go.mod"))) { diff --git a/FrySharp.Core/Services/Languages/Go/GoCellProgram.cs b/FrySharp.Core/Services/Languages/Go/GoCellProgram.cs new file mode 100644 index 00000000..1e172666 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoCellProgram.cs @@ -0,0 +1,253 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +/// A declaration an earlier notebook cell made (a function, a method, a type, a constant, a variable), kept so later cells can use it. +internal sealed record GoItem(string Kind, IReadOnlyList Keys, IReadOnlyList Names, string Text, string Masked) +{ + public static GoItem From(GoChunk chunk) => new(chunk.ItemKind!, chunk.Keys, chunk.Names, chunk.Text, chunk.Masked); +} + +/// A variable a cell made and the notebook kept: its type as Go writes it, and its value as JSON. +internal sealed record GoVariable(string Name, string Type, string Json); + +/// +/// The declarations the notebook's cells have made, in order. A cell that declares a name again takes the place of the older +/// declaration of it (a function written again, a type with another field). +/// +internal sealed class GoItemStore +{ + private readonly List _items = []; + + public IReadOnlyList Items => _items; + + public void Clear() => _items.Clear(); + + public void Apply(IEnumerable cellItems) + { + foreach (var chunk in cellItems) + { + var item = GoItem.From(chunk); + if (item.Kind == "func" && item.Names.Contains("main")) continue; // a cell that is a whole program isn't kept + _items.RemoveAll(existing => existing.Keys.Any(item.Keys.Contains)); + _items.Add(item); + } + } +} + +/// A cell made into a Go program, and the way back from the program's lines to the cell's. +internal sealed class GoCellProgram +{ + public required string Source { get; init; } + + /// For each line of the program (index 0 is line 1): the cell's line it is, or 0 for a line the cell didn't write. + public required IReadOnlyList CellLines { get; init; } + + /// The program's lines that give a variable back its value, and the variable each one gives. + public IReadOnlyDictionary RestoreLines { get; init; } = new Dictionary(); + + /// The cell's lines of the last statement when the program shows its value, else null. + public (int First, int Last)? ShownStatement { get; init; } + + /// Why the cell can't be made a program (it has its own func main and statements too); null when it can. + public string? Error { get; init; } + + public int? CellLineOf(int generatedLine) => generatedLine >= 1 && generatedLine <= CellLines.Count && CellLines[generatedLine - 1] > 0 ? CellLines[generatedLine - 1] : null; +} + +/// +/// Lays a cell out as a Go program: the imports it needs, every declaration the notebook has so far, then func main, which gives the +/// variables of earlier cells back their values, runs the cell's statements (in a block of their own, so x := 5 may make a variable +/// that an earlier cell made too) and hands the variables on to the next cell. The statements go in unchanged, so a compiler message names the +/// line and column the cell has. The last expression, if the cell ends with one, is shown, as a notebook does. +/// +internal static partial class GoCellProgramBuilder +{ + [GeneratedRegex(@"\bmain\.")] + private static partial Regex MainQualifier(); + + private sealed record Line(string Text, int CellLine, string? Restores = null); + + /// Every import the notebook has been given, plus the ones a cell may use without writing them: each is written only if the program uses it. + /// The variables earlier cells made, to give back. + /// Show the value of the last statement when it is an expression. + public static GoCellProgram Build(GoCell cell, IReadOnlyList earlierItems, IReadOnlyList imports, IReadOnlyList restore, bool showLast) + { + var cellKeys = cell.Items.SelectMany(i => i.Keys).ToHashSet(StringComparer.Ordinal); + var kept = earlierItems.Where(i => !i.Keys.Any(cellKeys.Contains)).ToList(); + var body = new List(); + var maskedCode = new StringBuilder(); + + void Items() + { + foreach (var item in kept) + { + foreach (var text in item.Text.Split('\n')) body.Add(new Line(text, 0)); + body.Add(new Line(string.Empty, 0)); + maskedCode.AppendLine(item.Masked); + } + + foreach (var item in cell.Items) + { + AddChunk(body, item); + body.Add(new Line(string.Empty, 0)); + maskedCode.AppendLine(item.Masked); + } + } + + Items(); + (int, int)? shown = null; + + if (cell.DefinesMain) + { + if (cell.Statements.Count > 0) + { + return new GoCellProgram { Source = string.Empty, CellLines = [], Error = "A cell that defines func main is a whole program: it can't also have statements outside the functions." }; + } + } + else + { + var packageVars = kept.Concat(cell.Items.Select(GoItem.From)).Where(i => i.Kind == "var").SelectMany(i => i.Names).ToHashSet(StringComparer.Ordinal); + var declaredHere = cell.Statements.SelectMany(s => s.Names).ToHashSet(StringComparer.Ordinal); + var varItemsHere = cell.Items.Where(i => i.ItemKind == "var").SelectMany(i => i.Names).ToHashSet(StringComparer.Ordinal); + + body.Add(new Line("func main() {", 0)); + var restored = new List(); + foreach (var variable in restore.Where(v => !varItemsHere.Contains(v.Name))) + { + if (packageVars.Contains(variable.Name)) + { + body.Add(new Line($"\tfry.RestoreInto(&{variable.Name}, {Quote(variable.Name)})", 0, variable.Name)); + } + else + { + body.Add(new Line($"\t{variable.Name} := fry.Restore[{MainQualifier().Replace(variable.Type, string.Empty)}]({Quote(variable.Name)})", 0, variable.Name)); + body.Add(new Line($"\t_ = {variable.Name}", 0, variable.Name)); + restored.Add(variable.Name); + } + } + + body.Add(new Line("\t{", 0)); + var last = cell.Statements.Count > 0 ? cell.Statements[^1] : null; + foreach (var statement in cell.Statements) + { + maskedCode.AppendLine(statement.Masked); + if (showLast && ReferenceEquals(statement, last) && GoCellSplitter.IsValueExpression(statement)) + { + body.Add(new Line("\tfry.ShowAll(", 0)); + var texts = statement.Text.Split('\n'); + var masks = statement.Masked.Split('\n'); + for (var i = 0; i < texts.Length; i++) + { + var text = texts[i]; + if (i == texts.Length - 1) + { + var end = GoCellSplitter.CodeEnd(masks[i]); + text = text[..end] + "," + text[end..]; + } + + body.Add(new Line(text, statement.StartLine + i)); + } + + body.Add(new Line("\t)", 0)); + shown = (statement.StartLine, statement.StartLine + statement.LineCount - 1); + } + else + { + AddChunk(body, statement); + } + } + + // What the cell made with := goes on to the next cell, from inside the block, where those variables are. + var persisted = new HashSet(StringComparer.Ordinal); + foreach (var name in declaredHere) + { + if (persisted.Add(name)) body.Add(new Line($"\t\tfry.Persist({Quote(name)}, {name})", 0)); + } + + body.Add(new Line("\t}", 0)); + + // The ones it didn't make again (it may have changed them) and the variables declared at the top of the program. + foreach (var name in restored.Where(r => !declaredHere.Contains(r)).Concat(packageVars)) + { + if (persisted.Add(name)) body.Add(new Line($"\tfry.Persist({Quote(name)}, {name})", 0)); + } + + body.Add(new Line("}", 0)); + } + + var generated = string.Join('\n', body.Where(l => l.CellLine == 0).Select(l => l.Text)); + var header = new List { "package main", string.Empty }; + var needed = Imports(imports, maskedCode.ToString() + "\n" + generated); + if (needed.Count > 0) + { + header.Add("import ("); + header.AddRange(needed.Select(i => "\t" + i)); + header.Add(")"); + header.Add(string.Empty); + } + + var source = new StringBuilder(); + var cellLines = new List(); + var restoreLines = new Dictionary(); + foreach (var text in header) + { + source.Append(text).Append('\n'); + cellLines.Add(0); + } + + foreach (var line in body) + { + source.Append(line.Text).Append('\n'); + cellLines.Add(line.CellLine); + if (line.Restores != null) restoreLines[cellLines.Count] = line.Restores; + } + + return new GoCellProgram { Source = source.ToString(), CellLines = cellLines, RestoreLines = restoreLines, ShownStatement = shown }; + } + + private static void AddChunk(List body, GoChunk chunk) + { + var texts = chunk.Text.Split('\n'); + for (var i = 0; i < texts.Length; i++) body.Add(new Line(texts[i], chunk.StartLine + i)); + } + + private static string Quote(string name) => "\"" + name + "\""; + + // Only the imports the program uses: Go refuses an import it doesn't use. + private static List Imports(IReadOnlyList candidates, string maskedProgram) + { + var needed = new List(); + foreach (var import in candidates.Distinct(StringComparer.Ordinal)) + { + var (alias, path) = Parse(import); + if (alias is "_" or ".") { needed.Add(import); continue; } + + var name = alias ?? PackageName(path); + if (Regex.IsMatch(maskedProgram, @"(? 1 && Regex.IsMatch(name, @"^v\d+$")) name = parts[^2]; + name = Regex.Replace(name, @"\.v\d+$", string.Empty); + if (name.StartsWith("go-", StringComparison.Ordinal)) name = name[3..]; + if (name.EndsWith("-go", StringComparison.Ordinal)) name = name[..^3]; + return name.Replace('-', '_').Replace('.', '_'); + } +} diff --git a/FrySharp.Core/Services/Languages/Go/GoCellSplitter.cs b/FrySharp.Core/Services/Languages/Go/GoCellSplitter.cs new file mode 100644 index 00000000..e4556b4d --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoCellSplitter.cs @@ -0,0 +1,325 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +internal enum GoChunkKind +{ + Item, + Statement +} + +/// One top-level piece of a cell: a declaration (a function, a type, a constant, a variable) or a statement, with where it is. +/// The lines as the cell has them. +/// The same lines with comments blanked and the inside of literals blanked: what is structure and nothing else. +/// The cell's line (from 1) it begins on. +internal sealed record GoChunk(GoChunkKind Kind, string Text, string Masked, int StartLine, int LineCount) +{ + /// "func", "method", "type", "const" or "var" for a declaration. + public string? ItemKind { get; init; } + + /// The names a declaration makes (or, for a statement, the variables := makes). + public IReadOnlyList Names { get; init; } = []; + + /// What replaces what: a later declaration with one of these names takes the place of this one. + public IReadOnlyList Keys { get; init; } = []; +} + +/// A cell, taken apart: what it imports, what it declares at the top of the program, and the statements that make up its main. +internal sealed record GoCell(IReadOnlyList Imports, IReadOnlyList Items, IReadOnlyList Statements) +{ + /// The cell is a whole program: it has its own func main. + public bool DefinesMain => Items.Any(i => i.ItemKind == "func" && i.Names.Contains("main")); +} + +/// +/// Takes a Go cell apart without a Go parser: comments and the inside of literals are blanked, and a line ends a piece when no +/// bracket is open and the line isn't continued (an operator, a comma or a dot at its end), which is how Go itself reads line ends. +/// +internal static partial class GoCellSplitter +{ + private static readonly HashSet NotValues = new(StringComparer.Ordinal) + { + "if", "for", "switch", "select", "go", "defer", "return", "break", "continue", "goto", "fallthrough", + "var", "const", "type", "func", "import", "package" + }; + + // Statements that print or end the program: their results aren't a value to show. + [GeneratedRegex(@"^(?:fmt\.(?:Fprint|Print)\w*|log\.\w+|fry\.\w+|println|print|panic|os\.Exit|time\.Sleep)\s*\(")] + private static partial Regex NoValueCall(); + + [GeneratedRegex(@"^func\s+([A-Za-z_]\w*)\s*[\[(]")] + private static partial Regex FunctionName(); + + [GeneratedRegex(@"^func\s*\(\s*(?:[A-Za-z_]\w*\s+)?\*?\s*([A-Za-z_]\w*)(?:\[[^\]]*\])?\s*\)\s*([A-Za-z_]\w*)\s*[\[(]")] + private static partial Regex MethodName(); + + [GeneratedRegex(@"^(?:type|const|var)\s+([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)")] + private static partial Regex SingleDeclaration(); + + [GeneratedRegex(@"^\s*([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)")] + private static partial Regex GroupMember(); + + [GeneratedRegex(@"^\s*([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*:=")] + private static partial Regex ShortDeclaration(); + + [GeneratedRegex(@"^\s*(?:(?[A-Za-z_]\w*|\.)\s+)?""(?[^""]+)""")] + private static partial Regex ImportSpec(); + + public static GoCell Split(string code) + { + var text = code.Replace("\r\n", "\n").Replace('\r', '\n'); + var lines = text.Split('\n'); + var masked = Mask(text, out var openAtLineEnd).Split('\n'); + + var imports = new List(); + var items = new List(); + var statements = new List(); + + var line = 0; + while (line < lines.Length) + { + if (masked[line].AsSpan().Trim().IsEmpty) + { + line++; + continue; + } + + var start = line; + var depth = 0; + while (true) + { + foreach (var c in masked[line]) + { + if (c is '(' or '{' or '[') depth++; + else if (c is ')' or '}' or ']') depth = Math.Max(0, depth - 1); + } + + var continues = openAtLineEnd[line] || depth > 0 || EndsExpecting(masked[line]); + line++; + if (!continues || line >= lines.Length) break; + } + + var chunkText = string.Join('\n', lines[start..line]); + var chunkMasked = string.Join('\n', masked[start..line]); + Classify(chunkText, chunkMasked, start + 1, line - start, imports, items, statements); + } + + return new GoCell(imports, items, statements); + } + + private static void Classify(string text, string masked, int startLine, int lineCount, List imports, List items, List statements) + { + var trimmed = masked.TrimStart(); + var word = FirstWord(trimmed); + + switch (word) + { + case "package": + return; + case "import": + foreach (var line in text.Split('\n')) + { + var spec = line.TrimStart(); + if (spec.StartsWith("import", StringComparison.Ordinal)) spec = spec["import".Length..]; + spec = spec.TrimStart('(', ' ', '\t'); + if (ImportSpec().Match(spec) is { Success: true } match) + { + imports.Add(match.Groups["alias"].Success ? $"{match.Groups["alias"].Value} \"{match.Groups["path"].Value}\"" : $"\"{match.Groups["path"].Value}\""); + } + } + + return; + case "func": + if (MethodName().Match(trimmed) is { Success: true } method) + { + var key = $"method:{method.Groups[1].Value}.{method.Groups[2].Value}"; + items.Add(new GoChunk(GoChunkKind.Item, text, masked, startLine, lineCount) { ItemKind = "method", Names = [method.Groups[2].Value], Keys = [key] }); + return; + } + + if (FunctionName().Match(trimmed) is { Success: true } function) + { + items.Add(new GoChunk(GoChunkKind.Item, text, masked, startLine, lineCount) + { + ItemKind = "func", + Names = [function.Groups[1].Value], + Keys = [$"name:{function.Groups[1].Value}"] + }); + return; + } + + break; // a func literal (func() {...}()) is a statement + case "type" or "const" or "var": + var names = DeclaredNames(trimmed, word); + if (names.Count > 0) + { + items.Add(new GoChunk(GoChunkKind.Item, text, masked, startLine, lineCount) + { + ItemKind = word, + Names = names.Where(n => n != "_").ToList(), + Keys = names.Where(n => n != "_").Select(n => $"name:{n}").ToList() + }); + return; + } + + break; + } + + var declared = ShortDeclaration().Match(trimmed) is { Success: true } shortDeclaration + ? shortDeclaration.Groups[1].Value.Split(',').Select(n => n.Trim()).Where(n => n != "_").Distinct().ToList() + : []; + statements.Add(new GoChunk(GoChunkKind.Statement, text, masked, startLine, lineCount) { Names = declared }); + } + + // type X ..., const a, b = ..., var x int; and the groups: type ( A struct{}; B int ), const ( ... ), var ( ... ) + private static List DeclaredNames(string masked, string word) + { + var rest = masked[word.Length..].TrimStart(); + if (!rest.StartsWith('(')) return SingleDeclaration().Match(masked) is { Success: true } single ? SplitNames(single.Groups[1].Value) : []; + + var names = new List(); + var depth = 0; + var first = true; + foreach (var line in masked.Split('\n')) + { + var depthAtStart = depth; + foreach (var c in line) + { + if (c is '(' or '{' or '[') depth++; + else if (c is ')' or '}' or ']') depth = Math.Max(0, depth - 1); + } + + if (first) + { + first = false; + // "var (" and the first member after it on the same line is rare enough to leave + continue; + } + + if (depthAtStart == 1 && GroupMember().Match(line) is { Success: true } member) names.AddRange(SplitNames(member.Groups[1].Value)); + } + + return names; + } + + private static List SplitNames(string names) => names.Split(',').Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); + + private static string FirstWord(string text) + { + var end = 0; + while (end < text.Length && (char.IsLetterOrDigit(text[end]) || text[end] == '_')) end++; + return text[..end]; + } + + // A line that stops in the middle: "x := a +", "f(a,", "obj." + private static bool EndsExpecting(string maskedLine) + { + var end = maskedLine.Length; + while (end > 0 && char.IsWhiteSpace(maskedLine[end - 1])) end--; + if (end == 0) return false; + var last = maskedLine[end - 1]; + if (last is '+' or '-' && end > 1 && maskedLine[end - 2] == last) return false; // x++ and x-- end a statement + return last is '+' or '-' or '*' or '/' or '%' or '&' or '|' or '^' or '<' or '>' or '=' or '!' or ',' or '.'; + } + + /// + /// Whether a statement is an expression whose value could be shown (x + 1, strconv.Itoa(5), a bare name), as against an + /// assignment, a control statement, or something that prints. A call that returns nothing looks like one: the build tells. + /// + public static bool IsValueExpression(GoChunk statement) + { + var masked = statement.Masked.Trim(); + if (masked.Length == 0) return false; + if (NotValues.Contains(FirstWord(masked))) return false; + if (NoValueCall().IsMatch(masked)) return false; + if (masked.EndsWith(':')) return false; // a label + + var depth = 0; + for (var i = 0; i < masked.Length; i++) + { + var c = masked[i]; + if (c is '(' or '{' or '[') depth++; + else if (c is ')' or '}' or ']') depth = Math.Max(0, depth - 1); + if (depth != 0) continue; + + var next = i + 1 < masked.Length ? masked[i + 1] : '\0'; + var previous = i > 0 ? masked[i - 1] : '\0'; + if (c == ':' && next == '=') return false; + if (c == '=' && next != '=' && (previous is not ('=' or '!' or '<' or '>' or ':') || (i > 1 && (masked.AsSpan(i - 2, 2) is "<<" or ">>" or "&^")))) return false; + if (c is '+' or '-' && next == c && masked[(i + 2)..].Trim().Length == 0) return false; + if (c == '<' && next == '-' && PreviousIsOperand(masked, i)) return false; // a send: ch <- v + } + + return true; + } + + private static bool PreviousIsOperand(string masked, int index) + { + var i = index - 1; + while (i >= 0 && char.IsWhiteSpace(masked[i])) i--; + return i >= 0 && (char.IsLetterOrDigit(masked[i]) || masked[i] is '_' or ')' or ']'); + } + + /// Where code ends on a line: its length without a trailing comment or spaces (the line is given masked, with its original text). + public static int CodeEnd(string maskedLine) + { + var end = maskedLine.Length; + while (end > 0 && char.IsWhiteSpace(maskedLine[end - 1])) end--; + return end; + } + + /// The text with every comment and the inside of every string, rune and raw string literal turned to spaces (line breaks stay). + public static string Mask(string text) => Mask(text, out _); + + private static string Mask(string text, out bool[] openAtLineEnd) + { + var result = new StringBuilder(text.Length); + var open = new List(); + var state = 0; // 0 code, 1 line comment, 2 block comment, 3 string, 4 raw string, 5 rune + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + var next = i + 1 < text.Length ? text[i + 1] : '\0'; + if (c == '\n') + { + open.Add(state is 2 or 4); + if (state is 1 or 3 or 5) state = 0; + result.Append('\n'); + continue; + } + + switch (state) + { + case 0: + if (c == '/' && next == '/') { state = 1; result.Append(" "); i++; } + else if (c == '/' && next == '*') { state = 2; result.Append(" "); i++; } + else if (c == '"') { state = 3; result.Append(c); } + else if (c == '`') { state = 4; result.Append(c); } + else if (c == '\'') { state = 5; result.Append(c); } + else result.Append(c); + break; + case 1: + result.Append(' '); + break; + case 2: + if (c == '*' && next == '/') { state = 0; result.Append(" "); i++; } + else result.Append(' '); + break; + case 3 or 5: + if (c == '\\' && next != '\n' && next != '\0') { result.Append(" "); i++; } + else if ((state == 3 && c == '"') || (state == 5 && c == '\'')) { state = 0; result.Append(c); } + else result.Append(' '); + break; + case 4: + if (c == '`') { state = 0; result.Append(c); } + else result.Append(' '); + break; + } + } + + open.Add(state is 2 or 4); + openAtLineEnd = open.ToArray(); + return result.ToString(); + } +} diff --git a/Services/Languages/Go/GoCompilerDiagnosticParser.cs b/FrySharp.Core/Services/Languages/Go/GoCompilerDiagnosticParser.cs similarity index 96% rename from Services/Languages/Go/GoCompilerDiagnosticParser.cs rename to FrySharp.Core/Services/Languages/Go/GoCompilerDiagnosticParser.cs index fe050c0f..52b61c2a 100644 --- a/Services/Languages/Go/GoCompilerDiagnosticParser.cs +++ b/FrySharp.Core/Services/Languages/Go/GoCompilerDiagnosticParser.cs @@ -166,6 +166,9 @@ private static List ParseRuntimePanics(string[] lines, string so var isCurrentFile = string.Equals(file, sourceFilePath, StringComparison.OrdinalIgnoreCase) || string.Equals(Path.GetFileName(file), sourceBaseName, StringComparison.OrdinalIgnoreCase); + // The runtime's own frames come first (panic.go, the failing slice index); the place in the program is the first one in its file. + if (!isCurrentFile) continue; + diagnostics.Add(new DiagnosticItem { Id = "PANIC", @@ -176,9 +179,7 @@ private static List ParseRuntimePanics(string[] lines, string so EndLine = lineNum, EndColumn = 2 }); - - // Found top-most frame in user's program - if (isCurrentFile) break; + break; } } } diff --git a/FrySharp.Core/Services/Languages/Go/GoConsoleText.cs b/FrySharp.Core/Services/Languages/Go/GoConsoleText.cs new file mode 100644 index 00000000..94682cbf --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoConsoleText.cs @@ -0,0 +1,32 @@ +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +/// +/// What the Go toolchain and a panic say, in the cell's own terms: they name the generated program's file and its lines +/// (./main.go:12:5, /tmp/.../main.go:15 +0x1d), and the user wrote a cell, so the file becomes cell and the line the cell's own. +/// +internal static partial class GoConsoleText +{ + // The generated program's file, with the folder it is in (or ./ for the one the build runs in), and a line and a column. + [GeneratedRegex(@"(?:\./|[^\s:""]*[\\/])?main\.go(?::(?\d+)(?::(?\d+))?)?")] + private static partial Regex Position(); + + [GeneratedRegex(@"\A(?:# [^\n]*\n)+")] + private static partial Regex PackageHeader(); + + public static string Positions(string text, GoCellProgram program) + { + if (text.Length == 0 || !text.Contains(GoNotebookKernel.ProgramFileName, StringComparison.Ordinal)) return text; + return Position().Replace(text, match => + { + if (!match.Groups["line"].Success) return "cell"; + var line = program.CellLineOf(int.Parse(match.Groups["line"].Value)); + if (line == null) return "an earlier cell"; + return match.Groups["column"].Success ? $"cell:{line}:{match.Groups["column"].Value}" : $"cell:{line}"; + }); + } + + /// The compiler's messages for a program that didn't build: without the "# package" line, and at the cell's positions. + public static string Build(string text, GoCellProgram program) => Positions(PackageHeader().Replace(text.Replace("\r\n", "\n"), string.Empty), program).Trim(); +} diff --git a/FrySharp.Core/Services/Languages/Go/GoDisplayRuntime.cs b/FrySharp.Core/Services/Languages/Go/GoDisplayRuntime.cs new file mode 100644 index 00000000..c53282e5 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoDisplayRuntime.cs @@ -0,0 +1,130 @@ +using System.Reflection; +using System.Text; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +/// +/// Provides display and inspection runtime support for Go scripts and notebooks in C# Code Studio, +/// emitting rich display MIME bundles for interactive visuals, charts, HTML, images, and tables. +/// +public static class GoDisplayRuntime +{ + private const string ResourcePrefix = "GoRuntime."; + + private static readonly SemaphoreSlim ModuleGate = new(1, 1); + + /// The files of the fry package (fry.go for the charts and tables, values.go for showing, keeping and sharing values), by name. + private static IReadOnlyList<(string Name, string Content)> RuntimeFiles() + { + var assembly = typeof(GoDisplayRuntime).Assembly; + var files = new List<(string, string)>(); + foreach (var resource in assembly.GetManifestResourceNames().Where(n => n.StartsWith(ResourcePrefix, StringComparison.Ordinal)).Order(StringComparer.Ordinal)) + { + using var stream = assembly.GetManifestResourceStream(resource)!; + using var reader = new StreamReader(stream, Encoding.UTF8); + files.Add((resource[ResourcePrefix.Length..], reader.ReadToEnd())); + } + + if (files.Count > 0) return files; + + // Fallback to the local source files if they are not compiled into the assembly + var folder = Path.Combine(AppContext.BaseDirectory, "Services", "Languages", "Go", "Runtime"); + return Directory.Exists(folder) ? Directory.GetFiles(folder, "*.go").Order(StringComparer.Ordinal).Select(f => (Path.GetFileName(f), File.ReadAllText(f))).ToList() : []; + } + + private static async Task WriteIfChangedAsync(string path, string content, CancellationToken ct) + { + if (File.Exists(path) && await File.ReadAllTextAsync(path, ct).ConfigureAwait(false) == content) return; + await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false); + } + + /// + /// The fry package as a module of its own, fry under , that every notebook cell builds against + /// (replace fry => that folder): written once and kept as it is while the engine's files don't change, so the build cache keeps the compiled package. + /// + public static async Task EnsureSharedModuleAsync(string goRoot, CancellationToken ct = default) + { + var folder = Path.Combine(goRoot, "fry"); + await ModuleGate.WaitAsync(ct).ConfigureAwait(false); + try + { + Directory.CreateDirectory(folder); + var files = RuntimeFiles(); + await WriteIfChangedAsync(Path.Combine(folder, "go.mod"), "module fry\n\ngo 1.21\n", ct).ConfigureAwait(false); + foreach (var (name, content) in files) await WriteIfChangedAsync(Path.Combine(folder, name), content, ct).ConfigureAwait(false); + foreach (var stale in Directory.GetFiles(folder, "*.go").Where(f => files.All(file => file.Name != Path.GetFileName(f)))) File.Delete(stale); + } + finally + { + ModuleGate.Release(); + } + + return folder; + } + + public static async Task EnsureDisplayPackageAsync(string buildDir, CancellationToken ct = default) + { + var files = RuntimeFiles(); + + // 1. Module package: buildDir/fry/*.go + go.mod + var fryDir = Path.Combine(buildDir, "fry"); + Directory.CreateDirectory(fryDir); + foreach (var (name, content) in files) await WriteIfChangedAsync(Path.Combine(fryDir, name), content, ct).ConfigureAwait(false); + var fryMod = Path.Combine(fryDir, "go.mod"); + if (!File.Exists(fryMod)) + { + await File.WriteAllTextAsync(fryMod, "module fry\n\ngo 1.21\n", ct).ConfigureAwait(false); + } + + // 2. GOPATH package: buildDir/src/fry/fry.go + var gopathDir = Path.Combine(buildDir, "src", "fry"); + Directory.CreateDirectory(gopathDir); + foreach (var (name, content) in files) await WriteIfChangedAsync(Path.Combine(gopathDir, name), content, ct).ConfigureAwait(false); + + // 3. Local root go.mod: if cell.go or main.go runs directly in buildDir + var rootMod = Path.Combine(buildDir, "go.mod"); + if (!File.Exists(rootMod)) + { + await File.WriteAllTextAsync(rootMod, "module cell\n\ngo 1.20\n\nrequire fry v0.0.0\nreplace fry => ./fry\n", ct).ConfigureAwait(false); + } + + // 4. Backwards compatibility: buildDir/fry/display/display.go + var displayDir = Path.Combine(buildDir, "fry", "display"); + Directory.CreateDirectory(displayDir); + await File.WriteAllTextAsync(Path.Combine(displayDir, "display.go"), """ + package display + + import "fry" + + func Html(c string) { fry.Html(c) } + func Image(p any) { fry.Image(p) } + func Json(j string) { fry.Json(j) } + func Dump(v any) { fry.Dump(v) } + """, ct).ConfigureAwait(false); + + // 5. Package fry_display in GOPATH + var fryDisplayGopath = Path.Combine(buildDir, "src", "fry_display"); + Directory.CreateDirectory(fryDisplayGopath); + await File.WriteAllTextAsync(Path.Combine(fryDisplayGopath, "display.go"), """ + package fry_display + + import "fry" + + type ChartOptions struct { + Type string + Title string + X []float64 + Y []float64 + } + + func Chart(opts ChartOptions) *fry.DisplayHandle { + if len(opts.Y) > 0 { + return fry.Chart(opts.Y, fry.Title(opts.Title)) + } + return fry.Chart(opts.X, fry.Title(opts.Title)) + } + """, ct).ConfigureAwait(false); + + return fryDir; + } +} diff --git a/FrySharp.Core/Services/Languages/Go/GoEngine.cs b/FrySharp.Core/Services/Languages/Go/GoEngine.cs new file mode 100644 index 00000000..95b2fdc5 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoEngine.cs @@ -0,0 +1,73 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +/// +/// Go, compiled with the official Go toolchain and run natively for source files: a two-phase build and run, the compiler's messages +/// in Problems, interactive standard input, and modules added with %go get. Notebook cells are built as small programs and run, with +/// what earlier cells declared and made carried over (see ). +/// +public sealed class GoEngine : LanguageEngine +{ + private static readonly string[] GoAliases = ["go", "golang"]; + private static readonly string[] GoExtensions = [".go"]; + + public GoEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + GoRoot = Path.Combine(services.BaseDirectory, "go"); + var toolchain = new GoToolchainProvider(services.Host, services.Processes, services.ToolchainSettings, GoRoot); + GoToolchain = toolchain; + var packages = new GoPackageManager(GoRoot, toolchain, services.Processes, services.Host); + Packages = packages; + ScriptRunner = new GoBuildAndRunScriptRunner(services.Host, GoRoot); + NotebookKernels = new DelegateKernelFactory(context => + new GoNotebookKernel(toolchain, services.Processes, services.Host, GoRoot, packages.NotebookDependencies, context)); + } + + /// Where the engine keeps what it builds: the shared build cache, the fry module, the notebooks' cells and the modules they added. + public string GoRoot { get; } + + public GoToolchainProvider GoToolchain { get; } + + public override string Id => LanguageIds.Go; + public override string DisplayName => "Go"; + public override JupyterLanguageInfo Jupyter { get; } = new("go", "Go", "go", "go", ".go", "text/x-gosrc"); + public override string ShortName => "GO"; + public override IReadOnlyList Aliases => GoAliases; + public override IReadOnlyList FileExtensions => GoExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + public override bool IsCompiled => true; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | + LanguageCapabilities.QuickInfo | + LanguageCapabilities.Completion | + LanguageCapabilities.Debugging | + LanguageCapabilities.Breakpoints | + LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | + LanguageCapabilities.Packages; + + public override string IconKind => "LanguageGo"; + public override string AccentHex => "#00ADD8"; + public override string LineCommentPrefix => "//"; + public override string RuntimeDescription => "Go (gc / go toolchain)"; + + public override string NewFileTemplate => + "// Runs with the Go toolchain installed on this computer (F5).\n" + + "package main\n\n" + + "import \"fmt\"\n\n" + + "func main() {\n" + + " fmt.Println(\"Hello from Go!\")\n" + + "}\n"; + + public override IToolchainProvider Toolchain => GoToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new GoCompilerDiagnosticParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/Go/GoGuidance.cs b/FrySharp.Core/Services/Languages/Go/GoGuidance.cs similarity index 100% rename from Services/Languages/Go/GoGuidance.cs rename to FrySharp.Core/Services/Languages/Go/GoGuidance.cs diff --git a/FrySharp.Core/Services/Languages/Go/GoNotebookDependencies.cs b/FrySharp.Core/Services/Languages/Go/GoNotebookDependencies.cs new file mode 100644 index 00000000..541b8fe7 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoNotebookDependencies.cs @@ -0,0 +1,67 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +/// +/// The modules notebook cells have asked for with %go get, kept as a module of their own (go.mod and go.sum, which go get writes) so +/// every Go notebook build sees them. A cell's directive lines are blanked before the kernel gets the code, and the package manager is only told +/// the toolchain (not which notebook), so the list can't live in either place; like pip install, an added module stays added. +/// +public sealed partial class GoNotebookDependencies(string goRoot) +{ + // "github.com/google/uuid v1.6.0" and "golang.org/x/text v0.3.0 // indirect", inside require ( ) or after "require" + [GeneratedRegex(@"^\s*(?:require\s+)?(?[^\s()/][^\s()]*)\s+(?v[0-9][^\s]*)(?\s*//.*)?$")] + private static partial Regex Requirement(); + + public string Directory => Path.Combine(goRoot, "notebook", "deps"); + + public string GoModPath => Path.Combine(Directory, "go.mod"); + + public string GoSumPath => Path.Combine(Directory, "go.sum"); + + /// The requirements go get recorded, each as path version (with // indirect when it has it). + public IReadOnlyList Requirements() + { + var found = new List(); + try + { + if (!File.Exists(GoModPath)) return found; + foreach (var line in File.ReadAllLines(GoModPath)) + { + if (line.TrimStart().StartsWith("module", StringComparison.Ordinal) || line.TrimStart().StartsWith("go ", StringComparison.Ordinal)) continue; + if (Requirement().Match(line) is { Success: true } match) + { + found.Add($"{match.Groups["path"].Value} {match.Groups["version"].Value}{match.Groups["rest"].Value}"); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // An unreadable list is an empty one. + } + + return found; + } + + public string? GoSum() + { + try + { + return File.Exists(GoSumPath) ? File.ReadAllText(GoSumPath) : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// The module the downloads are made in, made if it isn't there (a go.mod for the toolchain's language version, and a file to be a package). + public void EnsureModule(Version goVersion) + { + System.IO.Directory.CreateDirectory(Directory); + if (!File.Exists(GoModPath)) File.WriteAllText(GoModPath, $"module fry_deps\n\ngo {goVersion.Major}.{goVersion.Minor}\n", new UTF8Encoding(false)); + var source = Path.Combine(Directory, "deps.go"); + if (!File.Exists(source)) File.WriteAllText(source, "package deps\n", new UTF8Encoding(false)); + } +} diff --git a/FrySharp.Core/Services/Languages/Go/GoNotebookKernel.cs b/FrySharp.Core/Services/Languages/Go/GoNotebookKernel.cs new file mode 100644 index 00000000..cee4fe43 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoNotebookKernel.cs @@ -0,0 +1,609 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; +using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +/// +/// Notebook kernel for Go. Each cell is built with the Go toolchain as a small program and run: the functions, types, constants and +/// variables earlier cells declared are written in front of it, the variables of plain data they made are given back their values, and +/// the ones this cell makes (x := ...) are handed on as JSON, with their types, for the next one (see ). +/// The last expression of a cell is shown, errors are on the cell's own lines, and values move to and from other languages as JSON. +/// +public sealed partial class GoNotebookKernel : INotebookKernel +{ + internal const string ProgramFileName = "main.go"; + + // Packages a cell may use without importing them: each is written into the program only when the cell uses it. + private static readonly string[] DefaultImports = + [ + "\"fmt\"", "\"strings\"", "\"time\"", "\"os\"", "\"encoding/json\"", "\"math\"", "\"sort\"", "\"strconv\"", "\"errors\"", "\"sync\"", "\"fry\"" + ]; + + [GeneratedRegex(@"\bfry\s*\.\s*(?:Input|Password)\b")] + private static partial Regex AsksForInput(); + + [GeneratedRegex(@"undefined: (?[A-Za-z_]\w*)")] + private static partial Regex UndefinedName(); + + [GeneratedRegex(@"no required module provides package (?[^\s;:]+)")] + private static partial Regex MissingModule(); + + private readonly GoToolchainProvider _toolchain; + private readonly IProcessLauncher _processes; + private readonly IHostEnvironment _host; + private readonly string _goRoot; + private readonly GoNotebookDependencies _dependencies; + private readonly KernelCreationContext _context; + private readonly string _moduleDirectory; + private readonly object _stateLock = new(); + + private readonly GoItemStore _items = new(); + private readonly List _imports = []; + private readonly Dictionary _variables = new(StringComparer.Ordinal); + private readonly Dictionary _unkept = new(StringComparer.Ordinal); + private readonly Dictionary _shared = new(StringComparer.Ordinal); + private int _executionCount; + private bool _isDisposed; + + public GoNotebookKernel( + GoToolchainProvider toolchain, + IProcessLauncher processes, + IHostEnvironment host, + string goRoot, + GoNotebookDependencies dependencies, + KernelCreationContext context) + { + _toolchain = toolchain ?? throw new ArgumentNullException(nameof(toolchain)); + _processes = processes ?? throw new ArgumentNullException(nameof(processes)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + _goRoot = goRoot ?? throw new ArgumentNullException(nameof(goRoot)); + _dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _moduleDirectory = Path.Combine(goRoot, "notebook", "cells", Guid.NewGuid().ToString("N")[..12]); + } + + public string LanguageId => LanguageIds.Go; + public string DisplayName => "Go (gc toolchain)"; + public bool CanForceStop => true; + + public bool IsSessionActive + { + get + { + lock (_stateLock) return _executionCount > 0 || _variables.Count > 0 || _items.Items.Count > 0; + } + } + + private sealed record Build(GoCellProgram Program, string BinaryPath, IReadOnlyList Dropped); + + public async Task ExecuteAsync(KernelExecutionRequest request, CancellationToken ct) + { + var clock = Stopwatch.StartNew(); + lock (_stateLock) _executionCount++; + + var workingFolder = _context.WorkingDirectory(); + if (string.IsNullOrWhiteSpace(workingFolder) || !Directory.Exists(workingFolder)) workingFolder = _host.HomeDirectory; + + var resolution = await _toolchain.ResolveAsync(new ToolchainQuery(workingFolder, _context.WorkspaceRoot?.Invoke()), ct).ConfigureAwait(false); + if (!resolution.IsFound || resolution.Toolchain == null) + { + var guidance = resolution.Missing ?? GoGuidance.NotInstalled(_host); + var text = guidance.ToText(); + request.OnConsole?.Invoke(text + "\n"); + return new KernelExecutionResult { Success = false, ErrorMessage = guidance.Summary, ConsoleOutput = text, Elapsed = clock.Elapsed }; + } + + var cell = GoCellSplitter.Split(request.Code); + try + { + var fryFolder = await GoDisplayRuntime.EnsureSharedModuleAsync(_goRoot, ct).ConfigureAwait(false); + Directory.CreateDirectory(_moduleDirectory); + WriteModule(fryFolder, resolution.Toolchain.Version); + + var outcome = await BuildAsync(cell, resolution.Toolchain, request, clock, ct).ConfigureAwait(false); + if (outcome.Failure != null) + { + if (!outcome.Failure.WasCancelled && outcome.Failure.ConsoleOutput.Length > 0) request.OnConsole?.Invoke(outcome.Failure.ConsoleOutput + "\n"); + return outcome.Failure; + } + + return await RunProgramAsync(request, cell, outcome.Build!, workingFolder!, clock, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return Cancelled(clock); + } + catch (ProcessStartException ex) + { + request.OnConsole?.Invoke(ex.Message + "\n"); + return Failure(ex.Message, ex.Message, clock); + } + } + + // The module the cells are built in: fry from the shared folder, and the modules %go get added. + private void WriteModule(string fryFolder, Version goVersion) + { + var module = new StringBuilder(); + module.Append("module cell\n\n").Append($"go {goVersion.Major}.{goVersion.Minor}\n\n"); + module.Append("require (\n\tfry v0.0.0\n"); + foreach (var requirement in _dependencies.Requirements()) module.Append('\t').Append(requirement).Append('\n'); + module.Append(")\n\n").Append($"replace fry => \"{fryFolder.Replace('\\', '/')}\"\n"); + + WriteIfChanged(Path.Combine(_moduleDirectory, "go.mod"), module.ToString()); + if (_dependencies.GoSum() is { } sums) WriteIfChanged(Path.Combine(_moduleDirectory, "go.sum"), sums); + } + + private static void WriteIfChanged(string path, string content) + { + if (File.Exists(path) && File.ReadAllText(path) == content) return; + File.WriteAllText(path, content, new UTF8Encoding(false)); + } + + private Dictionary ToolEnvironment() => new(StringComparer.Ordinal) + { + // One build cache for every cell and notebook: the standard library and the fry package are built once. + ["GOCACHE"] = Path.Combine(_goRoot, "cache"), + ["GOTOOLCHAIN"] = "local", + ["GOWORK"] = "off", + ["GOFLAGS"] = "-mod=mod", + ["GO111MODULE"] = "on" + }; + + private sealed record BuildOutcome(Build? Build, KernelExecutionResult? Failure); + + private async Task BuildAsync(GoCell cell, ToolchainInfo toolchain, KernelExecutionRequest request, Stopwatch clock, CancellationToken ct) + { + var mainPath = Path.Combine(_moduleDirectory, ProgramFileName); + var binaryPath = Path.Combine(_moduleDirectory, _host.IsWindows ? "cell.exe" : "cell"); + + var imports = new List(); + lock (_stateLock) + { + foreach (var import in cell.Imports) if (!_imports.Contains(import)) _imports.Add(import); + imports.AddRange(_imports); + } + + foreach (var import in DefaultImports) if (!imports.Any(i => i.EndsWith(import, StringComparison.Ordinal))) imports.Add(import); + + var showLast = true; + var dropped = new HashSet(StringComparer.Ordinal); + for (var attempt = 0; attempt < 4; attempt++) + { + GoItem[] items; + GoVariable[] variables; + lock (_stateLock) + { + items = [.. _items.Items]; + variables = _variables.Values.Where(v => !dropped.Contains(v.Name)).ToArray(); + } + + var program = GoCellProgramBuilder.Build(cell, items, imports, variables, showLast); + if (program.Error != null) return new BuildOutcome(null, Failure(program.Error, program.Error, clock)); + + await File.WriteAllTextAsync(mainPath, program.Source, new UTF8Encoding(false), ct).ConfigureAwait(false); + var output = new StringBuilder(); + int exitCode; + using (var process = _processes.Start(new ProcessStartSpec + { + FileName = toolchain.ExecutablePath, + Arguments = ["build", "-o", binaryPath, "."], + WorkingDirectory = _moduleDirectory, + Environment = ToolEnvironment(), + MergeStandardError = true + }, text => { lock (output) output.Append(text); }, text => { lock (output) output.Append(text); })) + { + // No limit of the kernel's own: how long a cell may take is the studio's ExecutionTimeoutSeconds setting, which arrives as the cancellation token. + exitCode = await process.WaitForExitOrKillAsync(ct).ConfigureAwait(false); + } + + string buildText; + lock (output) buildText = output.ToString(); + if (exitCode == 0) + { + lock (_stateLock) + { + foreach (var name in dropped) + { + _variables.Remove(name); + _unkept[name] = "its value couldn't be given back to the cell that follows"; + } + + _items.Apply(cell.Items); + } + + return new BuildOutcome(new Build(program, binaryPath, [.. dropped]), null); + } + + var parsed = new GoCompilerDiagnosticParser().Parse(buildText, mainPath); + var errors = parsed.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + + // The cell's last expression couldn't be shown (it has no value: a call that returns nothing): run it as it is. + if (showLast && program.ShownStatement is { } shown && errors.Any(d => program.CellLineOf(d.Line) is { } line && line >= shown.First && line <= shown.Last)) + { + showLast = false; + continue; + } + + // A variable an earlier cell made that can't be given back (its type isn't one this cell can write): the cell goes on without it. + var unrestorable = errors.Select(d => program.RestoreLines.GetValueOrDefault(d.Line)).Where(n => n != null && !dropped.Contains(n)).Select(n => n!).Distinct().ToList(); + if (unrestorable.Count > 0) + { + dropped.UnionWith(unrestorable); + continue; + } + + return new BuildOutcome(null, BuildFailure(program, buildText, parsed, clock)); + } + + return new BuildOutcome(null, Failure("The cell didn't build.", "The cell didn't build.", clock)); + } + + private KernelExecutionResult BuildFailure(GoCellProgram program, string output, DiagnosticParseResult parsed, Stopwatch clock) + { + var shown = GoConsoleText.Build(output, program) + UnkeptHint(output) + MissingModuleHint(output); + var diagnostics = MapToCell(parsed.Diagnostics, program); + var summary = diagnostics.FirstOrDefault(d => d.Severity == DiagnosticSeverity.Error)?.Message + ?? shown.Split('\n').FirstOrDefault(l => l.Length > 0) + ?? "The build failed"; + return new KernelExecutionResult + { + Success = false, + ErrorMessage = summary, + ConsoleOutput = shown, + Diagnostics = diagnostics, + MissingDependency = parsed.MissingDependency, + Elapsed = clock.Elapsed + }; + } + + // "undefined: x" about a variable an earlier cell made but the notebook couldn't keep says why it's gone. + private string UnkeptHint(string output) + { + List<(string Name, string Why)> found; + lock (_stateLock) + { + found = UndefinedName().Matches(output).Select(m => m.Groups["name"].Value).Distinct().Where(_unkept.ContainsKey).Select(n => (n, _unkept[n])).ToList(); + } + + return string.Concat(found.Select(f => $"\nnote: {f.Name} was made in an earlier cell, but the notebook couldn't keep it: {f.Why}. Make it again in this cell, or share it as data with fry.Share(\"{f.Name}\", {f.Name}).")); + } + + private static string MissingModuleHint(string output) => + MissingModule().Match(output) is { Success: true } m ? $"\nnote: add the module with %go get {m.Groups["package"].Value}" : string.Empty; + + // Puts a message on the line of the cell it is about; one from the scaffolding or an earlier cell's code has no line here. + private static IReadOnlyList MapToCell(IReadOnlyList diagnostics, GoCellProgram program) + { + var mapped = new List(diagnostics.Count); + foreach (var d in diagnostics) + { + var line = program.CellLineOf(d.Line); + var endLine = program.CellLineOf(d.EndLine); + mapped.Add(new DiagnosticItem + { + Id = d.Id, + Severity = d.Severity, + Message = line == null ? "In code from an earlier cell: " + d.Message : d.Message, + Line = line ?? 1, + Column = line == null ? 1 : d.Column, + EndLine = endLine ?? line ?? 1, + EndColumn = line == null ? 1 : d.EndColumn + }); + } + + return mapped; + } + + private async Task RunProgramAsync(KernelExecutionRequest request, GoCell cell, Build build, string workingFolder, Stopwatch clock, CancellationToken ct) + { + // What the program printed as it printed it (a panic names the generated file: the diagnostics are read from this), and as the cell is shown it. + var console = new StringBuilder(); + var shownConsole = new StringBuilder(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var visuals = new ExternalVisualSession(); + var processor = new ExternalOutputProcessor( + onConsoleText: text => + { + var shown = GoConsoleText.Positions(text, build.Program); + lock (console) + { + console.Append(text); + shownConsole.Append(shown); + } + + request.OnConsole?.Invoke(shown); + }, + onRichOutput: bundle => request.OnRichOutput?.Invoke(bundle), + onShare: (name, json) => + { + lock (_stateLock) _shared[name] = json; + }, + visuals: visuals.Visuals, + onInputRequest: (prompt, password) => _ = AnswerInputAsync(started.Task, request, prompt, password, ct)); + + // The variables the cells before gave, and the ones this cell hands on, go through two files. + var varsIn = Path.Combine(_moduleDirectory, "variables-in.jsonl"); + var varsOut = Path.Combine(_moduleDirectory, "variables-out.jsonl"); + WriteVariablesIn(varsIn, build.Dropped); + TryDelete(varsOut); + + var environment = new Dictionary(StringComparer.Ordinal) + { + ["FRY_VARS_IN"] = varsIn, + ["FRY_VARS_OUT"] = varsOut, + ["FRY_INPUT_REQUESTS"] = "1" + }; + + IManagedProcess? running = null; + try + { + running = _processes.Start(visuals.Apply(new ProcessStartSpec + { + FileName = build.BinaryPath, + Arguments = Array.Empty(), + WorkingDirectory = workingFolder, + Environment = environment, + // fmt.Println and println in one cell read in the order they ran. + MergeStandardError = true + }), processor.ProcessChunk, processor.ProcessChunk); + started.SetResult(running); + + // A plain read of os.Stdin can't ask anyone for a line, so a program that does only that sees the end of its input rather than + // waiting for good; one that calls fry.Input is asked for each line as it comes. + if (!(request.OnInputRequest != null && AsksForInput().IsMatch(build.Program.Source))) running.CloseInput(); + + var exitCode = await running.WaitForExitOrKillAsync(ct).ConfigureAwait(false); + + ct.ThrowIfCancellationRequested(); + processor.Flush(); + string consoleText, rawConsole; + lock (console) + { + consoleText = shownConsole.ToString(); + rawConsole = console.ToString(); + } + + if (exitCode == 0) + { + var kept = KeepVariables(varsOut, build.Dropped); + if (kept.Length > 0) + { + request.OnConsole?.Invoke(kept); + consoleText += kept; + } + + return new KernelExecutionResult { Success = true, ConsoleOutput = consoleText, Elapsed = clock.Elapsed }; + } + + var parsed = new GoCompilerDiagnosticParser().Parse(rawConsole, Path.Combine(_moduleDirectory, ProgramFileName)); + var diagnostics = MapToCell(parsed.Diagnostics, build.Program); + return new KernelExecutionResult + { + Success = false, + ConsoleOutput = consoleText, + ErrorMessage = diagnostics.FirstOrDefault(d => d.Id == "PANIC")?.Message ?? $"The program exited with code {exitCode}", + Diagnostics = diagnostics, + Elapsed = clock.Elapsed + }; + } + catch (OperationCanceledException) + { + return Cancelled(clock); + } + finally + { + running?.Dispose(); + } + } + + // The program asked for a line: the notebook's answer is written to it, and no answer (cancelled, or nobody to ask) ends its input. + private static async Task AnswerInputAsync(Task started, KernelExecutionRequest request, string prompt, bool password, CancellationToken ct) + { + IManagedProcess process; + try + { + process = await started.ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return; + } + + string? answer = null; + try + { + if (request.OnInputRequest is { } ask) answer = await ask(prompt, password, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException) + { + // Stopped, or the host can't ask: the same as no answer. + } + + if (answer == null) process.CloseInput(); + else await process.WriteInputAsync(answer + "\n", CancellationToken.None).ConfigureAwait(false); + } + + private void WriteVariablesIn(string path, IReadOnlyCollection dropped) + { + GoVariable[] variables; + lock (_stateLock) variables = _variables.Values.Where(v => !dropped.Contains(v.Name)).ToArray(); + var lines = variables.Select(v => JsonSerializer.Serialize(new { name = v.Name, json = JsonDocument.Parse(v.Json).RootElement })); + File.WriteAllText(path, string.Join('\n', lines) + "\n", new UTF8Encoding(false)); + } + + // The cell ran to its end: the variables it handed on replace the ones of the same names from before. Returns a note about any that + // couldn't be kept and that the cell might have expected to be, or an empty string. + private string KeepVariables(string varsOut, IReadOnlyCollection dropped) + { + var notes = new StringBuilder(); + try + { + lock (_stateLock) + { + foreach (var line in File.Exists(varsOut) ? File.ReadAllLines(varsOut) : []) + { + if (string.IsNullOrWhiteSpace(line)) continue; + using var entry = JsonDocument.Parse(line); + var root = entry.RootElement; + var name = root.GetProperty("name").GetString() ?? string.Empty; + if (root.TryGetProperty("skipped", out var skipped)) + { + var wasKept = _variables.Remove(name); + var why = skipped.GetString() ?? "it can't be saved"; + var quiet = root.TryGetProperty("quiet", out var q) && q.ValueKind == JsonValueKind.True; + var isNew = _unkept.TryAdd(name, why); + _unkept[name] = why; + if (!quiet && (wasKept || isNew)) notes.Append($"note: {name} isn't kept for later cells: {why}.\n"); + } + else + { + _variables[name] = new GoVariable(name, root.GetProperty("type").GetString() ?? "any", root.GetProperty("json").GetRawText()); + _unkept.Remove(name); + } + } + } + } + catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException) + { + // A file that can't be read leaves the variables as they were before this cell. + } + + return notes.ToString(); + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + } + + private static KernelExecutionResult Failure(string message, string console, Stopwatch clock) => + new() { Success = false, ErrorMessage = message, ConsoleOutput = console, Elapsed = clock.Elapsed }; + + private static KernelExecutionResult Cancelled(Stopwatch clock) => new() + { + WasCancelled = true, + ErrorMessage = "Cell execution was cancelled.", + Elapsed = clock.Elapsed + }; + + public Task> GetVariablesAsync(CancellationToken ct) + { + var list = new List(); + lock (_stateLock) + { + foreach (var variable in _variables.Values) + { + var type = variable.Type.Replace("main.", string.Empty, StringComparison.Ordinal); + list.Add(new NotebookVariableInfo + { + Name = variable.Name, + TypeName = type, + ValueDisplay = variable.Json.Length > 120 ? variable.Json[..120] + "…" : variable.Json, + Kind = "Variable", + Kernel = "Go" + }); + } + + foreach (var item in _items.Items.Where(i => i.Kind != "var" && i.Kind != "const")) + { + foreach (var name in item.Names) + { + list.Add(new NotebookVariableInfo + { + Name = name, + TypeName = item.Kind == "type" ? "type" : "func", + ValueDisplay = item.Text.Split('\n')[0].TrimEnd('{', ' '), + Kind = item.Kind == "type" ? "Type" : "Function", + Kernel = "Go" + }); + } + } + } + + return Task.FromResult>(list); + } + + public Task GetValueJsonAsync(string name, CancellationToken ct) + { + lock (_stateLock) + { + if (_variables.TryGetValue(name, out var variable)) return Task.FromResult(variable.Json); + if (_shared.TryGetValue(name, out var shared)) return Task.FromResult(shared); + } + + throw new KernelValueException($"Go kernel has no variable named '{name}' that it can hand on. A variable of a type that can't be saved (a func, a struct with lowercase fields) can be shared as data with fry.Share(\"{name}\", {name})."); + } + + public Task SetValueFromJsonAsync(string name, string json, CancellationToken ct) + { + if (!Regex.IsMatch(name, @"^[A-Za-z_]\w*$") || GoKeywords.Contains(name)) + { + throw new KernelValueException($"'{name}' can't be a Go variable name."); + } + + try + { + using var _ = JsonDocument.Parse(json); + } + catch (JsonException) + { + throw new KernelValueException("The value isn't valid JSON."); + } + + lock (_stateLock) + { + _variables[name] = new GoVariable(name, GoValueTypes.Infer(json), json); + _unkept.Remove(name); + } + + return Task.CompletedTask; + } + + private static readonly HashSet GoKeywords = new(StringComparer.Ordinal) + { + "break", "default", "func", "interface", "select", "case", "defer", "go", "map", "struct", "chan", "else", "goto", "package", + "switch", "const", "fallthrough", "if", "range", "type", "continue", "for", "import", "return", "var", "_" + }; + + public void HardReset() + { + lock (_stateLock) + { + _items.Clear(); + _imports.Clear(); + _variables.Clear(); + _unkept.Clear(); + _shared.Clear(); + _executionCount = 0; + } + } + + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + HardReset(); + try + { + if (Directory.Exists(_moduleDirectory)) Directory.Delete(_moduleDirectory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Left behind for the next clean-up. + } + } +} diff --git a/Services/Languages/Go/GoPackageManager.cs b/FrySharp.Core/Services/Languages/Go/GoPackageManager.cs similarity index 78% rename from Services/Languages/Go/GoPackageManager.cs rename to FrySharp.Core/Services/Languages/Go/GoPackageManager.cs index be30dcdc..282c07c5 100644 --- a/Services/Languages/Go/GoPackageManager.cs +++ b/FrySharp.Core/Services/Languages/Go/GoPackageManager.cs @@ -7,26 +7,35 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; /// -/// Manages Go packages and modules via go get for source files and notebooks. -/// Supports %go get <package>, %get <package>, and // #go: <package> directives. +/// Adds Go modules for notebooks with go get: %go get <package>, %get <package>, or a // #go: <package> comment. +/// A module is downloaded into a scratch module of its own (see ), which every notebook build then +/// requires, since go get only works inside a module and a notebook cell isn't one until it is built. /// public sealed partial class GoPackageManager : IPackageManager { private readonly GoToolchainProvider? _toolchains; private readonly IProcessLauncher _launcher; private readonly IHostEnvironment _host; + private readonly string _goRoot; private readonly ConcurrentDictionary _locks = new(StringComparer.OrdinalIgnoreCase); + /// The studio's Go folder: the shared build cache, and the scratch module that downloads modules. public GoPackageManager( + string goRoot, GoToolchainProvider? toolchains = null, IProcessLauncher? launcher = null, IHostEnvironment? host = null) { + _goRoot = goRoot; _toolchains = toolchains; _launcher = launcher ?? new ProcessLauncher(); _host = host ?? new HostEnvironment(); + NotebookDependencies = new GoNotebookDependencies(goRoot); } + /// The modules notebook cells have added. + public GoNotebookDependencies NotebookDependencies { get; } + public GoToolchainProvider? Toolchains => _toolchains; [GeneratedRegex(@"^[%#!](?:go\s+)?(?:get|install|add)\s+(?[a-zA-Z0-9_\-\./@]+)(?:\s+(?.*))?$", RegexOptions.IgnoreCase)] @@ -122,14 +131,23 @@ public async Task RunAsync( { output($"▶ Running go get {pkg}...\n"); - var workingDir = _host.HomeDirectory; - if (string.IsNullOrEmpty(workingDir)) workingDir = Directory.GetCurrentDirectory(); + var version = toolchain?.Version ?? new Version(1, 21); + NotebookDependencies.EnsureModule(version); + var environment = new Dictionary(StringComparer.Ordinal) + { + ["GOCACHE"] = Path.Combine(_goRoot, "cache"), + ["GOTOOLCHAIN"] = "local", + ["GOWORK"] = "off", + ["GOFLAGS"] = "-mod=mod", + ["GO111MODULE"] = "on" + }; using var process = _launcher.Start(new ProcessStartSpec { FileName = goExecutable, Arguments = ["get", pkg], - WorkingDirectory = workingDir + WorkingDirectory = NotebookDependencies.Directory, + Environment = environment }, output, output); using (ct.Register(process.Kill)) @@ -153,7 +171,7 @@ public async Task RunAsync( return new PackageCommandResult(false, $"go get exited with code {exitCode}"); } - output($"✓ Successfully installed {pkg}\n"); + output($"✓ Added {pkg}: notebook cells can import it.\n"); return new PackageCommandResult(true); } } diff --git a/Services/Languages/Go/GoPackageMap.cs b/FrySharp.Core/Services/Languages/Go/GoPackageMap.cs similarity index 100% rename from Services/Languages/Go/GoPackageMap.cs rename to FrySharp.Core/Services/Languages/Go/GoPackageMap.cs diff --git a/Services/Languages/Go/GoToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/Go/GoToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/Go/GoToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/Go/GoToolchainProvider.Discovery.cs diff --git a/Services/Languages/Go/GoToolchainProvider.cs b/FrySharp.Core/Services/Languages/Go/GoToolchainProvider.cs similarity index 100% rename from Services/Languages/Go/GoToolchainProvider.cs rename to FrySharp.Core/Services/Languages/Go/GoToolchainProvider.cs diff --git a/FrySharp.Core/Services/Languages/Go/GoValueTypes.cs b/FrySharp.Core/Services/Languages/Go/GoValueTypes.cs new file mode 100644 index 00000000..740d6e1f --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/GoValueTypes.cs @@ -0,0 +1,43 @@ +using System.Text.Json; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; + +/// The Go type a value from another language gets (#!share --from csharp rows), from its JSON: what Go would call it, and no more. +internal static class GoValueTypes +{ + public static string Infer(string json) + { + try + { + using var document = JsonDocument.Parse(json); + return TypeOf(document.RootElement); + } + catch (JsonException) + { + return "any"; + } + } + + private static string TypeOf(JsonElement value) => value.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => "bool", + JsonValueKind.String => "string", + JsonValueKind.Number => value.GetRawText().Any(c => c is '.' or 'e' or 'E') || !value.TryGetInt64(out _) ? "float64" : "int", + JsonValueKind.Object => "map[string]any", + JsonValueKind.Array => ArrayType(value), + _ => "any" + }; + + // A list of one kind of thing is a slice of it (numbers: ints, or floats when any isn't whole); a mixed one is a slice of any. + private static string ArrayType(JsonElement array) + { + var types = array.EnumerateArray().Select(TypeOf).Distinct().ToList(); + return types.Count switch + { + 0 => "[]any", + 1 => "[]" + types[0], + 2 when types.Contains("int") && types.Contains("float64") => "[]float64", + _ => "[]any" + }; + } +} diff --git a/Services/Languages/Go/Runtime/fry.go b/FrySharp.Core/Services/Languages/Go/Runtime/fry.go similarity index 97% rename from Services/Languages/Go/Runtime/fry.go rename to FrySharp.Core/Services/Languages/Go/Runtime/fry.go index 15abc269..b572c5ea 100644 --- a/Services/Languages/Go/Runtime/fry.go +++ b/FrySharp.Core/Services/Languages/Go/Runtime/fry.go @@ -695,17 +695,6 @@ func Bars(values any, args ...any) *DisplayHandle { return sendDisplay(VisualizerMime, barsSpec(values, opts.title)) } -func Table(title string, obj any) { - bytes, _ := json.Marshal(map[string]any{ - "type": "display", - "data": map[string]any{ - TableMime: formatTable(title, obj), - }, - "metadata": map[string]any{}, - }) - emitRaw(string(bytes)) -} - func Html(htmlContent string) { bytes, _ := json.Marshal(map[string]any{ "type": "display", @@ -776,15 +765,6 @@ func Json(jsonString string) { emitRaw(string(bytes)) } -func Dump(v any) any { - Table("", v) - return v -} - -func Show(v any) any { - return Dump(v) -} - func Wait(seconds float64) { ensureEventSocket() end := time.Now().Add(time.Duration(seconds * float64(time.Second))) @@ -1309,43 +1289,6 @@ func barsSpec(values any, title string) map[string]any { return spec } -func formatTable(title string, obj any) map[string]any { - tbl := map[string]any{"title": title} - if title == "" { - tbl["title"] = "Table" - } - slice := toSlice(obj) - var cols []string - var numeric []bool - var rows [][]any - - if slice != nil && len(slice) > 0 { - first := toMap(slice[0]) - if first != nil { - for k, v := range first { - cols = append(cols, k) - _, isNum := v.(int) - if !isNum { - _, isNum = v.(float64) - } - numeric = append(numeric, isNum) - } - for _, item := range slice { - m := toMap(item) - var row []any - for _, col := range cols { - row = append(row, m[col]) - } - rows = append(rows, row) - } - } - } - tbl["columns"] = cols - tbl["numeric"] = numeric - tbl["rows"] = rows - return tbl -} - // --------------------------------------------------------------------------------------------------- Helpers func toSlice(v any) []any { if v == nil { diff --git a/FrySharp.Core/Services/Languages/Go/Runtime/values.go b/FrySharp.Core/Services/Languages/Go/Runtime/values.go new file mode 100644 index 00000000..8f9afda2 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Go/Runtime/values.go @@ -0,0 +1,738 @@ +package fry + +// What a notebook cell does with a value: show it (text, a table, the Object Explorer), keep it for the next cell, share it with +// another language, ask the notebook for a line of input. + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strconv" + "strings" + "sync" +) + +const ( + InspectorMime = "application/vnd.fry.inspector+json" + + maxInline = 50 // scalars in a list shown on one line + maxTableRows = 1000 // rows of a table (the rest is counted, not sent) + maxInspectItems = 100 // properties or elements of one object in the Object Explorer + maxInspectDepth = 3 + maxPersistBytes = 8 << 20 +) + +// ------------------------------------------------------------------------------------------------------------ Show + +// ShowAll shows what an expression gave: the values of a call that returns several (a trailing nil, such as the error of +// strconv.Atoi, is not a result worth showing). A cell's last expression is passed here. +func ShowAll(values ...any) { + n := len(values) + if n > 1 && values[n-1] == nil { + n-- + } + for _, v := range values[:n] { + showValue(v, "") + } +} + +// Dump shows a value as a cell's last expression is shown, and returns it. +func Dump(v any) any { + showValue(v, "") + return v +} + +// Show shows a value as a cell's last expression is shown, and returns it. +func Show(v any) any { + showValue(v, "") + return v +} + +// Table shows a list of structs, maps or values as a table. +func Table(title string, obj any) { + if title == "" { + title = "Table" + } + showValue(obj, title) +} + +func showValue(v any, title string) { + if v == nil { + fmt.Println("") + return + } + if err, ok := v.(error); ok { + fmt.Println(err.Error()) + return + } + rv := reflect.ValueOf(v) + if s, ok := v.(fmt.Stringer); ok && !(rv.Kind() == reflect.Ptr && rv.IsNil()) { + fmt.Println(s.String()) + return + } + switch rv.Kind() { + case reflect.Slice, reflect.Array: + showList(rv, title) + case reflect.Map: + showMap(rv, title) + case reflect.Struct: + showInspector(rv) + case reflect.Ptr, reflect.Interface: + if rv.IsNil() { + fmt.Println("") + } else if e := indirect(rv); e.Kind() == reflect.Struct { + showInspector(rv) + } else { + showValue(e.Interface(), title) + } + default: + fmt.Println(scalarText(rv, true)) + } +} + +func indirect(rv reflect.Value) reflect.Value { + for (rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface) && !rv.IsNil() { + rv = rv.Elem() + } + return rv +} + +func isScalar(rv reflect.Value) bool { + switch rv.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.String: + return true + } + return false +} + +// A number, text, a name: one cell, one line of text. Works on the unexported fields of a struct too. +func scalarText(rv reflect.Value, quote bool) string { + switch rv.Kind() { + case reflect.Bool: + return strconv.FormatBool(rv.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(rv.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return strconv.FormatUint(rv.Uint(), 10) + case reflect.Float32: + return strconv.FormatFloat(rv.Float(), 'g', -1, 32) + case reflect.Float64: + return strconv.FormatFloat(rv.Float(), 'g', -1, 64) + case reflect.Complex64, reflect.Complex128: + return fmt.Sprint(rv.Complex()) + case reflect.String: + if quote { + return strconv.Quote(rv.String()) + } + return rv.String() + } + return textOf(rv) +} + +// Any value as one line of text: its String or Error method when it has one (a time.Duration, an error), else Go's own %v. +func textOf(rv reflect.Value) string { + if !rv.IsValid() { + return "" + } + if rv.CanInterface() { + value := rv.Interface() + switch v := value.(type) { + case error: + return v.Error() + case fmt.Stringer: + if !(rv.Kind() == reflect.Ptr && rv.IsNil()) { + return v.String() + } + } + return fmt.Sprintf("%v", value) + } + if isScalar(rv) { + return scalarText(rv, false) + } + return fmt.Sprintf("%v", rv) +} + +func scalarLike(rv reflect.Value) bool { + rv = indirectScalar(rv) + if !rv.IsValid() { + return true + } + if isScalar(rv) { + return true + } + if rv.CanInterface() { + switch rv.Interface().(type) { + case error, fmt.Stringer: + return true + } + } + return false +} + +func indirectScalar(rv reflect.Value) reflect.Value { + for rv.IsValid() && (rv.Kind() == reflect.Interface || rv.Kind() == reflect.Ptr) { + if rv.IsNil() { + return reflect.Value{} + } + if rv.Kind() == reflect.Ptr && rv.Elem().Kind() == reflect.Struct { + return rv + } + rv = rv.Elem() + } + return rv +} + +func isRecordValue(rv reflect.Value) bool { + rv = indirect(rv) + if !rv.IsValid() { + return false + } + if rv.CanInterface() { + switch rv.Interface().(type) { + case error, fmt.Stringer: + return false + } + } + return rv.Kind() == reflect.Struct || rv.Kind() == reflect.Map +} + +func showList(rv reflect.Value, title string) { + n := rv.Len() + if n == 0 { + fmt.Println("[ ]") + return + } + allScalar, allRecord := true, true + for i := 0; i < n; i++ { + item := rv.Index(i) + if !scalarLike(item) { + allScalar = false + } + if !isRecordValue(item) { + allRecord = false + } + } + if allScalar && n <= maxInline { + parts := make([]string, n) + for i := 0; i < n; i++ { + item := indirectScalar(rv.Index(i)) + if !item.IsValid() { + parts[i] = "" + } else { + parts[i] = textOf(item) + } + } + fmt.Println("[ " + strings.Join(parts, ", ") + " ]") + return + } + if allRecord { + items := make([]reflect.Value, n) + for i := range items { + items[i] = indirect(rv.Index(i)) + } + emitTable(title, recordTable(items), n) + return + } + cells := make([][]any, 0, minInt(n, maxTableRows)) + for i := 0; i < n && i < maxTableRows; i++ { + cells = append(cells, []any{i, cellOf(rv.Index(i))}) + } + emitTable(title, tableData{columns: []string{"index", "value"}, rows: cells}, n) +} + +func showMap(rv reflect.Value, title string) { + if rv.Len() == 0 { + fmt.Println("{ }") + return + } + keys := sortedKeys(rv) + cells := make([][]any, 0, minInt(len(keys), maxTableRows)) + for i, k := range keys { + if i >= maxTableRows { + break + } + cells = append(cells, []any{cellOf(k), cellOf(rv.MapIndex(k))}) + } + emitTable(title, tableData{columns: []string{"key", "value"}, rows: cells}, len(keys)) +} + +func sortedKeys(rv reflect.Value) []reflect.Value { + keys := rv.MapKeys() + sort.SliceStable(keys, func(i, j int) bool { + a, b := keys[i], keys[j] + switch { + case isIntKind(a.Kind()) && isIntKind(b.Kind()): + return a.Int() < b.Int() + case isUintKind(a.Kind()) && isUintKind(b.Kind()): + return a.Uint() < b.Uint() + case a.Kind() == reflect.Float64 && b.Kind() == reflect.Float64: + return a.Float() < b.Float() + } + return textOf(a) < textOf(b) + }) + return keys +} + +func isIntKind(k reflect.Kind) bool { + return k == reflect.Int || k == reflect.Int8 || k == reflect.Int16 || k == reflect.Int32 || k == reflect.Int64 +} + +func isUintKind(k reflect.Kind) bool { + return k == reflect.Uint || k == reflect.Uint8 || k == reflect.Uint16 || k == reflect.Uint32 || k == reflect.Uint64 || k == reflect.Uintptr +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +// One table cell: a number, a bool or text stays what it is, anything else is its text. +func cellOf(rv reflect.Value) any { + rv = indirectScalar(rv) + if !rv.IsValid() { + return nil + } + switch rv.Kind() { + case reflect.Bool: + return rv.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return rv.Uint() + case reflect.Float32, reflect.Float64: + f := rv.Float() + if f != f || f > 1.7976931348623157e308 || f < -1.7976931348623157e308 { + return nil + } + return f + case reflect.String: + return rv.String() + } + return textOf(rv) +} + +type tableData struct { + columns []string + rows [][]any +} + +// The columns of a list of structs are their fields in the order they are declared; of maps, the keys that appear, sorted. +func recordTable(items []reflect.Value) tableData { + var columns []string + index := map[string]int{} + add := func(name string) { + if _, ok := index[name]; !ok { + index[name] = len(columns) + columns = append(columns, name) + } + } + structural := true + mapKeys := map[string]bool{} + for _, item := range items { + if item.Kind() == reflect.Struct { + t := item.Type() + for i := 0; i < t.NumField(); i++ { + add(t.Field(i).Name) + } + } else { + structural = false + for _, k := range item.MapKeys() { + mapKeys[textOf(k)] = true + } + } + } + if !structural { + names := make([]string, 0, len(mapKeys)) + for k := range mapKeys { + names = append(names, k) + } + sort.Strings(names) + for _, name := range names { + add(name) + } + } + rows := make([][]any, 0, minInt(len(items), maxTableRows)) + for r, item := range items { + if r >= maxTableRows { + break + } + row := make([]any, len(columns)) + if item.Kind() == reflect.Struct { + t := item.Type() + for i := 0; i < t.NumField(); i++ { + row[index[t.Field(i).Name]] = cellOf(item.Field(i)) + } + } else { + for _, k := range item.MapKeys() { + row[index[textOf(k)]] = cellOf(item.MapIndex(k)) + } + } + rows = append(rows, row) + } + return tableData{columns: columns, rows: rows} +} + +func emitTable(title string, t tableData, total int) { + numeric := make([]bool, len(t.columns)) + for c := range t.columns { + seen := false + numeric[c] = true + for _, row := range t.rows { + switch row[c].(type) { + case int, int64, uint64, float64: + seen = true + case nil: + default: + numeric[c] = false + } + } + numeric[c] = numeric[c] && seen + } + emitMime(TableMime, map[string]any{ + "title": title, + "columns": t.columns, + "numeric": numeric, + "rows": t.rows, + "totalRows": total, + "totalColumns": len(t.columns), + }) +} + +func emitMime(mime string, value any) { + data, err := json.Marshal(map[string]any{"type": "display", "data": map[string]any{mime: value}, "metadata": map[string]any{}}) + if err == nil { + emitRaw(string(data)) + } +} + +// ------------------------------------------------------------------------------------------------- Object Explorer + +type inspectorProperty struct { + Name string `json:"name"` + Value *string `json:"value,omitempty"` + IsNull bool `json:"isNull"` + IsString bool `json:"isString"` + Child *inspectorNode `json:"child,omitempty"` +} + +type inspectorNode struct { + Title string `json:"title"` + Circular bool `json:"circular,omitempty"` + Properties []inspectorProperty `json:"properties"` +} + +func showInspector(rv reflect.Value) { + node := inspect(rv, 0, map[uintptr]bool{}) + data, err := json.Marshal(map[string]any{ + "type": "display", + "data": map[string]any{InspectorMime: node, "text/plain": textOf(indirect(rv))}, + "metadata": map[string]any{}, + }) + if err == nil { + emitRaw(string(data)) + } +} + +func typeTitle(t reflect.Type) string { + return strings.ReplaceAll(t.String(), "main.", "") +} + +func inspect(rv reflect.Value, depth int, path map[uintptr]bool) *inspectorNode { + // A pointer back to an object above is listed again (flagged), down to the depth limit, as the desktop's C# inspector does. + circular := false + if rv.Kind() == reflect.Ptr && !rv.IsNil() { + p := rv.Pointer() + if path[p] { + circular = true + } else { + path[p] = true + defer delete(path, p) + } + } + rv = indirect(rv) + node := &inspectorNode{Title: "nil", Circular: circular, Properties: []inspectorProperty{}} + if !rv.IsValid() { + return node + } + node.Title = typeTitle(rv.Type()) + add := func(name string, v reflect.Value) { + if len(node.Properties) >= maxInspectItems { + return + } + node.Properties = append(node.Properties, inspectProperty(name, v, depth, path)) + } + more := func(total int) { + if total > maxInspectItems { + text := fmt.Sprintf("(more than %d shown)", maxInspectItems) + node.Properties = append(node.Properties, inspectorProperty{Name: "…", Value: &text}) + } + } + switch rv.Kind() { + case reflect.Struct: + t := rv.Type() + for i := 0; i < t.NumField(); i++ { + add(t.Field(i).Name, rv.Field(i)) + } + more(t.NumField()) + case reflect.Slice, reflect.Array: + for i := 0; i < rv.Len(); i++ { + add("["+strconv.Itoa(i)+"]", rv.Index(i)) + } + more(rv.Len()) + case reflect.Map: + keys := sortedKeys(rv) + for _, k := range keys { + add(textOf(k), rv.MapIndex(k)) + } + more(len(keys)) + } + return node +} + +func inspectProperty(name string, v reflect.Value, depth int, path map[uintptr]bool) inspectorProperty { + prop := inspectorProperty{Name: name} + inner := indirectScalar(v) + switch { + case !inner.IsValid(): + text := "nil" + prop.Value, prop.IsNull = &text, true + case isScalar(inner) || (inner.CanInterface() && isStringerOrError(inner)): + text := scalarText(inner, false) + if !isScalar(inner) { + text = textOf(inner) + } + prop.Value, prop.IsString = &text, inner.Kind() == reflect.String + case depth >= maxInspectDepth: + prop.Child = &inspectorNode{Title: typeTitle(indirect(v).Type()), Properties: []inspectorProperty{}} + default: + prop.Child = inspect(v, depth+1, path) + } + return prop +} + +func isStringerOrError(rv reflect.Value) bool { + switch rv.Interface().(type) { + case error, fmt.Stringer: + return true + } + return false +} + +// ------------------------------------------------------------------------------------- Keeping variables for the next cell + +var ( + restoreOnce sync.Once + restored = map[string]json.RawMessage{} + persistMu sync.Mutex +) + +func loadRestored() { + path := os.Getenv("FRY_VARS_IN") + if path == "" { + return + } + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1<<20), maxPersistBytes*2) + for scanner.Scan() { + var entry struct { + Name string `json:"name"` + Json json.RawMessage `json:"json"` + } + if json.Unmarshal(scanner.Bytes(), &entry) == nil && entry.Name != "" { + restored[entry.Name] = entry.Json + } + } +} + +// RestoreInto is Restore for a variable a cell declared at the top of the program (var counter int): the value goes into it. +func RestoreInto[T any](target *T, name string) { + restoreOnce.Do(loadRestored) + if raw, ok := restored[name]; ok { + _ = json.Unmarshal(raw, target) + } +} + +// Restore gives a cell the value a variable had when the cell that made it ended (a cell's start does this; it isn't called by hand). +func Restore[T any](name string) T { + restoreOnce.Do(loadRestored) + var value T + if raw, ok := restored[name]; ok { + _ = json.Unmarshal(raw, &value) + } + return value +} + +// Persist records a variable the cell made, for the next cell: its name, its type as Go writes it, and its value as JSON. +// A value that wouldn't come back the same (a func, a channel, a struct with fields that start with a lowercase letter) is +// recorded as skipped, with why; the notebook says so. +func Persist(name string, v any) { + path := os.Getenv("FRY_VARS_OUT") + if path == "" { + return + } + entry := map[string]any{"name": name} + t := reflect.TypeOf(v) + _, isError := v.(error) + switch { + case t == nil: + // nil (an error that didn't happen, a pointer that isn't set) has no type to give the next cell: not worth a note + entry["skipped"] = "it is nil, so its type isn't known" + entry["quiet"] = true + case isError: + entry["skipped"] = "errors can't be saved" + entry["quiet"] = true + default: + entry["type"] = t.String() + if !nameable(t) { + entry["skipped"] = "its type (" + t.String() + ") isn't one a cell can name" + } else if why := hiddenState(t, map[reflect.Type]bool{}); why != "" { + entry["skipped"] = why + } else if data, err := json.Marshal(v); err != nil { + entry["skipped"] = "its value can't be saved: " + err.Error() + } else if len(data) > maxPersistBytes { + entry["skipped"] = "it is too large to keep" + } else { + again := reflect.New(t) + if err := json.Unmarshal(data, again.Interface()); err != nil { + entry["skipped"] = "it doesn't come back the same: " + err.Error() + } else if back, err := json.Marshal(again.Elem().Interface()); err != nil || string(back) != string(data) { + entry["skipped"] = "it doesn't come back the same when it is saved" + } else { + entry["json"] = json.RawMessage(data) + } + } + } + line, err := json.Marshal(entry) + if err != nil { + return + } + persistMu.Lock() + defer persistMu.Unlock() + if file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil { + file.Write(append(line, '\n')) + file.Close() + } +} + +// A type the next cell can write down: not one a package keeps to itself (*errors.errorString), and not a func or a channel. +func nameable(t reflect.Type) bool { + if t.Name() != "" { + if t.PkgPath() != "" && t.PkgPath() != "main" && !isExportedName(t.Name()) { + return false + } + return t.Kind() != reflect.Func && t.Kind() != reflect.Chan && t.Kind() != reflect.UnsafePointer + } + switch t.Kind() { + case reflect.Ptr, reflect.Slice, reflect.Array: + return nameable(t.Elem()) + case reflect.Map: + return nameable(t.Key()) && nameable(t.Elem()) + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + if !nameable(t.Field(i).Type) { + return false + } + } + return true + case reflect.Interface: + return t.NumMethod() == 0 + } + return false +} + +func isExportedName(name string) bool { + return len(name) > 0 && name[0] >= 'A' && name[0] <= 'Z' +} + +var jsonMarshaler = reflect.TypeOf((*json.Marshaler)(nil)).Elem() + +// Fields JSON can't see (a lowercase name) would be lost: a type with them (that doesn't write itself as JSON) isn't kept. +func hiddenState(t reflect.Type, seen map[reflect.Type]bool) string { + if seen[t] { + return "" + } + seen[t] = true + if t.Implements(jsonMarshaler) || reflect.PtrTo(t).Implements(jsonMarshaler) { + return "" + } + switch t.Kind() { + case reflect.Ptr, reflect.Slice, reflect.Array: + return hiddenState(t.Elem(), seen) + case reflect.Map: + return hiddenState(t.Elem(), seen) + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + return "it has a field (" + f.Name + ") that starts with a lowercase letter, which can't be saved" + } + if why := hiddenState(f.Type, seen); why != "" { + return why + } + } + } + return "" +} + +// ----------------------------------------------------------------------------------------------- Sharing with other languages + +// Share offers a value to the cells of other languages (#!share --from go name): it goes as JSON. +func Share(name string, v any) { + data, err := json.Marshal(v) + if err != nil { + fmt.Println("⚠️ " + name + " can't be shared: " + err.Error()) + return + } + line, err := json.Marshal(map[string]any{"name": name, "json": string(data)}) + if err == nil { + fmt.Println("__FRY_SHARE__ " + string(line)) + } +} + +// ------------------------------------------------------------------------------------------------------------------ Input + +var ( + stdin = bufio.NewReader(os.Stdin) + inputEnded bool + inputMu sync.Mutex +) + +// Input asks for a line of input and returns it, without its line break; false is the end of input. In a notebook cell the +// notebook shows the prompt and the user answers there; a plain read of os.Stdin can't ask, and sees the end of input at once. +// In a program run on its own the prompt is printed and the line is read from the terminal. +func Input(prompt string) (string, bool) { return ask(prompt, false) } + +// Password is Input for a secret: what is typed is not shown. +func Password(prompt string) (string, bool) { return ask(prompt, true) } + +func ask(prompt string, secret bool) (string, bool) { + inputMu.Lock() + defer inputMu.Unlock() + if inputEnded { + return "", false + } + if os.Getenv("FRY_INPUT_REQUESTS") != "" { + line, _ := json.Marshal(map[string]any{"prompt": prompt, "password": secret}) + fmt.Println("__FRY_INPUT__ " + string(line)) + } else { + fmt.Print(prompt) + } + text, err := stdin.ReadString('\n') + if err != nil && text == "" { + inputEnded = true + return "", false + } + return strings.TrimRight(text, "\r\n"), true +} diff --git a/FrySharp.Core/Services/Languages/HeadlessLanguages.cs b/FrySharp.Core/Services/Languages/HeadlessLanguages.cs index 82484e8f..db2ff642 100644 --- a/FrySharp.Core/Services/Languages/HeadlessLanguages.cs +++ b/FrySharp.Core/Services/Languages/HeadlessLanguages.cs @@ -1,6 +1,12 @@ using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.CSharp; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Java; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.JavaScript; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Python; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Rust; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages; @@ -24,6 +30,12 @@ public static LanguageRegistry Create(LanguageEngineServices se registry.Register(new CSharpEngine()); registry.Register(new PythonEngine(services)); registry.Register(new JavaScriptEngine(services)); + registry.Register(new SqlEngine(services)); + registry.Register(new FSharpEngine(services)); + registry.Register(new JavaEngine(services)); + registry.Register(new RustEngine(services)); + registry.Register(new GoEngine(services)); + registry.Register(new CppEngine(services)); return registry; } diff --git a/Services/Languages/Java/JavaBuildAndRunScriptRunner.cs b/FrySharp.Core/Services/Languages/Java/JavaBuildAndRunScriptRunner.cs similarity index 100% rename from Services/Languages/Java/JavaBuildAndRunScriptRunner.cs rename to FrySharp.Core/Services/Languages/Java/JavaBuildAndRunScriptRunner.cs diff --git a/Services/Languages/Java/JavaCompilerDiagnosticParser.cs b/FrySharp.Core/Services/Languages/Java/JavaCompilerDiagnosticParser.cs similarity index 100% rename from Services/Languages/Java/JavaCompilerDiagnosticParser.cs rename to FrySharp.Core/Services/Languages/Java/JavaCompilerDiagnosticParser.cs diff --git a/Services/Languages/Java/JavaDisplayRuntime.cs b/FrySharp.Core/Services/Languages/Java/JavaDisplayRuntime.cs similarity index 100% rename from Services/Languages/Java/JavaDisplayRuntime.cs rename to FrySharp.Core/Services/Languages/Java/JavaDisplayRuntime.cs diff --git a/FrySharp.Core/Services/Languages/Java/JavaEngine.cs b/FrySharp.Core/Services/Languages/Java/JavaEngine.cs new file mode 100644 index 00000000..219d96fe --- /dev/null +++ b/FrySharp.Core/Services/Languages/Java/JavaEngine.cs @@ -0,0 +1,70 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Java; + +/// +/// Java, built and run with the JDK installed on this computer: source files are compiled by javac and run by +/// java; notebook cells run in a JShell kernel (a program started with java FryKernel.java that speaks the Fry +/// kernel protocol), so what one cell declares the next one uses. +/// +public sealed class JavaEngine : LanguageEngine +{ + private static readonly string[] JavaAliases = ["java", "jdk"]; + private static readonly string[] JavaExtensions = [".java"]; + + public JavaEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + var toolchain = new JavaToolchainProvider( + services.Host, + services.Processes, + services.ToolchainSettings, + Path.Combine(services.BaseDirectory, "java")); + JavaToolchain = toolchain; + ScriptRunner = new JavaBuildAndRunScriptRunner(services.Host); + Packages = new MavenPackageManager(new MavenCentralResolver(services.Host), services.Host); + + var launcher = new JavaKernelLauncher(toolchain, services.Host, Path.Combine(services.BaseDirectory, "java", "kernel")); + NotebookKernels = new DelegateKernelFactory(context => + new ProtocolKernel(LanguageIds.Java, "Java", launcher, services.Processes, context)); + } + + public JavaToolchainProvider JavaToolchain { get; } + + public override string Id => LanguageIds.Java; + public override string DisplayName => "Java"; + public override JupyterLanguageInfo Jupyter { get; } = new("java", "Java", "java", "java", ".java", "text/x-java-source"); + public override string ShortName => "JAVA"; + public override IReadOnlyList Aliases => JavaAliases; + public override IReadOnlyList FileExtensions => JavaExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + public override bool IsCompiled => true; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | LanguageCapabilities.Debugging | + LanguageCapabilities.Breakpoints | LanguageCapabilities.Packages | + LanguageCapabilities.Completion; + + public override string IconKind => "LanguageJava"; + public override string AccentHex => "#EA2D2E"; + public override string LineCommentPrefix => "//"; + public override string RuntimeDescription => "Java (JDK)"; + + public override string NewFileTemplate => + "// Runs with the Java Development Kit (JDK) installed on this computer (F5).\n" + + "public class Main {\n" + + " public static void main(String[] args) {\n" + + " System.out.println(\"Hello from Java \" + System.getProperty(\"java.version\") + \"!\");\n" + + " }\n" + + "}\n"; + + public override IToolchainProvider Toolchain => JavaToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new JavaCompilerDiagnosticParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/Java/JavaGuidance.cs b/FrySharp.Core/Services/Languages/Java/JavaGuidance.cs similarity index 100% rename from Services/Languages/Java/JavaGuidance.cs rename to FrySharp.Core/Services/Languages/Java/JavaGuidance.cs diff --git a/Services/Languages/Java/JavaKernelLauncher.cs b/FrySharp.Core/Services/Languages/Java/JavaKernelLauncher.cs similarity index 100% rename from Services/Languages/Java/JavaKernelLauncher.cs rename to FrySharp.Core/Services/Languages/Java/JavaKernelLauncher.cs diff --git a/Services/Languages/Java/JavaToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/Java/JavaToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/Java/JavaToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/Java/JavaToolchainProvider.Discovery.cs diff --git a/Services/Languages/Java/JavaToolchainProvider.cs b/FrySharp.Core/Services/Languages/Java/JavaToolchainProvider.cs similarity index 100% rename from Services/Languages/Java/JavaToolchainProvider.cs rename to FrySharp.Core/Services/Languages/Java/JavaToolchainProvider.cs diff --git a/FrySharp.Core/Services/Languages/Java/Kernel/FryInspector.java b/FrySharp.Core/Services/Languages/Java/Kernel/FryInspector.java new file mode 100644 index 00000000..37fa999e --- /dev/null +++ b/FrySharp.Core/Services/Languages/Java/Kernel/FryInspector.java @@ -0,0 +1,289 @@ +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.RecordComponent; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * The Object Explorer for Java: what the last expression of a notebook cell shows when it is an object, the way the + * studio shows a C# value (ObjectInspectorBuilder): a scalar stays text, a short collection of scalars is one line + * ("[ 1, 34, 45 ]"), anything else is a tree of its public fields and getters (a record's components, a map's entries, + * a collection's items), each nested object a node that opens. Sent as application/vnd.fry.inspector+json. + * Loaded into the kernel's JShell after Display, which carries the message. + */ +class FryInspector { + static final String MIME = "application/vnd.fry.inspector+json"; + static final int MAX_DEPTH = 3; // levels of objects below the one shown, as the C# inspector + static final int MAX_ITEMS = 100; // items of a collection or map listed + static final int MAX_INLINE = 50; // scalars of a collection shown on one line + + /** + * Shows the value. Returns false when it is for the caller to print as text: null, a scalar, or an object + * with nothing to list. A chart, plot or visualizer a cell ends with (Display's handle) is already on screen, + * so it is taken as shown, as in every other language. + */ + static boolean show(Object value) { + if (value == null || isScalar(value)) return false; + if (isShown(value)) return true; + + String inline = inlineScalars(value); + if (inline != null) { + System.out.println(inline); + return true; + } + + // a collection of records, beans or maps is the studio's table, as in Python and F# + try { + if (Display.showTableIfTabular(value)) return true; + } catch (Throwable ignored) { + // then it is listed as a tree + } + + List rows; + try { + IdentityHashMap path = new IdentityHashMap<>(); + path.put(value, Boolean.TRUE); + rows = rows(value, 0, path); + } catch (Throwable t) { + return false; + } + if (rows.isEmpty()) return false; + + String text; + try { + text = String.valueOf(value); + } catch (Throwable t) { + text = titleOf(value.getClass()); + } + Display.emitRaw("{\"type\":\"display\",\"data\":{\"" + MIME + "\":" + node(value, rows, false) + ",\"text/plain\":" + quote(plain(text)) + "},\"metadata\":{}}"); + return true; + } + + // ------------------------------------------------------------------------------------------------------- values + + static boolean isScalar(Object v) { + return v != null && Display.isScalarValue(v); + } + + // Display's handles carry _fry_shown: a value that was shown when it was made. + static boolean isShown(Object value) { + try { + return value.getClass().getField("_fry_shown").getBoolean(value); + } catch (Throwable t) { + return false; + } + } + + static String format(Object v) { + if (v instanceof Double d) { + if (d.isNaN()) return "NaN"; + if (d.isInfinite()) return d > 0 ? "∞" : "-∞"; + } + if (v instanceof Float f) { + if (f.isNaN()) return "NaN"; + if (f.isInfinite()) return f > 0 ? "∞" : "-∞"; + } + return String.valueOf(v); + } + + /** One line for an array or collection whose items are all scalars (or null); null when it has anything else. */ + static String inlineScalars(Object obj) { + List items = new ArrayList<>(); + boolean more = false; + if (obj.getClass().isArray()) { + int length = Array.getLength(obj); + for (int i = 0; i < length; i++) { + if (i > MAX_INLINE) { more = true; break; } + items.add(Array.get(obj, i)); + } + } else if (obj instanceof Collection collection) { + int i = 0; + for (Object item : collection) { + if (i++ > MAX_INLINE) { more = true; break; } + items.add(item); + } + } else { + return null; + } + + StringBuilder sb = new StringBuilder("[ "); + for (int i = 0; i < items.size(); i++) { + Object item = items.get(i); + if (i > 0) sb.append(", "); + if (item == null) sb.append(""); + else if (isScalar(item)) sb.append(format(item)); + else return null; + } + if (more) sb.append(items.isEmpty() ? "..." : ", ..."); + return sb.append(" ]").toString(); + } + + // ------------------------------------------------------------------------------------------------------- tree + + static String titleOf(Class type) { + if (type.isArray()) return titleOf(type.getComponentType()) + "[]"; + // The JDK's own hidden classes (List.of makes a "List12") are named for what they are. + if (type.getName().startsWith("java.") && !Modifier.isPublic(type.getModifiers())) { + for (Class kind : new Class[] { List.class, java.util.Set.class, Map.class, Collection.class }) { + if (kind.isAssignableFrom(type)) return kind.getSimpleName(); + } + } + String name = type.getSimpleName(); + return name.isEmpty() ? type.getName() : name; + } + + // A JShell class is REPL.$JShell$27$People: the part before its own name says nothing. + static String plain(String text) { + return text.replaceAll("REPL\\.\\$JShell\\$\\d+\\$", ""); + } + + static String node(Object obj, List rows, boolean circular) { + return "{\"title\":" + quote(titleOf(obj.getClass())) + (circular ? ",\"circular\":true" : "") + + ",\"properties\":[" + String.join(",", rows) + "]}"; + } + + static String row(String name, Object value, int depth, IdentityHashMap path) { + String key = "{\"name\":" + quote(name); + if (value == null) return key + ",\"value\":\"\",\"isNull\":true}"; + if (isScalar(value)) { + boolean text = value instanceof CharSequence || value instanceof Character; + return key + ",\"value\":" + quote(format(value)) + ",\"isString\":" + text + "}"; + } + String inline = inlineScalars(value); + if (inline != null) return key + ",\"value\":" + quote(inline) + "}"; + if (depth > MAX_DEPTH) return key + ",\"child\":" + node(value, List.of(), false) + "}"; + + // A reference back to an object above is listed again (flagged), down to the depth limit, as the desktop's C# inspector does. + boolean circular = path.containsKey(value); + path.put(value, Boolean.TRUE); + try { + return key + ",\"child\":" + node(value, rows(value, depth, path), circular) + "}"; + } finally { + if (!circular) path.remove(value); + } + } + + static List rows(Object obj, int depth, IdentityHashMap path) { + List rows = new ArrayList<>(); + int next = depth + 1; + + if (obj instanceof Map map) { + int i = 0; + for (Map.Entry entry : map.entrySet()) { + if (i++ >= MAX_ITEMS) { rows.add(truncated(i - 1)); break; } + rows.add(row(String.valueOf(entry.getKey()), entry.getValue(), next, path)); + } + } else if (obj instanceof Iterable items) { + int i = 0; + for (Object item : items) { + if (i >= MAX_ITEMS) { rows.add(truncated(i)); break; } + rows.add(row("[" + i++ + "]", item, next, path)); + } + } else if (obj.getClass().isArray()) { + int length = Array.getLength(obj); + for (int i = 0; i < length; i++) { + if (i >= MAX_ITEMS) { rows.add(truncated(i)); break; } + rows.add(row("[" + i + "]", Array.get(obj, i), next, path)); + } + } else { + members(obj, next, path, rows); + } + return rows; + } + + static String truncated(int shown) { + return "{\"name\":\"...\",\"value\":\"(" + shown + "+ items truncated)\"}"; + } + + // A record's components; otherwise the public fields, then the public getters (getX(), isX()) that no field covers. + static void members(Object obj, int depth, IdentityHashMap path, List rows) { + Class type = obj.getClass(); + + if (type.isRecord()) { + for (RecordComponent component : type.getRecordComponents()) { + rows.add(read(component.getName(), () -> { + Method accessor = component.getAccessor(); + accessor.setAccessible(true); + return accessor.invoke(obj); + }, depth, path)); + } + return; + } + + List names = new ArrayList<>(); + for (Field field : type.getFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue; + names.add(field.getName()); + rows.add(read(field.getName(), () -> field.get(obj), depth, path)); + } + + List getters = new ArrayList<>(); + for (Method method : type.getMethods()) { + if (isGetter(method)) getters.add(method); + } + getters.sort(Comparator.comparing(FryInspector::propertyName)); + for (Method getter : getters) { + String name = propertyName(getter); + if (names.contains(name)) continue; + names.add(name); + rows.add(read(name, () -> { + getter.setAccessible(true); + return getter.invoke(obj); + }, depth, path)); + } + } + + interface Read { Object get() throws Exception; } + + static String read(String name, Read read, int depth, IdentityHashMap path) { + Object value; + try { + value = read.get(); + } catch (Throwable t) { + Throwable cause = t.getCause() != null ? t.getCause() : t; + return "{\"name\":" + quote(name) + ",\"value\":" + quote("") + "}"; + } + return row(name, value, depth, path); + } + + static boolean isGetter(Method m) { + if (Modifier.isStatic(m.getModifiers()) || m.isSynthetic() || m.isBridge()) return false; + if (m.getParameterCount() != 0 || m.getDeclaringClass() == Object.class) return false; + Class returns = m.getReturnType(); + if (returns == void.class) return false; + String name = m.getName(); + if (name.equals("getClass") || name.equals("hashCode") || name.equals("toString")) return false; + return (name.startsWith("get") && name.length() > 3) + || (name.startsWith("is") && name.length() > 2 && (returns == boolean.class || returns == Boolean.class)); + } + + static String propertyName(Method m) { + String name = m.getName(); + String rest = name.startsWith("get") ? name.substring(3) : name.substring(2); + return rest.length() > 1 && Character.isUpperCase(rest.charAt(1)) ? rest : Character.toLowerCase(rest.charAt(0)) + rest.substring(1); + } + + // ------------------------------------------------------------------------------------------------------- json + + static String quote(String s) { + StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"') sb.append("\\\""); + else if (c == '\\') sb.append("\\\\"); + else if (c == '\n') sb.append("\\n"); + else if (c == '\r') sb.append("\\r"); + else if (c == '\t') sb.append("\\t"); + else if (c < 32) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + return sb.append('"').toString(); + } +} diff --git a/Services/Languages/Java/Kernel/FryKernel.java b/FrySharp.Core/Services/Languages/Java/Kernel/FryKernel.java similarity index 57% rename from Services/Languages/Java/Kernel/FryKernel.java rename to FrySharp.Core/Services/Languages/Java/Kernel/FryKernel.java index e10f80db..a859e47d 100644 --- a/Services/Languages/Java/Kernel/FryKernel.java +++ b/FrySharp.Core/Services/Languages/Java/Kernel/FryKernel.java @@ -13,75 +13,194 @@ public class FryKernel { private static JShell jshell; private static volatile String currentRequestId = null; private static int executionCount = 0; + private static volatile boolean interruptRequested = false; + // Where each snippet of the running cell begins (1-based line of the cell), by JShell's snippet id, so that a stack frame + // named "#3:1" can be put on the cell's own line. + private static final Map snippetStartLines = new HashMap<>(); private static final PrintStream protocolOut = System.out; - private static final SnippetOutputStream snippetOutStream = new SnippetOutputStream(); + private static final SnippetOutputStream snippetOutStream = new SnippetOutputStream("stdout"); private static final PrintStream snippetPs = new PrintStream(snippetOutStream, true, StandardCharsets.UTF_8); + private static final SnippetOutputStream snippetErrStream = new SnippetOutputStream("stderr"); + private static final PrintStream snippetErrPs = new PrintStream(snippetErrStream, true, StandardCharsets.UTF_8); private static final BlockingQueue messageQueue = new LinkedBlockingQueue<>(); private static final Map displayOwners = new ConcurrentHashMap<>(); + // The answers to input_request: a line of text, or empty for the end of input. + private static final BlockingQueue> inputReplies = new LinkedBlockingQueue<>(); + private static final KernelInput kernelInput = new KernelInput(); + + /** + * System.in of the running cell (Scanner, BufferedReader): when the code reads and nothing is waiting, the studio is + * asked for a line (input_request) and the read waits for input_reply, as Python's input() does. + */ + private static class KernelInput extends InputStream { + private byte[] pending = new byte[0]; + private int position = 0; + private boolean ended = false; + @Override + public synchronized int read() throws IOException { + if (!fill()) return -1; + return pending[position++] & 0xFF; + } + + @Override + public synchronized int read(byte[] buffer, int offset, int length) throws IOException { + if (length == 0) return 0; + if (!fill()) return -1; + int count = Math.min(length, pending.length - position); + System.arraycopy(pending, position, buffer, offset, count); + position += count; + return count; + } + + @Override + public synchronized int available() { + return pending.length - position; + } + + synchronized void startCell() { + pending = new byte[0]; + position = 0; + ended = false; + inputReplies.clear(); + } + + private boolean fill() throws IOException { + if (position < pending.length) return true; + if (ended) return false; + + String id = currentRequestId; + flushText(); + send(Map.of("type", "input_request", "id", id == null ? "" : id, "prompt", "", "password", false)); + try { + Optional reply = inputReplies.take(); + if (reply.isEmpty()) { + ended = true; + return false; + } + pending = (reply.get() + "\n").getBytes(StandardCharsets.UTF_8); + position = 0; + return true; + } catch (InterruptedException e) { + throw new InterruptedIOException(); + } + } + } + + // What the cell prints is sent in batches (8 KB, or 50 ms), in the order it was printed with what else it sends: a display, an + // error, a question for input. Text is one stream per name (stdout, stderr); a change of name ends the batch. + private static final Object textGate = new Object(); + private static final StringBuilder textBuffer = new StringBuilder(); + private static String textName = "stdout"; + private static String textOwner = null; + + private static void emitText(String name, String text) { + if (text.isEmpty()) return; + synchronized (textGate) { + if (!name.equals(textName) || !Objects.equals(textOwner, currentRequestId)) flushTextLocked(); + textName = name; + textOwner = currentRequestId; + textBuffer.append(text); + if (textBuffer.length() >= 8192) flushTextLocked(); + } + } + + static void flushText() { + synchronized (textGate) { flushTextLocked(); } + } + + private static void flushTextLocked() { + if (textBuffer.length() == 0) return; + String id = textOwner != null ? textOwner : currentRequestId; + String text = textBuffer.toString(); + textBuffer.setLength(0); + if (id != null) sendNow(Map.of("type", "stream", "id", id, "name", textName, "text", text)); + } + + private static boolean couldBeMarker(String partial) { + String marker = "__FRY_DISPLAY__ "; + return partial.isEmpty() || marker.startsWith(partial) || partial.startsWith(marker); + } + + /** + * An output stream of the cell's code. Text goes on at once (a prompt printed without a newline is shown before the cell + * waits for its answer); a line that is a display of Display (marker + JSON) is a protocol message in its place. + */ private static class SnippetOutputStream extends OutputStream { - private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private final String name; + private final ByteArrayOutputStream partial = new ByteArrayOutputStream(); + + SnippetOutputStream(String name) { + this.name = name; + } @Override public synchronized void write(int b) { - if (b == '\n') { - flushLine(); - } else { - buffer.write(b); - } + write(new byte[] { (byte) b }, 0, 1); } @Override public synchronized void write(byte[] b, int off, int len) { for (int i = off; i < off + len; i++) { if (b[i] == '\n') { - flushLine(); + String line = partial.toString(StandardCharsets.UTF_8); + partial.reset(); + line(line); } else { - buffer.write(b[i]); + partial.write(b[i]); + } + } + // an unfinished line that can't be a display line is text now (a prompt); a character cut in half waits + if (partial.size() > 0) { + byte[] bytes = partial.toByteArray(); + int complete = completeUtf8Length(bytes); + String head = new String(bytes, 0, complete, StandardCharsets.UTF_8); + if (!(name.equals("stdout") && couldBeMarker(head)) && !head.isEmpty()) { + emitText(name, head); + partial.reset(); + partial.write(bytes, complete, bytes.length - complete); } } } - private void flushLine() { - String line = buffer.toString(StandardCharsets.UTF_8); - buffer.reset(); - processSnippetLine(line); + private void line(String line) { + if (name.equals("stdout")) { + String trimmed = line.trim(); + int at = line.indexOf("__FRY_DISPLAY__ "); + if (at >= 0) { + String before = trimmed.startsWith("__FRY_DISPLAY__ ") ? "" : line.substring(0, at); + emitText(name, before); + flushText(); + handleDisplayPayload(line.substring(at + "__FRY_DISPLAY__ ".length()).trim()); + return; + } + } + emitText(name, line + "\n"); } @Override public synchronized void flush() { - // Lines are flushed on newline '\n' to prevent splitting long protocol payloads. } @Override public synchronized void close() { - if (buffer.size() > 0) { - flushLine(); + if (partial.size() > 0) { + emitText(name, partial.toString(StandardCharsets.UTF_8)); + partial.reset(); } } } - private static synchronized void processSnippetLine(String line) { - String trimmed = line.trim(); - if (trimmed.startsWith("__FRY_DISPLAY__ ")) { - String payload = trimmed.substring("__FRY_DISPLAY__ ".length()).trim(); - handleDisplayPayload(payload); - return; - } - int markerIdx = line.indexOf("__FRY_DISPLAY__ "); - if (markerIdx >= 0) { - String pre = line.substring(0, markerIdx); - String payload = line.substring(markerIdx + "__FRY_DISPLAY__ ".length()).trim(); - if (!pre.isEmpty() && currentRequestId != null) { - sendStream(currentRequestId, "stdout", pre + "\n"); - } - handleDisplayPayload(payload); - return; - } - - if (currentRequestId != null && !line.isEmpty()) { - sendStream(currentRequestId, "stdout", line + "\n"); - } + // How many bytes of the start of `bytes` are whole UTF-8 characters. + private static int completeUtf8Length(byte[] bytes) { + int end = bytes.length; + int i = end - 1; + int back = 0; + while (i >= 0 && back < 4 && (bytes[i] & 0xC0) == 0x80) { i--; back++; } + if (i < 0) return end; + int lead = bytes[i] & 0xFF; + int need = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1; + return (end - i) < need ? i : end; } public static void handleDisplayPayload(String payload) { @@ -111,8 +230,11 @@ public static void handleDisplayPayload(String payload) { if (targetCellId != null) { toSend = "{\"id\":\"" + targetCellId + "\"," + trimmed.substring(1); } - protocolOut.println(toSend); - protocolOut.flush(); + flushText(); + synchronized (protocolOut) { + protocolOut.println(toSend); + protocolOut.flush(); + } } catch (Exception ex) { System.err.println("[FryKernel] error handling display: " + ex); } @@ -134,7 +256,7 @@ public static void main(String[] args) throws Exception { } } - jshell = JShell.builder().out(snippetPs).err(snippetPs).build(); + jshell = JShell.builder().in(kernelInput).out(snippetPs).err(snippetErrPs).build(); // Default imports evalSnippet("import java.util.*;"); @@ -169,6 +291,16 @@ public static void main(String[] args) throws Exception { } } + // The Object Explorer: what the last expression of a cell shows when it is an object (it needs Display loaded) + File inspectorFile = kernelDir != null ? new File(kernelDir, "FryInspector.java") : new File("FryInspector.java"); + if (inspectorFile.exists()) { + try { + evalSnippet(Files.readString(inspectorFile.toPath())); + } catch (Exception e) { + System.err.println("[FryKernel] Failed loading FryInspector.java: " + e); + } + } + evalSnippet("void display(Object obj) { Display.show(obj); }"); evalSnippet("void dump(Object obj) { Display.show(obj); }"); @@ -235,6 +367,16 @@ public static String toJson(Object o) { "executable", javaExe )); + // Text a cell prints is sent at the latest 50 ms after it was printed + Thread flusher = new Thread(() -> { + while (true) { + try { Thread.sleep(50); } catch (InterruptedException e) { return; } + flushText(); + } + }, "fry-text-flusher"); + flusher.setDaemon(true); + flusher.start(); + // Background stdin reader: dispatches events immediately, queues execute/variables requests Thread readerThread = new Thread(() -> { try { @@ -247,6 +389,15 @@ public static String toJson(Object o) { String type = (String) msg.get("type"); if ("event".equals(type)) { handleEvent(msg, line); + } else if ("input_reply".equals(type)) { + // Read while a cell runs, so not through the queue the running cell holds up. + Object value = msg.get("value"); + inputReplies.put(value instanceof String text ? Optional.of(text) : Optional.empty()); + } else if ("interrupt".equals(type)) { + // The cell that is running ends with an exception, and what the notebook declared stays. The main + // thread is busy with the cell, so this is read here and not through the queue. + interruptRequested = true; + try { jshell.stop(); } catch (Exception ignored) { } } else if ("shutdown".equals(type)) { System.exit(0); } else { @@ -255,6 +406,10 @@ public static String toJson(Object o) { } } catch (Exception ignored) { } + // The studio closed its end (the notebook closed, or the program that started this one is gone): the kernel ends with + // what it started, as the protocol says; nothing else would end it, and the JVM would outlive its owner. + try { jshell.close(); } catch (Exception ignored) { } + System.exit(0); }, "fry-stdin-reader"); readerThread.setDaemon(true); readerThread.start(); @@ -289,6 +444,14 @@ private static void handleMessage(String jsonLine) { handleGetValue(id, (String) msg.get("name")); } else if ("set_value".equals(type)) { handleSetValue(id, (String) msg.get("name"), (String) msg.get("json")); + } else if ("add_search_path".equals(type)) { + // A jar a package command just restored: the classes in it are usable from the next cell on, no restart. + try { + jshell.addToClasspath((String) msg.get("path")); + send(Map.of("type", "reply", "id", id, "status", "ok")); + } catch (Exception ex) { + send(Map.of("type", "reply", "id", id, "status", "error", "message", String.valueOf(ex.getMessage()))); + } } else if ("shutdown".equals(type)) { System.exit(0); } @@ -331,42 +494,129 @@ private static String mainClassOf(String code) { return name; } + // JShell reports the value of a declaration (`int x = 7;`) and of an assignment too; a cell shows the value of an expression + // (`x + 1`, `x`, a call), as in every other language. (An expression is a temporary variable to JShell.) + private static boolean isValueExpression(Snippet snippet) { + Snippet.SubKind kind = snippet.subKind(); + return kind == Snippet.SubKind.TEMP_VAR_EXPRESSION_SUBKIND + || kind == Snippet.SubKind.VAR_VALUE_SUBKIND + || kind == Snippet.SubKind.OTHER_EXPRESSION_SUBKIND; + } + + private static int lineAt(String text, int offset) { + int line = 1; + for (int i = 0; i < Math.min(offset, text.length()); i++) if (text.charAt(i) == '\n') line++; + return line; + } + + private static int columnAt(String text, int offset) { + int clamped = Math.max(0, Math.min(offset, text.length())); + return clamped - (text.lastIndexOf('\n', clamped - 1) + 1) + 1; + } + + // The compile errors of a rejected snippet, on the cell's own lines and columns: the positions JShell gives are in the + // text of the snippet it was handed, which begins at chunkStart of the cell. + private static List diagnosticsOf(Snippet snippet, String code, int chunkStart) { + List list = new ArrayList<>(); + jshell.diagnostics(snippet).forEach(d -> { + long start = d.getStartPosition() >= 0 ? d.getStartPosition() : d.getPosition(); + long end = d.getEndPosition() >= start ? d.getEndPosition() : start; + int from = chunkStart + (int) Math.max(0, start); + int to = chunkStart + (int) Math.max(0, end); + Map item = new LinkedHashMap<>(); + item.put("severity", d.isError() ? "error" : "warning"); + item.put("code", d.getCode()); + item.put("message", d.getMessage(null).trim()); + item.put("line", lineAt(code, from)); + item.put("column", columnAt(code, from)); + item.put("endLine", lineAt(code, to)); + item.put("endColumn", Math.max(columnAt(code, to), columnAt(code, from) + 1)); + list.add(item); + }); + return list; + } + + private static void sendCompileError(String id, Snippet snippet, String code, int chunkStart, int startLine) { + List diagnostics = diagnosticsOf(snippet, code, chunkStart); + StringBuilder text = new StringBuilder(); + String first = ""; + int line = startLine; + for (Object o : diagnostics) { + Map d = (Map) o; + if (first.isEmpty()) { first = String.valueOf(d.get("message")); line = (Integer) d.get("line"); } + text.append("(").append(d.get("line")).append(",").append(d.get("column")).append("): ") + .append(d.get("severity")).append(" ").append(d.get("code")).append(": ").append(d.get("message")).append("\n"); + } + send(sendErrorFields(id, "CompileError", first.isEmpty() ? "compilation failed" : first, text.toString(), line, diagnostics)); + } + + // An exception of the cell's code: what kind it was, what it said, and the line of the cell it went through: the innermost + // frame in a snippet this cell declared or ran (JShell names a frame "#:"). + private static void sendRuntimeError(String id, JShellException exception, int snippetStartLine) { + String kind = exception instanceof EvalException eval ? eval.getExceptionClassName() : exception.getClass().getName(); + String shortKind = kind.substring(kind.lastIndexOf('.') + 1); + int line = snippetStartLine; + StringBuilder trace = new StringBuilder(kind).append(exception.getMessage() != null ? ": " + exception.getMessage() : "").append("\n"); + boolean found = false; + for (StackTraceElement frame : exception.getStackTrace()) { + String file = frame.getFileName(); + if (file == null || !file.startsWith("#")) continue; + Integer start = snippetStartLines.get(file.substring(1)); + if (start == null) continue; + int at = start + Math.max(0, frame.getLineNumber() - 1); + trace.append(" at ").append(frame.getMethodName().isEmpty() ? "" : frame.getMethodName()).append(" (line ").append(at).append(")\n"); + if (!found) { line = at; found = true; } + } + send(sendErrorFields(id, shortKind, exception.getMessage() == null ? "" : exception.getMessage(), trace.toString(), line, null)); + } + private static void handleExecute(String id, String code, String cell) { currentRequestId = id; executionCount++; + interruptRequested = false; + snippetStartLines.clear(); + kernelInput.startCell(); // A program written for a script (imports of the Display package, a class with main) runs here too: Display and // Visualizer are already loaded, and a class that declares main has it called once it is declared. String mainClass = mainClassOf(code); - code = code.replaceAll("(?m)^\\s*import\\s+(com\\.frypdf\\.display|fry)\\.[\\w*]+\\s*;[ \\t]*$", ""); + // (the line is emptied, not removed, and spaces and tabs only: the lines after it keep their numbers) + code = code.replaceAll("(?m)^[ \\t]*import\\s+(com\\.frypdf\\.display|fry)\\.[\\w*]+\\s*;[ \\t]*$", ""); SourceCodeAnalysis sca = jshell.sourceCodeAnalysis(); String remaining = code; boolean hasError = false; String lastValue = null; + Snippet lastSnippet = null; while (!remaining.trim().isEmpty()) { SourceCodeAnalysis.CompletionInfo info = sca.analyzeCompletion(remaining); if (info.source().trim().isEmpty()) break; + int chunkStart = code.length() - remaining.length(); + int leading = 0; + while (leading < info.source().length() && Character.isWhitespace(info.source().charAt(leading))) leading++; + int startLine = lineAt(code, chunkStart + leading); + List events = jshell.eval(info.source()); for (SnippetEvent e : events) { + snippetStartLines.put(e.snippet().id(), startLine); if (e.status() == Snippet.Status.REJECTED) { hasError = true; - StringBuilder diag = new StringBuilder(); - jshell.diagnostics(e.snippet()).forEach(d -> { - diag.append(d.getMessage(null)).append("\n"); - }); - sendError(id, "CompileError", diag.toString(), cell); + sendCompileError(id, e.snippet(), code, chunkStart, startLine); break; } if (e.exception() != null) { hasError = true; - sendError(id, e.exception().getClass().getSimpleName(), e.exception().getMessage(), cell); + if (interruptRequested) break; + sendRuntimeError(id, e.exception(), startLine); break; } - if (e.value() != null && !e.value().isEmpty()) { + // The value of an expression is shown, as in the other languages; a declaration (`int x = 7;`) is not an + // expression, though JShell reports its value too. + if (e.value() != null && !e.value().isEmpty() && isValueExpression(e.snippet())) { lastValue = e.value(); + lastSnippet = e.snippet(); } } @@ -378,26 +628,33 @@ private static void handleExecute(String id, String code, String cell) { for (SnippetEvent e : jshell.eval(mainClass + ".main(new String[0]);")) { if (e.status() == Snippet.Status.REJECTED) { hasError = true; - StringBuilder diag = new StringBuilder(); - jshell.diagnostics(e.snippet()).forEach(d -> diag.append(d.getMessage(null)).append("\n")); - sendError(id, "CompileError", diag.toString(), cell); + sendCompileError(id, e.snippet(), mainClass + ".main(new String[0]);", 0, 1); break; } if (e.exception() != null) { hasError = true; - sendError(id, e.exception().getClass().getSimpleName(), e.exception().getMessage(), cell); + if (interruptRequested) break; + snippetStartLines.put(e.snippet().id(), 1); + sendRuntimeError(id, e.exception(), 1); break; } } } snippetPs.flush(); + snippetErrPs.flush(); + flushText(); - if (!hasError && lastValue != null) { + // The last expression: an object opens in the Object Explorer, as a C# value does; a scalar, null, or an object + // with nothing to list is JShell's own text. + if (!hasError && !interruptRequested && lastValue != null && !showAsObject(lastSnippet)) { sendStream(id, "stdout", lastValue + "\n"); } - if (hasError) { + // (a stopped cell can end without an exception: JShell just returns) + if (interruptRequested) { + send(Map.of("type", "reply", "id", id, "status", "interrupted")); + } else if (hasError) { send(Map.of("type", "reply", "id", id, "status", "error")); } else { send(Map.of("type", "reply", "id", id, "status", "ok", "executionCount", executionCount)); @@ -406,6 +663,26 @@ private static void handleExecute(String id, String code, String cell) { currentRequestId = null; } + // True when the value of this snippet (the temporary JShell makes for an expression, or a variable named on its own) + // was shown by FryInspector, which does not for a scalar, null, or an object with nothing to list. + private static boolean showAsObject(Snippet snippet) { + String name = null; + if (snippet instanceof VarSnippet v && v.subKind() == Snippet.SubKind.TEMP_VAR_EXPRESSION_SUBKIND) { + name = v.name(); + } else if (snippet instanceof ExpressionSnippet x && x.subKind() == Snippet.SubKind.VAR_VALUE_SUBKIND) { + name = x.name(); + } + if (name == null) return false; + + try { + List events = jshell.eval("FryInspector.show(" + name + ");"); + snippetPs.flush(); + return !events.isEmpty() && events.get(0).status() == Snippet.Status.VALID && "true".equals(events.get(0).value()); + } catch (Exception ex) { + return false; + } + } + private static void handleVariables(String id) { List> vars = new ArrayList<>(); for (VarSnippet v : jshell.variables().toList()) { @@ -460,14 +737,30 @@ private static String unquote(String raw) { } private static void sendStream(String id, String name, String text) { - send(Map.of("type", "stream", "id", id, "name", name, "text", text)); + sendNow(Map.of("type", "stream", "id", id, "name", name, "text", text)); } - private static void sendError(String id, String ename, String evalue, String cell) { - send(Map.of("type", "error", "id", id, "ename", ename, "evalue", evalue, "traceback", evalue, "line", 1)); + // An `error` message: what failed, the text to show, the failing line of the cell, and (for a compile error) the + // diagnostics with their columns. + private static Map sendErrorFields(String id, String ename, String evalue, String traceback, int line, List diagnostics) { + Map message = new LinkedHashMap<>(); + message.put("type", "error"); + message.put("id", id); + message.put("ename", ename); + message.put("evalue", evalue); + message.put("traceback", traceback); + message.put("line", line); + if (diagnostics != null && !diagnostics.isEmpty()) message.put("diagnostics", diagnostics); + return message; } + // A message of the protocol: after the text the cell printed before it. private static void send(Map map) { + if (!"stream".equals(map.get("type"))) flushText(); + sendNow(map); + } + + private static void sendNow(Map map) { StringBuilder sb = new StringBuilder("{"); int i = 0; for (Map.Entry e : map.entrySet()) { @@ -475,8 +768,10 @@ private static void send(Map map) { sb.append("\"").append(e.getKey()).append("\":").append(formatJsonValue(e.getValue())); } sb.append("}\n"); - protocolOut.print(sb.toString()); - protocolOut.flush(); + synchronized (protocolOut) { + protocolOut.print(sb.toString()); + protocolOut.flush(); + } } private static String formatJsonValue(Object o) { diff --git a/Services/Languages/Java/MavenCentralResolver.cs b/FrySharp.Core/Services/Languages/Java/MavenCentralResolver.cs similarity index 100% rename from Services/Languages/Java/MavenCentralResolver.cs rename to FrySharp.Core/Services/Languages/Java/MavenCentralResolver.cs diff --git a/Services/Languages/Java/MavenPackageManager.cs b/FrySharp.Core/Services/Languages/Java/MavenPackageManager.cs similarity index 100% rename from Services/Languages/Java/MavenPackageManager.cs rename to FrySharp.Core/Services/Languages/Java/MavenPackageManager.cs diff --git a/Services/Languages/Java/MavenPackageMap.cs b/FrySharp.Core/Services/Languages/Java/MavenPackageMap.cs similarity index 100% rename from Services/Languages/Java/MavenPackageMap.cs rename to FrySharp.Core/Services/Languages/Java/MavenPackageMap.cs diff --git a/Services/Languages/Java/Runtime/Display.java b/FrySharp.Core/Services/Languages/Java/Runtime/Display.java similarity index 88% rename from Services/Languages/Java/Runtime/Display.java rename to FrySharp.Core/Services/Languages/Java/Runtime/Display.java index edf951f2..d19a0bfc 100644 --- a/Services/Languages/Java/Runtime/Display.java +++ b/FrySharp.Core/Services/Languages/Java/Runtime/Display.java @@ -1071,42 +1071,155 @@ public static String escapeJson(String s) { return sb.toString(); } - private static String formatTable(String title, Object obj) { + // A value that is one cell of a table, or one line of text: not an object to open up. + public static boolean isScalarValue(Object v) { + return v == null || v instanceof CharSequence || v instanceof Character || v instanceof Boolean || v instanceof Number + || v instanceof Enum || v instanceof UUID || v instanceof java.util.Date + || v instanceof java.time.temporal.TemporalAccessor || v instanceof java.time.temporal.TemporalAmount + || v instanceof Class || v instanceof java.nio.file.Path || v instanceof File || v instanceof URI; + } + + // What is read from an object for a row of a table: a record's components, else the public fields and the public getters + // (getX(), isX()) that no field covers. + private static LinkedHashMap> columnsOf(Object sample) { + Class type = sample.getClass(); + LinkedHashMap> columns = new LinkedHashMap<>(); + if (type.isRecord()) { + for (RecordComponent component : type.getRecordComponents()) { + Method accessor = component.getAccessor(); + accessor.setAccessible(true); + columns.put(component.getName(), () -> accessor.invoke(CURRENT.get())); + } + return columns; + } + for (Field field : type.getFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue; + columns.put(field.getName(), () -> field.get(CURRENT.get())); + } + List getters = new ArrayList<>(); + for (Method m : type.getMethods()) { + String name = m.getName(); + if (Modifier.isStatic(m.getModifiers()) || m.isSynthetic() || m.isBridge() || m.getParameterCount() != 0 || m.getDeclaringClass() == Object.class) continue; + Class returns = m.getReturnType(); + if (returns == void.class || name.equals("getClass") || name.equals("hashCode") || name.equals("toString")) continue; + if ((name.startsWith("get") && name.length() > 3) || (name.startsWith("is") && name.length() > 2 && (returns == boolean.class || returns == Boolean.class))) getters.add(m); + } + getters.sort(Comparator.comparing(Method::getName)); + for (Method getter : getters) { + String name = getter.getName().startsWith("get") ? getter.getName().substring(3) : getter.getName().substring(2); + name = name.length() > 1 && Character.isUpperCase(name.charAt(1)) ? name : Character.toLowerCase(name.charAt(0)) + name.substring(1); + if (columns.containsKey(name)) continue; + getter.setAccessible(true); + columns.put(name, () -> getter.invoke(CURRENT.get())); + } + return columns; + } + + // The object a column reader is reading (the readers are made once for a class and then read each row's object). + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private static String cellText(Object v) { + if (v == null) return ""; + if (v instanceof Double d && (d.isNaN() || d.isInfinite())) return d.isNaN() ? "NaN" : d > 0 ? "∞" : "-∞"; + return String.valueOf(v); + } + + /** + * The studio's table for a collection or array of maps, records and beans (a column for each key, component, field or + * getter), or of scalars (a column of values); null when the value is not a collection. Rows beyond 1,000 are left out and + * counted. + */ + public static String tableJson(String title, Object obj) { + List list = toList(obj); + if (list == null) return null; Map table = new LinkedHashMap<>(); table.put("title", title != null ? title : "Table"); List cols = new ArrayList<>(); List numeric = new ArrayList<>(); List> rows = new ArrayList<>(); - List list = toList(obj); - if (list != null && !list.isEmpty() && list.get(0) instanceof Map first) { + int shown = Math.min(list.size(), 1000); + Object sample = null; + for (Object item : list) { if (item != null) { sample = item; break; } } + + if (sample instanceof Map first) { for (Object k : first.keySet()) { cols.add(String.valueOf(k)); numeric.add(first.get(k) instanceof Number); } - for (Object item : list) { - if (item instanceof Map r) { - List row = new ArrayList<>(); - for (String c : cols) row.add(r.get(c)); - rows.add(row); + for (Object item : list.subList(0, shown)) { + List row = new ArrayList<>(); + for (String c : cols) row.add(item instanceof Map r ? cellText(r.get(c)) : ""); + rows.add(row); + } + } else if (sample != null && !isScalarValue(sample) && !(sample instanceof Collection) && !sample.getClass().isArray() + && !columnsOf(sample).isEmpty()) { + Class type = sample.getClass(); + LinkedHashMap> columns = columnsOf(sample); + cols.addAll(columns.keySet()); + boolean[] allNumbers = new boolean[cols.size()]; + Arrays.fill(allNumbers, true); + boolean[] anyValue = new boolean[cols.size()]; + for (Object item : list.subList(0, shown)) { + List row = new ArrayList<>(); + int c = 0; + for (java.util.concurrent.Callable reader : columns.values()) { + Object value = null; + if (item != null && item.getClass() == type) { + CURRENT.set(item); + try { value = reader.call(); } catch (Throwable t) { value = ""; } + } + if (value != null) { anyValue[c] = true; if (!(value instanceof Number)) allNumbers[c] = false; } + row.add(cellText(value)); + c++; } + rows.add(row); } + CURRENT.remove(); + for (int c = 0; c < cols.size(); c++) numeric.add(allNumbers[c] && anyValue[c]); } else { cols.add("Value"); - numeric.add(false); - if (list != null) { - for (Object item : list) rows.add(List.of(String.valueOf(item))); - } else { - rows.add(List.of(String.valueOf(obj))); + boolean allNumbers = !list.isEmpty(); + for (Object item : list.subList(0, shown)) { + if (item != null && !(item instanceof Number)) allNumbers = false; + rows.add(List.of(cellText(item))); } + numeric.add(allNumbers); } table.put("columns", cols); table.put("numeric", numeric); table.put("rows", rows); - table.put("totalRows", rows.size()); + table.put("totalRows", list.size()); table.put("totalColumns", cols.size()); return toJson(table); } + private static String formatTable(String title, Object obj) { + String json = tableJson(title, obj); + if (json != null) return json; + // not a collection: one value in a table of one cell + Map table = new LinkedHashMap<>(); + table.put("title", title != null ? title : "Table"); + table.put("columns", List.of("Value")); + table.put("numeric", List.of(obj instanceof Number)); + table.put("rows", List.of(List.of(cellText(obj)))); + table.put("totalRows", 1); + table.put("totalColumns", 1); + return toJson(table); + } + + /** Shows a collection of records, beans or maps as a table (what a cell ending in such a list is shown as). False when the value is not one. */ + public static boolean showTableIfTabular(Object obj) { + List list = toList(obj); + if (list == null || list.isEmpty()) return false; + Object sample = null; + for (Object item : list) { if (item != null) { sample = item; break; } } + if (sample == null || isScalarValue(sample) || sample instanceof Collection || sample.getClass().isArray()) return false; + if (!(sample instanceof Map) && columnsOf(sample).isEmpty()) return false; + String title = (sample instanceof Map ? "Map" : sample.getClass().getSimpleName()) + " (" + list.size() + ")"; + emitRaw("{\"type\":\"display\",\"data\":{\"" + TABLE_MIME + "\":" + tableJson(title, obj) + ",\"text/plain\":" + toJson(title) + "},\"metadata\":{}}"); + return true; + } + public static class JsonParser { private final String src; private int pos = 0; diff --git a/Services/Languages/Java/Runtime/Visualizer.java b/FrySharp.Core/Services/Languages/Java/Runtime/Visualizer.java similarity index 100% rename from Services/Languages/Java/Runtime/Visualizer.java rename to FrySharp.Core/Services/Languages/Java/Runtime/Visualizer.java diff --git a/FrySharp.Core/Services/Languages/JavaScript/Kernel/fry_kernel.js b/FrySharp.Core/Services/Languages/JavaScript/Kernel/fry_kernel.js index 2367d9e8..6584259f 100644 --- a/FrySharp.Core/Services/Languages/JavaScript/Kernel/fry_kernel.js +++ b/FrySharp.Core/Services/Languages/JavaScript/Kernel/fry_kernel.js @@ -211,7 +211,10 @@ async function handleExecute(req) { } if (result !== undefined && !(result && result._fry_shown)) { - streamOutput("stdout", util.inspect(result, { depth: 3 }) + "\n"); + // An object opens in the Object Explorer, as a C# value does; anything else is text. + const inspected = fry.inspectorBundle(result); + if (inspected) send({ type: "display", id: req.id, data: inspected, metadata: {} }); + else streamOutput("stdout", util.inspect(result, { depth: 3 }) + "\n"); } send({ diff --git a/FrySharp.Core/Services/Languages/JavaScript/Runtime/fry.js b/FrySharp.Core/Services/Languages/JavaScript/Runtime/fry.js index cb4e633e..8a101f18 100644 --- a/FrySharp.Core/Services/Languages/JavaScript/Runtime/fry.js +++ b/FrySharp.Core/Services/Languages/JavaScript/Runtime/fry.js @@ -16,6 +16,7 @@ const CHART_MIME = 'application/vnd.fry.chart.v1+json'; const PLOT3D_MIME = 'application/vnd.fry.plot3d.v1+json'; const VISUALIZER_MIME = 'application/vnd.fry.visualizer.v1+json'; const TABLE_MIME = 'application/vnd.fry.table+json'; +const INSPECTOR_MIME = 'application/vnd.fry.inspector+json'; const DISPLAY_MARKER = '__FRY_DISPLAY__'; // ---------------------------------------------------------------------------------------------------------- channel @@ -955,6 +956,118 @@ class CanvasVisualizer { } } +// ------------------------------------------------------------------------------------------------- Object Explorer +// What the last expression of a notebook cell shows when it is an object, the way the studio shows a C# value: a tree +// of its own properties and the getters of its class (a Map's entries, a collection's items), each nested object a +// node that opens. {title, properties: [{name, value, isString, isNull, child}]}; null for what reads best as text. +const MAX_INSPECT_DEPTH = 3; // levels of objects below the one shown, as the C# inspector +const MAX_INSPECT_ITEMS = 100; // items of a collection listed +const MAX_INSPECT_INLINE = 50; // scalars of an array on one line + +function isScalarValue(v) { + if (v === null || v === undefined) return true; + const type = typeof v; + if (type !== 'object') return true; // number, string, boolean, bigint, symbol, and functions (shown as text) + // util.types, not instanceof: a cell's values come from the notebook's vm context, which has its own Map and Date. + return util.types.isDate(v) || util.types.isRegExp(v) || util.types.isNativeError(v) || util.types.isPromise(v) || + util.types.isBoxedPrimitive(v) || util.types.isAnyArrayBuffer(v) || ArrayBuffer.isView(v); +} + +function scalarText(v) { + if (typeof v === 'string') return v; + if (typeof v === 'number') { + if (Number.isNaN(v)) return 'NaN'; + if (!Number.isFinite(v)) return v > 0 ? '∞' : '-∞'; + return String(v); + } + return util.inspect(v); +} + +// "[ 1, 2, 3 ]" for an array whose items are all scalars; null for any other value. +function inlineScalars(v) { + if (!Array.isArray(v)) return null; + const parts = []; + for (let i = 0; i < v.length; i++) { + if (i > MAX_INSPECT_INLINE) { parts.push('...'); break; } + const item = v[i]; + if (item === null || item === undefined) parts.push(''); + else if (isScalarValue(item) && typeof item !== 'function') parts.push(scalarText(item)); + else return null; + } + return '[ ' + parts.join(', ') + ' ]'; +} + +function inspectMembers(obj) { + if (util.types.isMap(obj)) return [...obj.entries()].map(([k, v]) => [String(k), v]); + if (util.types.isSet(obj) || Array.isArray(obj)) return [...obj].map((v, i) => ['[' + i + ']', v]); + + const members = new Map(); + for (const key of Object.keys(obj)) { + let value; + try { value = obj[key]; } catch (e) { value = { error: e }; } + if (typeof value !== 'function') members.set(key, value); + } + // The getters of its class, as properties are in C#. + for (let proto = Object.getPrototypeOf(obj); proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) { + for (const name of Object.getOwnPropertyNames(proto)) { + if (name === 'constructor' || name === '__proto__' || members.has(name)) continue; + const descriptor = Object.getOwnPropertyDescriptor(proto, name); + if (!descriptor || !descriptor.get) continue; + try { members.set(name, obj[name]); } catch (e) { members.set(name, { error: e }); } + } + } + return [...members.entries()]; +} + +function inspectNode(obj, depth, path, circular) { + const title = (obj && obj.constructor && obj.constructor.name) || 'Object'; + const node = { title, properties: [] }; + if (circular) node.circular = true; // a reference back to an object above: its members are listed again, down to the depth limit + const rows = node.properties; + inspectMembers(obj).forEach(([name, value], index) => { + if (index === MAX_INSPECT_ITEMS) rows.push({ name: '...', value: `(${index}+ items truncated)` }); + else if (index < MAX_INSPECT_ITEMS) rows.push(inspectRow(name, value, depth + 1, path)); + }); + return node; +} + +function inspectRow(name, value, depth, path) { + const row = { name }; + if (value !== null && typeof value === 'object' && util.types.isNativeError(value.error) && Object.keys(value).length === 1) { + row.value = ``; + } else if (value === null || value === undefined) { + row.value = ''; + row.isNull = true; + } else if (typeof value === 'function') { + row.value = util.inspect(value); + } else if (isScalarValue(value)) { + row.value = scalarText(value); + row.isString = typeof value === 'string'; + } else if (inlineScalars(value) !== null) { + row.value = inlineScalars(value); + } else if (depth > MAX_INSPECT_DEPTH) { + row.child = { title: (value.constructor && value.constructor.name) || 'Object', properties: [] }; + } else { + const circular = path.has(value); + path.add(value); + try { row.child = inspectNode(value, depth, path, circular); } finally { if (!circular) path.delete(value); } + } + return row; +} + +// The display bundle for the last expression of a cell, or null when its value is text. +function inspectorBundle(value) { + if (isScalarValue(value) || inlineScalars(value) !== null) return null; + let node; + try { + node = inspectNode(value, 0, new Set([value]), false); + } catch (e) { + return null; + } + if (node.properties.length === 0) return null; + return { [INSPECTOR_MIME]: node, 'text/plain': util.inspect(value, { depth: 3 }) }; +} + function dump(obj, title) { return Display.dump(obj, title); } function display(...objects) { for (const o of objects) Display.dump(o); } @@ -963,6 +1076,7 @@ module.exports = { DisplayHandle, dump, display, + inspectorBundle, setChannel, postEvent, runPendingEvents, diff --git a/FrySharp.Core/Services/Languages/Python/Runtime/fry_display.py b/FrySharp.Core/Services/Languages/Python/Runtime/fry_display.py index a6f9eb17..fa631d54 100644 --- a/FrySharp.Core/Services/Languages/Python/Runtime/fry_display.py +++ b/FrySharp.Core/Services/Languages/Python/Runtime/fry_display.py @@ -2,18 +2,23 @@ A value is shown the way Jupyter would show it, as a MIME bundle, using the first of these it has: _repr_mimebundle_, a pandas DataFrame/Series (as the studio's own table), _repr_png_ / _repr_jpeg_, _repr_svg_, -_repr_html_, a matplotlib Figure (as a PNG), and otherwise its repr. Also turns values into JSON for #!share. +_repr_html_, a matplotlib Figure (as a PNG), an object or dict (as the Object Explorer's tree), and otherwise its repr. Also turns values into JSON for #!share. """ import base64 +import collections import dataclasses import datetime import decimal +import enum import io import json import math +import pathlib import pprint import sys +import types +import uuid MAX_TEXT = 100_000 MAX_IMAGE_BYTES = 20 * 1024 * 1024 @@ -22,6 +27,12 @@ MAX_CELL_TEXT = 200 TABLE_MIME = "application/vnd.fry.table+json" +INSPECTOR_MIME = "application/vnd.fry.inspector+json" + +# The Object Explorer: levels of objects shown below the first, items of a collection listed, scalars on one line +MAX_INSPECT_DEPTH = 3 +MAX_INSPECT_ITEMS = 100 +MAX_INSPECT_INLINE = 50 _publisher = None @@ -98,6 +109,10 @@ def to_mime(obj): if _is_figure(obj): return figure_bundle(obj, close=True) + node = inspector(obj) + if node is not None: + return {INSPECTOR_MIME: node, "text/plain": _text(obj)}, {} + return {"text/plain": _text(obj)}, {} @@ -265,6 +280,136 @@ def _cell(value): return text if len(text) <= MAX_CELL_TEXT else text[:MAX_CELL_TEXT - 1] + "…" +def inspector(obj): + """The Object Explorer's tree for an object, as the studio shows a C# value: {title, properties: [{name, value, + isString, isNull, child}]}. None for a value that reads best as text: a scalar, a function or class, a short + list of scalars, or an object with nothing to list.""" + if _is_scalar(obj) or callable(obj) or isinstance(obj, (type, types.ModuleType)): + return None + if _inline(obj) is not None: + return None + try: + node = _inspect_node(obj, 0, {id(obj)}) + except Exception: + return None + return node if node["properties"] else None + + +def _is_scalar(value): + if value is None or isinstance(value, (bool, int, float, complex, str, bytes, bytearray, range, + decimal.Decimal, datetime.date, datetime.time, datetime.timedelta, + enum.Enum, uuid.UUID, pathlib.PurePath)): + return True + numpy = sys.modules.get("numpy") + return numpy is not None and isinstance(value, numpy.generic) + + +def _scalar_text(value): + if isinstance(value, float): + if math.isnan(value): + return "NaN" + if math.isinf(value): + return "∞" if value > 0 else "-∞" + if isinstance(value, enum.Enum): + return value.name + return str(value) + + +def _inline(value): + """One line for a list, tuple or set whose items are all scalars (or None), as '[ 1, 2, 3 ]'; else None.""" + if not isinstance(value, (list, tuple, set, frozenset, collections.deque)) or hasattr(value, "_fields"): + return None + parts = [] + for index, item in enumerate(value): + if index > MAX_INSPECT_INLINE: + parts.append("...") + break + if item is None: + parts.append("") + elif _is_scalar(item): + parts.append(_scalar_text(item)) + else: + return None + return "[ " + ", ".join(parts) + " ]" + + +def _inspect_node(obj, depth, path, circular=False): + node = {"title": type(obj).__name__, "properties": []} + if circular: + node["circular"] = True # a reference back to an object above: its members are listed again, down to the depth limit + rows = node["properties"] + for index, (name, value) in enumerate(_members(obj)): + if index >= MAX_INSPECT_ITEMS: + rows.append({"name": "...", "value": f"({index}+ items truncated)"}) + break + rows.append(_inspect_row(name, value, depth + 1, path)) + return node + + +def _inspect_row(name, value, depth, path): + row = {"name": name} + if isinstance(value, _MemberError): + row["value"] = f"" + elif value is None: + row["value"] = "" + row["isNull"] = True + elif _is_scalar(value): + row["value"] = _scalar_text(value) + row["isString"] = isinstance(value, str) + elif callable(value) or isinstance(value, (type, types.ModuleType)): + row["value"] = _text(value) + elif (inline := _inline(value)) is not None: + row["value"] = inline + elif depth > MAX_INSPECT_DEPTH: + row["child"] = {"title": type(value).__name__, "properties": []} + else: + circular = id(value) in path + if not circular: + path.add(id(value)) + try: + row["child"] = _inspect_node(value, depth, path, circular) + finally: + if not circular: + path.discard(id(value)) + return row + + +class _MemberError: + def __init__(self, error): + self.message = str(error) + + +def _members(obj): + """(name, value) pairs an object lists: a dict's items, a collection's items by index, otherwise its public + attributes (a namedtuple's or dataclass's fields, __dict__, __slots__) and the properties of its class.""" + if isinstance(obj, dict): + return [(str(key), value) for key, value in obj.items()] + if isinstance(obj, tuple) and hasattr(obj, "_fields"): + return list(zip(obj._fields, obj)) + if isinstance(obj, (list, tuple, set, frozenset, collections.deque)): + return [(f"[{index}]", item) for index, item in enumerate(obj)] + + members = {} + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + for field in dataclasses.fields(obj): + members[field.name] = getattr(obj, field.name, None) + for name, value in getattr(obj, "__dict__", {}).items(): + if not name.startswith("_"): + members.setdefault(name, value) + for klass in type(obj).__mro__: + slots = klass.__dict__.get("__slots__", ()) + for name in ((slots,) if isinstance(slots, str) else slots): + if not name.startswith("_") and name not in members and hasattr(obj, name): + members[name] = getattr(obj, name) + for name, attribute in klass.__dict__.items(): + if isinstance(attribute, property) and not name.startswith("_") and name not in members: + try: + members[name] = getattr(obj, name) + except Exception as error: + members[name] = _MemberError(error) + return list(members.items()) + + def _text(obj): try: text = pprint.pformat(obj, width=100, compact=True) if isinstance(obj, (dict, list, tuple, set, frozenset)) else repr(obj) diff --git a/Services/Languages/Rust/Runtime/Cargo.toml b/FrySharp.Core/Services/Languages/Rust/Runtime/Cargo.toml similarity index 100% rename from Services/Languages/Rust/Runtime/Cargo.toml rename to FrySharp.Core/Services/Languages/Rust/Runtime/Cargo.toml diff --git a/Services/Languages/Rust/Runtime/lib.rs b/FrySharp.Core/Services/Languages/Rust/Runtime/lib.rs similarity index 94% rename from Services/Languages/Rust/Runtime/lib.rs rename to FrySharp.Core/Services/Languages/Rust/Runtime/lib.rs index c61e9db0..7bae7d6b 100644 --- a/Services/Languages/Rust/Runtime/lib.rs +++ b/FrySharp.Core/Services/Languages/Rust/Runtime/lib.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fmt::Debug; use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, Once}; use std::thread; use std::time::{Duration, Instant}; @@ -18,9 +18,9 @@ pub const TABLE_MIME: &str = "application/vnd.fry.table+json"; pub mod prelude { pub use crate::{ array, bar_chart, bars, chart, dump, graph, graph3d, histogram, html, image, image_file, - islands, json, line_chart, linked_list, matrix, pie_chart, process_events, scatter3d, - scatter_chart, share, show, surface3d, table, tree, wait, DisplayHandle, Event, Json, - ListNode, TreeNode, + input, islands, json, line_chart, linked_list, markdown, matrix, password, pie_chart, + process_events, scatter3d, scatter_chart, share, show, surface3d, table, tree, wait, + DisplayHandle, Event, Json, ListNode, TreeNode, }; } @@ -1743,7 +1743,7 @@ pub fn dump(data: &T, title: &str) { Some(node) if node.is_simple() => { emit("text/plain", &json_string(&format!("{}: {}\n", title, node.render(false)))); } - Some(node) => emit_table(title, &shape(&node)), + Some(node) => show_node(&node, title), None => emit("text/plain", &json_string(&format!("{}: {}\n", title, text))), } } @@ -1801,11 +1801,50 @@ pub fn show(data: &T) { Some(node) if node.is_simple() || node.wraps_simple() => { emit("text/plain", &json_string(&format!("{}\n", node.render(true)))) } - Some(node) => emit_table("", &shape(&node)), + Some(node) => show_node(&node, ""), None => emit("text/plain", &json_string(&format!("{}\n", text))), } } +static INPUT_ENDED: AtomicBool = AtomicBool::new(false); + +/// Asks for a line of input and returns it, without the line break. `None` is the end of input. In a notebook cell the notebook shows +/// the prompt and the user answers there (as for `input()` in Python); a plain `std::io::stdin()` read can't ask, so it sees the +/// end of input at once, and this is how a cell lets the user type something. In a program run on its own the prompt is printed and +/// the line is read from the terminal. +pub fn input(prompt: &str) -> Option { + ask(prompt, false) +} + +/// Like [`input`], for a secret: what is typed is not shown. +pub fn password(prompt: &str) -> Option { + ask(prompt, true) +} + +fn ask(prompt: &str, secret: bool) -> Option { + if INPUT_ENDED.load(Ordering::SeqCst) { + return None; + } + if std::env::var_os("FRY_INPUT_REQUESTS").is_some() { + println!("__FRY_INPUT__ {{\"prompt\":{},\"password\":{}}}", json_string(prompt), secret); + } else { + print!("{}", prompt); + } + let _ = std::io::stdout().flush(); + let mut line = String::new(); + match std::io::stdin().lock().read_line(&mut line) { + Ok(0) | Err(_) => { + INPUT_ENDED.store(true, Ordering::SeqCst); + None + } + Ok(_) => Some(line.trim_end_matches(|c| c == '\r' || c == '\n').to_string()), + } +} + +pub fn markdown(text: &str) { + emit_bundle(&[("text/markdown", json_string(text)), ("text/plain", json_string(text))]); +} + pub fn share(name: &str, data: &T) { let text = format!("{:#?}", data); let json = match parse(&text) { @@ -2446,6 +2485,105 @@ fn shape(root: &Node) -> Table { } } +const MAX_INLINE: usize = 50; +const MAX_INSPECT_ITEMS: usize = 100; +const MAX_INSPECT_DEPTH: usize = 3; +const INSPECTOR_MIME: &str = "application/vnd.fry.inspector+json"; + +fn emit_bundle(pairs: &[(&str, String)]) { + let data: Vec = pairs.iter().map(|(mime, json)| format!("{}:{}", json_string(mime), json)).collect(); + println!( + "__FRY_DISPLAY__ {{\"type\":\"display\",\"data\":{{{}}},\"metadata\":{{}}}}", + data.join(",") + ); +} + +// A value that is one cell or one line of text: a number, text, a name, or Some of one. +fn scalar_like(node: &Node) -> bool { + node.is_simple() || node.wraps_simple() +} + +// A short list of scalars is one line, `[ 1, 2, 3 ]`, as in the notebook's other languages. +fn inline_list(items: &[Node]) -> Option { + if items.len() > MAX_INLINE || !items.iter().all(scalar_like) { + return None; + } + let parts: Vec = items.iter().map(|item| item.render(false)).collect(); + Some(format!("[ {} ]", parts.join(", "))) +} + +// What a value that isn't a scalar is shown as: an empty or short list as text, a list of records (or of anything else) and a map +// as the studio's table, and one object (a struct, an enum variant with data, a tuple) as the Object Explorer's tree. +fn show_node(node: &Node, title: &str) { + match node { + Node::List(items) | Node::Set(items) => { + if items.is_empty() { + emit("text/plain", &json_string("[ ]\n")); + } else if let Some(line) = inline_list(items) { + emit("text/plain", &json_string(&format!("{}\n", line))); + } else { + emit_table(title, &shape(node)); + } + } + Node::Map(entries) => { + if entries.is_empty() { + emit("text/plain", &json_string("{ }\n")); + } else { + emit_table(title, &shape(node)); + } + } + _ => emit_bundle(&[(INSPECTOR_MIME, inspector_json(node, 0)), ("text/plain", json_string(&node.render(false)))]), + } +} + +// The Object Explorer's data for a value: {title, properties: [{name, value, isNull, isString, child}]}. +fn inspector_json(node: &Node, depth: usize) -> String { + let (title, rows): (String, Vec<(String, &Node)>) = match node { + Node::Struct(name, fields) => (name.clone(), fields.iter().map(|(k, v)| (k.clone(), v)).collect()), + Node::Tuple(Some(name), items) => (name.clone(), items.iter().enumerate().map(|(i, v)| (i.to_string(), v)).collect()), + Node::Tuple(None, items) => ("Tuple".to_string(), items.iter().enumerate().map(|(i, v)| (i.to_string(), v)).collect()), + Node::List(items) => ("List".to_string(), items.iter().enumerate().map(|(i, v)| (format!("[{}]", i), v)).collect()), + Node::Set(items) => ("Set".to_string(), items.iter().enumerate().map(|(i, v)| (format!("[{}]", i), v)).collect()), + Node::Map(entries) => ("Map".to_string(), entries.iter().map(|(k, v)| (k.render_plain(), v)).collect()), + other => (other.render_plain(), Vec::new()), + }; + + let mut out = String::from("{\"title\":"); + out.push_str(&json_string(&title)); + out.push_str(",\"properties\":["); + for (i, (name, value)) in rows.iter().take(MAX_INSPECT_ITEMS).enumerate() { + if i > 0 { + out.push(','); + } + out.push_str("{\"name\":"); + out.push_str(&json_string(name)); + if scalar_like(value) { + let is_null = matches!(value, Node::Ident(w) if w == "None"); + let is_string = matches!(value, Node::Text(_)); + out.push_str(&format!( + ",\"value\":{},\"isNull\":{},\"isString\":{}", + json_string(&value.render_plain()), + is_null, + is_string + )); + } else if depth >= MAX_INSPECT_DEPTH { + out.push_str(&format!(",\"child\":{{\"title\":{},\"properties\":[]}}", json_string(&value.render_plain()))); + } else { + out.push_str(",\"child\":"); + out.push_str(&inspector_json(value, depth + 1)); + } + out.push('}'); + } + if rows.len() > MAX_INSPECT_ITEMS { + out.push_str(&format!( + ",{{\"name\":\"…\",\"value\":{},\"isNull\":false,\"isString\":false}}", + json_string(&format!("(more than {} shown)", MAX_INSPECT_ITEMS)) + )); + } + out.push_str("]}"); + out +} + fn shape_list(items: &[Node]) -> Table { let items = &items[..items.len().min(MAX_ROWS)]; if items.iter().all(|it| matches!(it, Node::Struct(_, _))) { diff --git a/Services/Languages/Rust/RustBuildAndRunScriptRunner.cs b/FrySharp.Core/Services/Languages/Rust/RustBuildAndRunScriptRunner.cs similarity index 100% rename from Services/Languages/Rust/RustBuildAndRunScriptRunner.cs rename to FrySharp.Core/Services/Languages/Rust/RustBuildAndRunScriptRunner.cs diff --git a/Services/Languages/Rust/RustCellProgram.cs b/FrySharp.Core/Services/Languages/Rust/RustCellProgram.cs similarity index 94% rename from Services/Languages/Rust/RustCellProgram.cs rename to FrySharp.Core/Services/Languages/Rust/RustCellProgram.cs index a16efc69..eb33713c 100644 --- a/Services/Languages/Rust/RustCellProgram.cs +++ b/FrySharp.Core/Services/Languages/Rust/RustCellProgram.cs @@ -151,9 +151,10 @@ public static RustCellProgram Build(RustCell cell, IReadOnlyList var isLast = i == statements.Count - 1; if (isLast && statement.CanBeShownValue && !statement.StartsWithLet) { - program.Line(" let __fry_last = {", statement.StartLine); + // A bare name is shown by borrowing it: showing `v` after `let v = vec![1, 2, 3];` mustn't move v away from the next cell. + program.Line(IsBareName(statement.Text) ? " let __fry_last = &(" : " let __fry_last = {", statement.StartLine); program.User(statement); - program.Line(" };", statement.StartLine + statement.Text.Count(c => c == '\n')); + program.Line(IsBareName(statement.Text) ? " );" : " };", statement.StartLine + statement.Text.Count(c => c == '\n')); program.Line(" fry::__auto!(__fry_last);", statement.StartLine + statement.Text.Count(c => c == '\n')); } else @@ -177,6 +178,8 @@ public static RustCellProgram Build(RustCell cell, IReadOnlyList return new RustCellProgram { Source = program.ToString(), Segments = program.Segments, PersistLines = persistLines }; } + private static bool IsBareName(string text) => Regex.IsMatch(text.Trim(), @"^[A-Za-z_][A-Za-z0-9_]*$"); + // Line-counting output: it knows which line each thing it writes lands on. private sealed class Writer { diff --git a/Services/Languages/Rust/RustCellSplitter.cs b/FrySharp.Core/Services/Languages/Rust/RustCellSplitter.cs similarity index 100% rename from Services/Languages/Rust/RustCellSplitter.cs rename to FrySharp.Core/Services/Languages/Rust/RustCellSplitter.cs diff --git a/FrySharp.Core/Services/Languages/Rust/RustConsoleText.cs b/FrySharp.Core/Services/Languages/Rust/RustConsoleText.cs new file mode 100644 index 00000000..df975bbe --- /dev/null +++ b/FrySharp.Core/Services/Languages/Rust/RustConsoleText.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Rust; + +/// +/// What rustc and a panic say, in the cell's own terms: they name the generated program's file and its lines (/tmp/.../cell.rs:6:14), +/// and the user wrote a cell, so the file becomes cell and the line the cell's own (cell:2:14). +/// +internal static partial class RustConsoleText +{ + // "6 | let y: i32 = ..." and "6 - old" / "6 + new" in a suggestion: the line number of a snippet rustc quotes. + [GeneratedRegex(@"^(\s*)(\d+)(\s+[|+-](?:\s|$))")] + private static partial Regex Gutter(); + + [GeneratedRegex(@"could not compile `[^`]+` \(bin ""[^""]+""\)")] + private static partial Regex CompileSummary(); + + /// A position in the program's file, in a line of text, as a position in the cell: a panic's panicked at …/cell.rs:8:2:. + public static string Positions(string text, string sourcePath, RustCellProgram program) + { + if (text.Length == 0 || text.IndexOf(sourcePath, StringComparison.Ordinal) < 0) return text; + return Regex.Replace(text, Regex.Escape(sourcePath) + @"(?::(?\d+)(?::(?\d+))?)?", match => + { + if (!match.Groups["line"].Success) return "cell"; + var line = program.CellLineOf(int.Parse(match.Groups["line"].Value)); + if (line == null) return "an earlier cell"; + return match.Groups["column"].Success ? $"cell:{line}:{match.Groups["column"].Value}" : $"cell:{line}"; + }); + } + + /// Rustc's messages for a program that didn't build: positions, the quoted lines' numbers and the generated crate's name. + public static string Build(string text, string sourcePath, RustCellProgram program) + { + var lines = Positions(text, sourcePath, program).Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var match = Gutter().Match(lines[i]); + if (!match.Success) continue; + + // rustc numbers the lines of the program; the cell has fewer lines before its own, so the number is never wider. + if (program.CellLineOf(int.Parse(match.Groups[2].Value)) is not { } cellLine) continue; + lines[i] = match.Groups[1].Value + cellLine.ToString().PadLeft(match.Groups[2].Length) + lines[i][(match.Groups[1].Length + match.Groups[2].Length)..]; + } + + return CompileSummary().Replace(string.Join('\n', lines), "could not compile the cell"); + } +} diff --git a/Services/Languages/Rust/RustDiagnosticParser.cs b/FrySharp.Core/Services/Languages/Rust/RustDiagnosticParser.cs similarity index 100% rename from Services/Languages/Rust/RustDiagnosticParser.cs rename to FrySharp.Core/Services/Languages/Rust/RustDiagnosticParser.cs diff --git a/Services/Languages/Rust/RustDirectives.cs b/FrySharp.Core/Services/Languages/Rust/RustDirectives.cs similarity index 100% rename from Services/Languages/Rust/RustDirectives.cs rename to FrySharp.Core/Services/Languages/Rust/RustDirectives.cs diff --git a/Services/Languages/Rust/RustDisplayRuntime.cs b/FrySharp.Core/Services/Languages/Rust/RustDisplayRuntime.cs similarity index 100% rename from Services/Languages/Rust/RustDisplayRuntime.cs rename to FrySharp.Core/Services/Languages/Rust/RustDisplayRuntime.cs diff --git a/FrySharp.Core/Services/Languages/Rust/RustEngine.cs b/FrySharp.Core/Services/Languages/Rust/RustEngine.cs new file mode 100644 index 00000000..05ebcab6 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Rust/RustEngine.cs @@ -0,0 +1,72 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Rust; + +/// +/// Rust, built with Cargo and run natively for source files: a two-phase build and run, rustc's messages in Problems, +/// interactive standard input, and crates named in // #crate: comments. Notebook cells are built with Cargo as small +/// programs and run, with what earlier cells defined carried over (see ). +/// +public sealed class RustEngine : LanguageEngine +{ + private static readonly string[] RustAliases = ["rust", "rs"]; + private static readonly string[] RustExtensions = [".rs"]; + + public RustEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + RustRoot = Path.Combine(services.BaseDirectory, "rust"); + var toolchain = new RustToolchainProvider(services.Host, services.Processes, services.ToolchainSettings, RustRoot); + RustToolchain = toolchain; + var packages = new RustPackageManager(RustRoot, toolchain, services.Processes, services.Host); + Packages = packages; + ScriptRunner = new RustBuildAndRunScriptRunner(services.Host, RustRoot); + NotebookKernels = new DelegateKernelFactory(context => + new RustNotebookKernel(toolchain, services.Processes, services.Host, RustRoot, packages.NotebookDependencies, context)); + } + + /// Where the engine keeps what it builds: Cargo packages, the shared crate cache, the notebooks' cells. + public string RustRoot { get; } + + public RustToolchainProvider RustToolchain { get; } + + public override string Id => LanguageIds.Rust; + public override string DisplayName => "Rust"; + public override JupyterLanguageInfo Jupyter { get; } = new("rust", "Rust", "rust", "rust", ".rs", "text/rust"); + public override string ShortName => "RS"; + public override IReadOnlyList Aliases => RustAliases; + public override IReadOnlyList FileExtensions => RustExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + public override bool IsCompiled => true; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | + LanguageCapabilities.QuickInfo | + LanguageCapabilities.Completion | + LanguageCapabilities.Debugging | + LanguageCapabilities.Breakpoints | + LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | + LanguageCapabilities.Packages; + + public override string IconKind => "LanguageRust"; + public override string AccentHex => "#DEA584"; + public override string LineCommentPrefix => "//"; + public override string RuntimeDescription => "Rust (rustc / Cargo)"; + + public override string NewFileTemplate => + "// Builds with Cargo and runs with the Rust toolchain installed on this computer (F5).\n" + + "// Add a crate with a comment such as: // #crate: rand = \"0.8\"\n" + + "fn main() {\n" + + " println!(\"Hello from Rust!\");\n" + + "}\n"; + + public override IToolchainProvider Toolchain => RustToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new RustDiagnosticParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/Rust/RustGuidance.cs b/FrySharp.Core/Services/Languages/Rust/RustGuidance.cs similarity index 100% rename from Services/Languages/Rust/RustGuidance.cs rename to FrySharp.Core/Services/Languages/Rust/RustGuidance.cs diff --git a/Services/Languages/Rust/RustLetNames.cs b/FrySharp.Core/Services/Languages/Rust/RustLetNames.cs similarity index 100% rename from Services/Languages/Rust/RustLetNames.cs rename to FrySharp.Core/Services/Languages/Rust/RustLetNames.cs diff --git a/Services/Languages/Rust/RustNotebookDependencies.cs b/FrySharp.Core/Services/Languages/Rust/RustNotebookDependencies.cs similarity index 100% rename from Services/Languages/Rust/RustNotebookDependencies.cs rename to FrySharp.Core/Services/Languages/Rust/RustNotebookDependencies.cs diff --git a/Services/Languages/Rust/RustNotebookKernel.cs b/FrySharp.Core/Services/Languages/Rust/RustNotebookKernel.cs similarity index 88% rename from Services/Languages/Rust/RustNotebookKernel.cs rename to FrySharp.Core/Services/Languages/Rust/RustNotebookKernel.cs index ef4c1eba..ed1c1f7f 100644 --- a/Services/Languages/Rust/RustNotebookKernel.cs +++ b/FrySharp.Core/Services/Languages/Rust/RustNotebookKernel.cs @@ -257,15 +257,26 @@ private async Task BuildAsync( private async Task RunProgramAsync(KernelExecutionRequest request, CellBuild build, Stopwatch clock, CancellationToken ct) { + // What the program printed, as it printed it (rustc and a panic name the generated file: the diagnostics are read from this), and + // as the cell is shown it (those names and lines are the cell's own). var console = new StringBuilder(); + var shownConsole = new StringBuilder(); var sharedNow = new HashSet(StringComparer.Ordinal); // The cell's visuals, and the events on them its code listens to while it runs (over the event socket). using var visuals = new ExternalVisualSession(); + // fry::input asks for a line; the program may ask before Start has returned the process, so the answer waits for it. + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var processor = new ExternalOutputProcessor( onConsoleText: text => { - lock (console) console.Append(text); - request.OnConsole?.Invoke(text); + var shown = RustConsoleText.Positions(text, build.SourcePath, build.Program); + lock (console) + { + console.Append(text); + shownConsole.Append(shown); + } + + request.OnConsole?.Invoke(shown); }, onRichOutput: bundle => request.OnRichOutput?.Invoke(bundle), onShare: (name, json) => @@ -280,11 +291,17 @@ private async Task RunProgramAsync(KernelExecutionRequest catch (KernelValueException ex) { var warning = "⚠️ " + ex.Message + "\n"; - lock (console) console.Append(warning); + lock (console) + { + console.Append(warning); + shownConsole.Append(warning); + } + request.OnConsole?.Invoke(warning); } }, - visuals: visuals.Visuals); + visuals: visuals.Visuals, + onInputRequest: (prompt, password) => _ = AnswerInputAsync(started.Task, request, prompt, password, ct)); // The program says what it made with let by appending to this file as it ends. var varsFile = Path.Combine(Path.GetDirectoryName(build.SourcePath)!, "variables.jsonl"); @@ -296,7 +313,7 @@ private async Task RunProgramAsync(KernelExecutionRequest { } - var environment = new Dictionary(build.Environment, StringComparer.Ordinal) { ["FRY_VARS_FILE"] = varsFile }; + var environment = new Dictionary(build.Environment, StringComparer.Ordinal) { ["FRY_VARS_FILE"] = varsFile, ["FRY_INPUT_REQUESTS"] = "1" }; IManagedProcess? running = null; try @@ -306,18 +323,26 @@ private async Task RunProgramAsync(KernelExecutionRequest FileName = build.Stage.BinPath, Arguments = Array.Empty(), WorkingDirectory = build.WorkingFolder, - Environment = environment + Environment = environment, + // println! and eprintln! in one cell read in the order they ran. + MergeStandardError = true }), processor.ProcessChunk, processor.ProcessChunk); + started.SetResult(running); - // A cell can't be typed into, so a program that reads its input sees the end of it rather than waiting for good. - running.CloseInput(); + // A plain stdin read can't ask anyone for a line, so a program that does only that sees the end of its input rather than + // waiting for good; one that calls fry::input is asked for each line as it comes. + if (!(request.OnInputRequest != null && AsksForInput.IsMatch(build.Program.Source))) running.CloseInput(); var exitCode = await running.WaitForExitOrKillAsync(ct).ConfigureAwait(false); ct.ThrowIfCancellationRequested(); processor.Flush(); - string consoleText; - lock (console) consoleText = console.ToString(); + string consoleText, rawConsole; + lock (console) + { + consoleText = shownConsole.ToString(); + rawConsole = console.ToString(); + } if (exitCode == 0) { @@ -331,7 +356,7 @@ private async Task RunProgramAsync(KernelExecutionRequest return new KernelExecutionResult { Success = true, ConsoleOutput = consoleText, Elapsed = clock.Elapsed }; } - var parsed = new RustDiagnosticParser().Parse(consoleText, build.SourcePath); + var parsed = new RustDiagnosticParser().Parse(rawConsole, build.SourcePath); return new KernelExecutionResult { Success = false, @@ -356,6 +381,36 @@ private async Task RunProgramAsync(KernelExecutionRequest } } + // fry::input("...") and fry::password("..."), written as a path or imported by name (use fry::{input, show};). + private static readonly Regex AsksForInput = new(@"\bfry\s*::\s*(\{[^}]*\b)?(input|password)\b", RegexOptions.Compiled); + + // The program asked for a line: the notebook's answer is written to it, and no answer (cancelled, or nobody to ask) ends its input. + private static async Task AnswerInputAsync(Task started, KernelExecutionRequest request, string prompt, bool password, CancellationToken ct) + { + IManagedProcess process; + try + { + process = await started.ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return; + } + + string? answer = null; + try + { + if (request.OnInputRequest is { } ask) answer = await ask(prompt, password, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException) + { + // Stopped, or the host can't ask: the same as no answer. + } + + if (answer == null) process.CloseInput(); + else await process.WriteInputAsync(answer + "\n", CancellationToken.None).ConfigureAwait(false); + } + // The cell ran to its end: the variables it made replace the ones of the same names from before. Returns a note about any that // couldn't be kept and that the cell might have expected to be (too large, or moved away), or an empty string. private string KeepVariables(CellBuild build, string varsFile, IReadOnlySet sharedNow) @@ -419,7 +474,8 @@ private KernelExecutionResult BuildFailure(RustCellProgram program, string sourc var parsed = new RustDiagnosticParser().Parse(output, sourcePath); // What the user needs is rustc's own messages, not Cargo's progress. - var shown = string.Join('\n', output.Replace("\r\n", "\n").Split('\n').Where(line => !CargoProgress.IsMatch(line))).Trim() + UnkeptHint(output); + var shown = string.Join('\n', output.Replace("\r\n", "\n").Split('\n').Where(line => !CargoProgress.IsMatch(line))).Trim(); + shown = RustConsoleText.Build(shown, sourcePath, program) + UnkeptHint(output); var diagnostics = MapToCell(parsed.Diagnostics, program); var summary = diagnostics.FirstOrDefault(d => d.Severity == DiagnosticSeverity.Error)?.Message diff --git a/Services/Languages/Rust/RustPackageManager.cs b/FrySharp.Core/Services/Languages/Rust/RustPackageManager.cs similarity index 100% rename from Services/Languages/Rust/RustPackageManager.cs rename to FrySharp.Core/Services/Languages/Rust/RustPackageManager.cs diff --git a/Services/Languages/Rust/RustPackageMap.cs b/FrySharp.Core/Services/Languages/Rust/RustPackageMap.cs similarity index 100% rename from Services/Languages/Rust/RustPackageMap.cs rename to FrySharp.Core/Services/Languages/Rust/RustPackageMap.cs diff --git a/Services/Languages/Rust/RustProjectStager.cs b/FrySharp.Core/Services/Languages/Rust/RustProjectStager.cs similarity index 100% rename from Services/Languages/Rust/RustProjectStager.cs rename to FrySharp.Core/Services/Languages/Rust/RustProjectStager.cs diff --git a/Services/Languages/Rust/RustToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/Rust/RustToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/Rust/RustToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/Rust/RustToolchainProvider.Discovery.cs diff --git a/Services/Languages/Rust/RustToolchainProvider.cs b/FrySharp.Core/Services/Languages/Rust/RustToolchainProvider.cs similarity index 100% rename from Services/Languages/Rust/RustToolchainProvider.cs rename to FrySharp.Core/Services/Languages/Rust/RustToolchainProvider.cs diff --git a/Services/Languages/Rust/RustValueLiterals.cs b/FrySharp.Core/Services/Languages/Rust/RustValueLiterals.cs similarity index 100% rename from Services/Languages/Rust/RustValueLiterals.cs rename to FrySharp.Core/Services/Languages/Rust/RustValueLiterals.cs diff --git a/Services/Languages/Sql/SqlCompilerDiagnosticParser.cs b/FrySharp.Core/Services/Languages/Sql/SqlCompilerDiagnosticParser.cs similarity index 100% rename from Services/Languages/Sql/SqlCompilerDiagnosticParser.cs rename to FrySharp.Core/Services/Languages/Sql/SqlCompilerDiagnosticParser.cs diff --git a/FrySharp.Core/Services/Languages/Sql/SqlCompletions.cs b/FrySharp.Core/Services/Languages/Sql/SqlCompletions.cs new file mode 100644 index 00000000..c40455dc --- /dev/null +++ b/FrySharp.Core/Services/Languages/Sql/SqlCompletions.cs @@ -0,0 +1,172 @@ +using System.Text.RegularExpressions; +using PdfEditorApp.Plugins.CSharpEditor.Models; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; + +/// +/// Code completion for SQL scripts and notebook cells: SQLite dot-commands (after .), DDL/DML keywords, data types, +/// built-in functions, and the tables and views the text itself creates. Pure text in, items out: the desktop's editor +/// and the sidecar's notebook cells both use it. +/// +public static partial class SqlCompletions +{ + [GeneratedRegex(@"\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?[a-zA-Z_][a-zA-Z0-9_]*)", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + private static partial Regex CreateTableRegex(); + + [GeneratedRegex(@"\bCREATE\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?(?[a-zA-Z_][a-zA-Z0-9_]*)", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + private static partial Regex CreateViewRegex(); + + /// The completions at in . + public static IReadOnlyList Get(string code, int caretOffset) + { + if (string.IsNullOrEmpty(code) || caretOffset < 0 || caretOffset > code.Length) + { + return Array.Empty(); + } + + // Check if cursor is immediately after a dot (SQLite dot command) + int wordStart = caretOffset; + while (wordStart > 0 && (char.IsLetterOrDigit(code[wordStart - 1]) || code[wordStart - 1] == '_')) + { + wordStart--; + } + + if (wordStart > 0 && code[wordStart - 1] == '.') + { + return GetDotCommandCompletions(code[wordStart..caretOffset]); + } + + return GetGeneralCompletions(code, code[wordStart..caretOffset]); + } + + private static IReadOnlyList GetDotCommandCompletions(string prefix) + { + var dotCommands = new (string Name, string Description)[] + { + (".tables", "List names of tables matching LIKE pattern"), + (".schema", "Show the CREATE statements matching pattern"), + (".mode", "Set output mode: table, column, json, csv, line"), + (".header", "Turn display of headers on or off"), + (".read", "Execute SQL from a file"), + (".databases", "List names and files of attached databases"), + (".indices", "List names of indexes"), + (".dump", "Render database content as SQL"), + (".nullvalue", "Use STRING in place of NULL values"), + (".show", "Show the current values for various settings"), + (".exit", "Exit SQLite prompt") + }; + + // The dot is already typed: the prefix is of the name after it (".ta" offers ".tables"). + var list = new List(); + foreach (var (cmd, desc) in dotCommands) + { + if (!cmd.AsSpan(1).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + list.Add(new CSharpCompletionItem { DisplayText = cmd, InsertionText = cmd, Signature = cmd, Documentation = desc, Kind = CompletionItemKind.Method }); + } + return list; + } + + private static IReadOnlyList GetGeneralCompletions(string code, string prefix) + { + var list = new List(); + + // Keywords and snippets + var keywords = new (string Word, string Snippet, string Desc, CompletionItemKind Kind)[] + { + ("SELECT", "SELECT ", "Retrieves data from one or more tables.", CompletionItemKind.Keyword), + ("FROM", "FROM ", "Specifies the table or view to query from.", CompletionItemKind.Keyword), + ("WHERE", "WHERE ", "Filters rows based on a search condition.", CompletionItemKind.Keyword), + ("GROUP BY", "GROUP BY ", "Groups rows with identical values in summary columns.", CompletionItemKind.Keyword), + ("HAVING", "HAVING ", "Filters groups created by GROUP BY.", CompletionItemKind.Keyword), + ("ORDER BY", "ORDER BY ", "Sorts query result set by specified columns.", CompletionItemKind.Keyword), + ("LIMIT", "LIMIT ", "Constrains the number of rows returned.", CompletionItemKind.Keyword), + ("OFFSET", "OFFSET ", "Skips specified number of rows before returning rows.", CompletionItemKind.Keyword), + ("JOIN", "JOIN ", "Combines rows from two or more tables.", CompletionItemKind.Keyword), + ("LEFT JOIN", "LEFT JOIN ", "Returns all records from the left table and matched from right.", CompletionItemKind.Keyword), + ("INNER JOIN", "INNER JOIN ", "Returns records that have matching values in both tables.", CompletionItemKind.Keyword), + ("INSERT INTO", "INSERT INTO ", "Inserts new rows into a table.", CompletionItemKind.Keyword), + ("VALUES", "VALUES ", "Specifies row values to insert.", CompletionItemKind.Keyword), + ("UPDATE", "UPDATE ", "Modifies existing rows in a table.", CompletionItemKind.Keyword), + ("SET", "SET ", "Specifies columns and values to update.", CompletionItemKind.Keyword), + ("DELETE FROM", "DELETE FROM ", "Deletes rows from a table.", CompletionItemKind.Keyword), + ("CREATE TABLE", "CREATE TABLE IF NOT EXISTS ", "Creates a new database table.", CompletionItemKind.Keyword), + ("DROP TABLE", "DROP TABLE IF EXISTS ", "Removes a table definition and data.", CompletionItemKind.Keyword), + ("ALTER TABLE", "ALTER TABLE ", "Modifies an existing table structure.", CompletionItemKind.Keyword), + ("PRAGMA", "PRAGMA ", "Queries or modifies SQLite operational parameters.", CompletionItemKind.Keyword), + ("UNION ALL", "UNION ALL ", "Combines results of two queries without removing duplicates.", CompletionItemKind.Keyword), + ("DISTINCT", "DISTINCT ", "Filters duplicate rows from result set.", CompletionItemKind.Keyword), + ("PRIMARY KEY", "PRIMARY KEY ", "Uniquely identifies each record in a table.", CompletionItemKind.Keyword), + ("FOREIGN KEY", "FOREIGN KEY ", "Defines a relationship to another table.", CompletionItemKind.Keyword), + ("AUTOINCREMENT", "AUTOINCREMENT", "Automatically increments integer primary key.", CompletionItemKind.Keyword) + }; + + foreach (var (word, snippet, desc, kind) in keywords) + { + Add(list, prefix, word, snippet, desc, kind); + } + + // Data types + var types = new[] { "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE", "VARCHAR(255)" }; + foreach (var t in types) + { + Add(list, prefix, t, t, $"SQLite data type {t}", CompletionItemKind.Class); + } + + // Functions + var functions = new (string Name, string Snippet, string Desc)[] + { + ("COUNT", "COUNT(*)", "Returns the number of rows matching criteria."), + ("SUM", "SUM()", "Returns total sum of a numeric column."), + ("AVG", "AVG()", "Returns the average value of a numeric column."), + ("MIN", "MIN()", "Returns the smallest value in a column."), + ("MAX", "MAX()", "Returns the largest value in a column."), + ("COALESCE", "COALESCE()", "Returns first non-null argument."), + ("IFNULL", "IFNULL()", "Returns first non-null argument, or second argument."), + ("NULLIF", "NULLIF()", "Returns NULL if both arguments are equal."), + ("ROUND", "ROUND()", "Rounds numeric expression to specified decimals."), + ("LOWER", "LOWER()", "Converts string to lowercase."), + ("UPPER", "UPPER()", "Converts string to uppercase."), + ("LENGTH", "LENGTH()", "Returns character or byte length of expression."), + ("SUBSTR", "SUBSTR()", "Returns substring of a string."), + ("PRINTF", "PRINTF()", "Formats text according to format string."), + ("STRFTIME", "STRFTIME()", "Formats date/time string."), + ("DATE", "DATE('now')", "Returns current date (YYYY-MM-DD)."), + ("DATETIME", "DATETIME('now')", "Returns current date and time.") + }; + + foreach (var (name, snippet, desc) in functions) + { + Add(list, prefix, name, snippet, desc, CompletionItemKind.Method); + } + + // Tables and views defined in code + foreach (var (regex, what) in new[] { (CreateTableRegex(), "table"), (CreateViewRegex(), "view") }) + { + foreach (Match match in regex.Matches(code)) + { + var name = match.Groups["name"].Value; + if (list.Any(item => string.Equals(item.DisplayText, name, StringComparison.OrdinalIgnoreCase))) continue; + Add(list, prefix, name, name, $"User {what} '{name}'", CompletionItemKind.Struct); + } + } + + return list; + } + + private static void Add(List list, string prefix, string text, string snippet, string doc, CompletionItemKind kind) + { + if (!string.IsNullOrEmpty(prefix) && !text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + list.Add(new CSharpCompletionItem + { + DisplayText = text, + InsertionText = snippet, + Signature = text, + Documentation = doc, + Kind = kind + }); + } +} diff --git a/FrySharp.Core/Services/Languages/Sql/SqlEngine.cs b/FrySharp.Core/Services/Languages/Sql/SqlEngine.cs new file mode 100644 index 00000000..0938bd00 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Sql/SqlEngine.cs @@ -0,0 +1,78 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; + +/// +/// SQL, run by the SQLite 3 CLI (sqlite3) installed on this computer: .sql files run like +/// sqlite3 db ".read file.sql"; notebook cells run against a database that lives as long as the notebook's kernel. +/// +public sealed class SqlEngine : LanguageEngine +{ + private static readonly string[] SqlAliases = ["sql", "sqlite", "sqlite3"]; + private static readonly string[] SqlExtensions = [".sql"]; + + public SqlEngine(LanguageEngineServices services) + { + ArgumentNullException.ThrowIfNull(services); + var toolchain = new SqlToolchainProvider(services.Host, services.Processes, services.ToolchainSettings); + SqlToolchain = toolchain; + ScriptRunner = new SqlScriptRunner(); + Packages = new SqlPackageManager(); + NotebookKernels = new DelegateKernelFactory(context => + new SqlNotebookKernel(toolchain, services.Processes, services.Host, context)); + } + + public SqlToolchainProvider SqlToolchain { get; } + + public override string Id => LanguageIds.Sql; + public override string DisplayName => "SQL"; + public override JupyterLanguageInfo Jupyter { get; } = new("sql", "SQL", "sql", "sql", ".sql", "application/sql"); + public override string ShortName => "SQL"; + public override IReadOnlyList Aliases => SqlAliases; + public override IReadOnlyList FileExtensions => SqlExtensions; + public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; + + public override LanguageCapabilities Capabilities => + LanguageCapabilities.StandardInput | + LanguageCapabilities.QuickInfo | + LanguageCapabilities.Completion | + LanguageCapabilities.NotebookCells | + LanguageCapabilities.ValueSharing | + LanguageCapabilities.Packages | + LanguageCapabilities.Formatting; + + public override string IconKind => "Database"; + public override string AccentHex => "#F29111"; + public override string LineCommentPrefix => "--"; + public override string RuntimeDescription => "SQLite 3 Engine"; + + public override string NewFileTemplate => + "-- Runs with SQLite 3 (sqlite3) on this computer (F5).\n" + + "-- :database :memory:\n\n" + + "CREATE TABLE IF NOT EXISTS developers (\n" + + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + + " name TEXT NOT NULL,\n" + + " role TEXT NOT NULL,\n" + + " rating REAL DEFAULT 5.0\n" + + ");\n\n" + + "INSERT INTO developers (name, role, rating) VALUES\n" + + " ('Antigravity', 'AI Architect', 5.0),\n" + + " ('Ada Lovelace', 'First Programmer', 5.0),\n" + + " ('Alan Turing', 'Computer Pioneer', 5.0);\n\n" + + "SELECT \n" + + " id,\n" + + " name,\n" + + " role,\n" + + " printf('★ %.1f', rating) AS score\n" + + "FROM developers\n" + + "ORDER BY id ASC;\n"; + + public override IToolchainProvider Toolchain => SqlToolchain; + public override IScriptRunner ScriptRunner { get; } + public override IDiagnosticParser RunDiagnostics { get; } = new SqlCompilerDiagnosticParser(); + public override IPackageManager Packages { get; } + public override INotebookKernelFactory NotebookKernels { get; } +} diff --git a/Services/Languages/Sql/SqlGuidance.cs b/FrySharp.Core/Services/Languages/Sql/SqlGuidance.cs similarity index 100% rename from Services/Languages/Sql/SqlGuidance.cs rename to FrySharp.Core/Services/Languages/Sql/SqlGuidance.cs diff --git a/Services/Languages/Sql/SqlNotebookKernel.CodePreparation.cs b/FrySharp.Core/Services/Languages/Sql/SqlNotebookKernel.CodePreparation.cs similarity index 75% rename from Services/Languages/Sql/SqlNotebookKernel.CodePreparation.cs rename to FrySharp.Core/Services/Languages/Sql/SqlNotebookKernel.CodePreparation.cs index 30d750c2..e6c70e39 100644 --- a/Services/Languages/Sql/SqlNotebookKernel.CodePreparation.cs +++ b/FrySharp.Core/Services/Languages/Sql/SqlNotebookKernel.CodePreparation.cs @@ -5,26 +5,22 @@ namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; public sealed partial class SqlNotebookKernel { - private static string CleanCode(string code, out List shareDirectives) + // A cell's directive lines are for the notebook, not for SQLite. They are emptied, not removed, so the lines that + // follow keep their numbers and an error points at the right one. + private static string CleanCode(string code) { - shareDirectives = new List(); - var sb = new StringBuilder(); - var lines = code.Split('\n'); - - foreach (var raw in lines) + var lines = code.Replace("\r\n", "\n").Split('\n'); + for (var i = 0; i < lines.Length; i++) { - var trimmed = raw.Trim(); - if (trimmed.StartsWith("#!share", StringComparison.OrdinalIgnoreCase)) - { - shareDirectives.Add(trimmed); - continue; - } - sb.AppendLine(raw); + var trimmed = lines[i].Trim(); + if (trimmed.StartsWith("#!share", StringComparison.OrdinalIgnoreCase)) lines[i] = string.Empty; } - return sb.ToString().Trim(); + return string.Join("\n", lines).TrimEnd() + "\n"; } + private static string Quote(string identifier) => "\"" + identifier.Replace("\"", "\"\"") + "\""; + private static string GenerateSharedTableSql(string varName, string jsonValue) { if (string.IsNullOrWhiteSpace(jsonValue)) return string.Empty; @@ -34,6 +30,9 @@ private static string GenerateSharedTableSql(string varName, string jsonValue) using var doc = JsonDocument.Parse(jsonValue); var root = doc.RootElement; + // One value (a number, a text, one object) is a table of one row. + if (root.ValueKind != JsonValueKind.Array) return GenerateSharedTableSql(varName, "[" + jsonValue + "]"); + if (root.ValueKind == JsonValueKind.Array) { var count = root.GetArrayLength(); @@ -55,12 +54,12 @@ private static string GenerateSharedTableSql(string varName, string jsonValue) columns.Add((prop.Name, colType)); } - var colDefs = string.Join(", ", columns.Select(c => $"\"{c.Name}\" {c.Type}")); - var colNames = string.Join(", ", columns.Select(c => $"\"{c.Name}\"")); + var colDefs = string.Join(", ", columns.Select(c => $"{Quote(c.Name)} {c.Type}")); + var colNames = string.Join(", ", columns.Select(c => Quote(c.Name))); var sb = new StringBuilder(); - sb.AppendLine($"DROP TABLE IF EXISTS \"{varName}\";"); - sb.AppendLine($"CREATE TABLE \"{varName}\" ({colDefs});"); + sb.AppendLine($"DROP TABLE IF EXISTS {Quote(varName)};"); + sb.AppendLine($"CREATE TABLE {Quote(varName)} ({colDefs});"); var insertRows = new List(); foreach (var row in root.EnumerateArray()) @@ -83,7 +82,7 @@ private static string GenerateSharedTableSql(string varName, string jsonValue) if (insertRows.Count > 0) { - sb.AppendLine($"INSERT INTO \"{varName}\" ({colNames}) VALUES"); + sb.AppendLine($"INSERT INTO {Quote(varName)} ({colNames}) VALUES"); sb.AppendLine(string.Join(",\n", insertRows) + ";"); } return sb.ToString(); @@ -99,8 +98,8 @@ private static string GenerateSharedTableSql(string varName, string jsonValue) }; var sb = new StringBuilder(); - sb.AppendLine($"DROP TABLE IF EXISTS \"{varName}\";"); - sb.AppendLine($"CREATE TABLE \"{varName}\" (value {colType});"); + sb.AppendLine($"DROP TABLE IF EXISTS {Quote(varName)};"); + sb.AppendLine($"CREATE TABLE {Quote(varName)} (value {colType});"); var vals = new List(); foreach (var elem in root.EnumerateArray()) @@ -110,7 +109,7 @@ private static string GenerateSharedTableSql(string varName, string jsonValue) if (vals.Count > 0) { - sb.AppendLine($"INSERT INTO \"{varName}\" (value) VALUES"); + sb.AppendLine($"INSERT INTO {Quote(varName)} (value) VALUES"); sb.AppendLine(string.Join(",\n", vals) + ";"); } return sb.ToString(); diff --git a/Services/Languages/Sql/SqlNotebookKernel.cs b/FrySharp.Core/Services/Languages/Sql/SqlNotebookKernel.cs similarity index 64% rename from Services/Languages/Sql/SqlNotebookKernel.cs rename to FrySharp.Core/Services/Languages/Sql/SqlNotebookKernel.cs index 301d5aa7..b4b7ec3e 100644 --- a/Services/Languages/Sql/SqlNotebookKernel.cs +++ b/FrySharp.Core/Services/Languages/Sql/SqlNotebookKernel.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text; using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; @@ -78,7 +79,7 @@ public async Task ExecuteAsync(KernelExecutionRequest req }; } - var cellCode = CleanCode(request.Code, out var shareDirectives); + var cellCode = CleanCode(request.Code); // Prepend table definitions for any shared variables var preSql = new StringBuilder(); @@ -87,30 +88,24 @@ public async Task ExecuteAsync(KernelExecutionRequest req var tableSql = GenerateSharedTableSql(varName, jsonVal); if (!string.IsNullOrEmpty(tableSql)) { - preSql.AppendLine(tableSql); + preSql.AppendLine(tableSql.TrimEnd()); } } _sharedVariables.Clear(); - var fullSql = (preSql.ToString() + "\n" + cellCode).Trim(); + // The cell's own lines come after the tables it was given, and sqlite3 numbers lines from the top of the file: + // an error is shown on the cell's line, not on one of the generated ones. + var prefixLines = preSql.ToString().Count(c => c == '\n'); var cellScriptPath = Path.Combine(_sessionDir, $"cell_{_executionCount}.sql"); - await File.WriteAllTextAsync(cellScriptPath, fullSql, ct).ConfigureAwait(false); + await File.WriteAllTextAsync(cellScriptPath, preSql + cellCode, ct).ConfigureAwait(false); var executable = resolution.Toolchain.ExecutablePath; - var runArgs = new List { "-header", "-table", _dbFilePath, $".read {cellScriptPath}" }; - - var consoleBuilder = new StringBuilder(); - var processor = new ExternalOutputProcessor( - onConsoleText: text => - { - consoleBuilder.Append(text); - request.OnConsole?.Invoke(text); - }, - onRichOutput: bundle => - { - request.OnRichOutput?.Invoke(bundle); - }); + // -json: a result set arrives as rows, to be shown as a table; the text a script prints arrives as it is. + var runArgs = new List { "-json", _dbFilePath, $".read {cellScriptPath}" }; + var stdout = new StringBuilder(); + var stderr = new List<(int Offset, string Text)>(); + var gate = new object(); var spec = new ProcessStartSpec { FileName = executable, @@ -120,27 +115,34 @@ public async Task ExecuteAsync(KernelExecutionRequest req using var managedProcess = _processes.Start( spec, - onStandardOutput: text => processor.ProcessChunk(text), - onStandardError: err => processor.ProcessChunk(err)); + onStandardOutput: text => { lock (gate) stdout.Append(text); }, + onStandardError: text => { lock (gate) stderr.Add((stdout.Length, text)); }); try { // No limit of the kernel's own: how long a cell may take is the studio's ExecutionTimeoutSeconds setting, which // arrives as the cancellation token. var exitCode = await managedProcess.WaitForExitOrKillAsync(ct).ConfigureAwait(false); - processor.Flush(); - var fullConsole = consoleBuilder.ToString(); - var parser = new SqlCompilerDiagnosticParser(); - var parseResult = parser.Parse(fullConsole, cellScriptPath); - var hasErrors = parseResult.Diagnostics.Any(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error); + string output; + List<(int Offset, string Text)> errors; + lock (gate) + { + output = stdout.ToString(); + errors = [.. stderr]; + } + + var console = Present(SqlResultStream.Split(output), errors, request); + var parseResult = new SqlCompilerDiagnosticParser().Parse(console, cellScriptPath); + var diagnostics = parseResult.Diagnostics.Select(d => OnCellLines(d, prefixLines)).ToList(); + var hasErrors = diagnostics.Any(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error); return new KernelExecutionResult { Success = exitCode == 0 && !hasErrors, - ErrorMessage = exitCode != 0 ? (parseResult.Diagnostics.FirstOrDefault(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)?.Message ?? $"SQL execution failed with exit code {exitCode}.") : string.Empty, - ConsoleOutput = fullConsole, - Diagnostics = parseResult.Diagnostics, + ErrorMessage = exitCode != 0 ? (diagnostics.FirstOrDefault(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)?.Message ?? $"SQL execution failed with exit code {exitCode}.") : string.Empty, + ConsoleOutput = console, + Diagnostics = diagnostics, Elapsed = clock.Elapsed }; } @@ -155,6 +157,63 @@ public async Task ExecuteAsync(KernelExecutionRequest req } } + // Shows what the script printed in the order it printed it: its result sets as tables, its text (and sqlite3's errors, + // which arrive on the other stream) as console text. Returns the text. + private static string Present(IReadOnlyList segments, List<(int Offset, string Text)> errors, KernelExecutionRequest request) + { + var console = new StringBuilder(); + var tables = segments.OfType().ToList(); + var next = 0; + + void Say(string text) + { + console.Append(text); + request.OnConsole?.Invoke(text); + } + + void SayErrorsBefore(int offset) + { + while (next < errors.Count && errors[next].Offset <= offset) Say(errors[next++].Text); + } + + var tableNumber = 0; + foreach (var segment in segments) + { + SayErrorsBefore(segment.Start); + switch (segment) + { + case SqlTextSegment text: + Say(text.Text); + break; + case SqlTableSegment result: + tableNumber++; + if (tables.Count > 1) result.Table.Title = $"Result {tableNumber}"; + request.OnRichOutput?.Invoke(new RichCellOutput { Kind = CellOutputKind.Table, TableResult = result.Table }); + break; + } + } + + SayErrorsBefore(int.MaxValue); + return console.ToString(); + } + + // sqlite3 names a line of the file it was given; the cell's code starts after the lines of the shared tables. + private static DiagnosticItem OnCellLines(DiagnosticItem item, int prefixLines) + { + var line = Math.Max(1, item.Line - prefixLines); + var endLine = Math.Max(line, item.EndLine - prefixLines); + return new DiagnosticItem + { + Id = item.Id, + Message = item.Message, + Severity = item.Severity, + Line = line, + Column = item.Column, + EndLine = endLine, + EndColumn = item.EndColumn + }; + } + public async Task> GetVariablesAsync(CancellationToken ct) { var list = new List(); @@ -215,14 +274,12 @@ public async Task GetValueJsonAsync(string name, CancellationToken ct) { var run = await _host.RunAsync( resolution.Toolchain.ExecutablePath, - ["-json", _dbFilePath, $"SELECT * FROM \"{name.Replace("\"", "\"\"")}\";"], + ["-json", _dbFilePath, $"SELECT * FROM {Quote(name)};"], TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); - if (run.ExitCode == 0 && !string.IsNullOrWhiteSpace(run.StandardOutput)) - { - return run.StandardOutput.Trim(); - } + // A table without rows prints nothing, and is the empty list. + if (run.ExitCode == 0) return string.IsNullOrWhiteSpace(run.StandardOutput) ? "[]" : run.StandardOutput.Trim(); } catch { } } diff --git a/Services/Languages/Sql/SqlPackageManager.cs b/FrySharp.Core/Services/Languages/Sql/SqlPackageManager.cs similarity index 100% rename from Services/Languages/Sql/SqlPackageManager.cs rename to FrySharp.Core/Services/Languages/Sql/SqlPackageManager.cs diff --git a/Services/Languages/Sql/SqlQuickInfoProvider.cs b/FrySharp.Core/Services/Languages/Sql/SqlQuickInfoProvider.cs similarity index 91% rename from Services/Languages/Sql/SqlQuickInfoProvider.cs rename to FrySharp.Core/Services/Languages/Sql/SqlQuickInfoProvider.cs index 4c4870c2..b2a40248 100644 --- a/Services/Languages/Sql/SqlQuickInfoProvider.cs +++ b/FrySharp.Core/Services/Languages/Sql/SqlQuickInfoProvider.cs @@ -47,6 +47,21 @@ public sealed record QuickInfo(string Signature, string Summary, string? Module } } + // ...or the first of two: hovering GROUP in "GROUP BY" is the clause too. + if (end < code.Length && code[end] == ' ') + { + int nextStart = end; + while (nextStart < code.Length && code[nextStart] == ' ') nextStart++; + int nextEnd = nextStart; + while (nextEnd < code.Length && IsIdentChar(code[nextEnd])) nextEnd++; + + if (nextEnd > nextStart) + { + var twoWord = $"{symbol} {code[nextStart..nextEnd]}"; + if (Entries.ContainsKey(twoWord)) return twoWord; + } + } + return symbol; } diff --git a/FrySharp.Core/Services/Languages/Sql/SqlResultStream.cs b/FrySharp.Core/Services/Languages/Sql/SqlResultStream.cs new file mode 100644 index 00000000..33dc5a64 --- /dev/null +++ b/FrySharp.Core/Services/Languages/Sql/SqlResultStream.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; + +namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; + +/// One piece of what sqlite3 -json printed: a result set, or the text around it. and +/// are character offsets in the output. +public abstract record SqlOutputSegment(int Start, int End); + +/// Text a script printed (.print, a dot-command's answer): not a result set. +public sealed record SqlTextSegment(int Start, int End, string Text) : SqlOutputSegment(Start, End); + +/// The rows one statement returned. +public sealed record SqlTableSegment(int Start, int End, DumpTableResult Table) : SqlOutputSegment(Start, End); + +/// +/// Reads the output of sqlite3 -json into result sets and text. SQLite writes a statement's rows as one JSON array +/// with one row to a line ([{...},\n{...}]), a statement without rows as nothing, and everything else a script +/// prints as it is, so a line that starts a result set is told from text by being the start of an array that parses. +/// A script that changes the output mode (.mode csv) simply gets its text. +/// +public static class SqlResultStream +{ + public static IReadOnlyList Split(string output) + { + ArgumentNullException.ThrowIfNull(output); + var segments = new List(); + var lines = Lines(output); + + var i = 0; + while (i < lines.Count) + { + var line = lines[i]; + if (StartsResultSet(line.Text) && TryReadResultSet(lines, i, out var table, out var last)) + { + segments.Add(new SqlTableSegment(line.Start, lines[last].End, table)); + i = last + 1; + continue; + } + + segments.Add(new SqlTextSegment(line.Start, line.End, output[line.Start..line.End])); + i++; + } + + return segments; + } + + /// The text of only, as a console would show it. + public static string TextOf(IEnumerable segments) => + string.Concat(segments.OfType().Select(s => s.Text)); + + private readonly record struct Line(int Start, int End, string Text); + + private static List Lines(string output) + { + var lines = new List(); + var start = 0; + while (start < output.Length) + { + var newline = output.IndexOf('\n', start); + var end = newline < 0 ? output.Length : newline + 1; + lines.Add(new Line(start, end, output[start..end].TrimEnd('\r', '\n'))); + start = end; + } + + return lines; + } + + private static bool StartsResultSet(string line) => line.StartsWith("[{", StringComparison.Ordinal); + + // A result set is the lines from "[{" to the first line ending "}]": a row ends "}," and only the last one "}]". + private static bool TryReadResultSet(List lines, int first, out DumpTableResult table, out int last) + { + table = null!; + for (last = first; last < lines.Count; last++) + { + var text = lines[last].Text; + if (last > first && StartsResultSet(text)) break; + if (!text.EndsWith("}]", StringComparison.Ordinal)) continue; + + var json = string.Join("\n", lines.Skip(first).Take(last - first + 1).Select(l => l.Text)); + return TryBuild(json, out table); + } + + last = first; + return false; + } + + private static bool TryBuild(string json, out DumpTableResult table) + { + table = null!; + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() == 0) return false; + + var firstRow = root[0]; + if (firstRow.ValueKind != JsonValueKind.Object) return false; + + // Columns by position, not by name: "select 1 as a, 2 as a" has two. + var headers = firstRow.EnumerateObject().Select(p => p.Name).ToList(); + var cells = new List>(); + var numeric = Enumerable.Repeat(true, headers.Count).ToArray(); + foreach (var row in root.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) return false; + var values = new List(headers.Count); + var column = 0; + foreach (var property in row.EnumerateObject()) + { + if (column >= headers.Count) break; + if (property.Value.ValueKind is not (JsonValueKind.Number or JsonValueKind.Null)) numeric[column] = false; + values.Add(Cell(property.Value)); + column++; + } + + while (values.Count < headers.Count) values.Add(DumpTableBuilder.CreateCell(null)); + cells.Add(values); + } + + var result = new DumpTableResult("Result"); + for (var c = 0; c < headers.Count; c++) + { + result.Columns.Add(new DumpTableColumn { Header = headers[c], IsNumeric = numeric[c] }); + } + + result.AddRows(cells.Select((values, index) => new DumpTableRow(index, values)).ToList()); + table = result; + return true; + } + catch (JsonException) + { + return false; + } + } + + private static DumpTableCell Cell(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Null or JsonValueKind.Undefined => DumpTableBuilder.CreateCell(null), + JsonValueKind.True => DumpTableBuilder.CreateCell(true), + JsonValueKind.False => DumpTableBuilder.CreateCell(false), + JsonValueKind.Number when value.TryGetInt64(out var whole) => DumpTableBuilder.CreateCell(whole), + JsonValueKind.Number => DumpTableBuilder.CreateCell(value.GetDouble()), + JsonValueKind.String => DumpTableBuilder.CreateCell(value.GetString()), + _ => DumpTableBuilder.CreateCell(value.GetRawText()) + }; +} diff --git a/Services/Languages/Sql/SqlScriptRunner.cs b/FrySharp.Core/Services/Languages/Sql/SqlScriptRunner.cs similarity index 100% rename from Services/Languages/Sql/SqlScriptRunner.cs rename to FrySharp.Core/Services/Languages/Sql/SqlScriptRunner.cs diff --git a/Services/Languages/Sql/SqlToolchainProvider.Discovery.cs b/FrySharp.Core/Services/Languages/Sql/SqlToolchainProvider.Discovery.cs similarity index 100% rename from Services/Languages/Sql/SqlToolchainProvider.Discovery.cs rename to FrySharp.Core/Services/Languages/Sql/SqlToolchainProvider.Discovery.cs diff --git a/Services/Languages/Sql/SqlToolchainProvider.cs b/FrySharp.Core/Services/Languages/Sql/SqlToolchainProvider.cs similarity index 100% rename from Services/Languages/Sql/SqlToolchainProvider.cs rename to FrySharp.Core/Services/Languages/Sql/SqlToolchainProvider.cs diff --git a/Services/Processes/ExternalOutputProcessor.cs b/FrySharp.Core/Services/Processes/ExternalOutputProcessor.cs similarity index 85% rename from Services/Processes/ExternalOutputProcessor.cs rename to FrySharp.Core/Services/Processes/ExternalOutputProcessor.cs index f8f4e6fa..59ee5898 100644 --- a/Services/Processes/ExternalOutputProcessor.cs +++ b/FrySharp.Core/Services/Processes/ExternalOutputProcessor.cs @@ -26,20 +26,31 @@ public sealed class ExternalOutputProcessor /// public const string ShareMarker = "__FRY_SHARE__"; + /// + /// __FRY_INPUT__ {"prompt":"Name? ","password":false}: the program waits for a line on its standard input and asks whoever runs + /// it for one. It is handed to onInputRequest, which answers the program (writes the line, or closes its input), and isn't + /// shown as output. + /// + public const string InputMarker = "__FRY_INPUT__"; + private readonly StringBuilder _partialLine = new(); private readonly Action _onConsoleText; private readonly Action _onRichOutput; private readonly Action? _onShare; + private readonly Action? _onInputRequest; private readonly ProgramVisuals _visuals; private readonly AsciiTableDetector _tableDetector = new(); /// The program's visuals, whose events reach it (); without /// them, its visuals still update in place, but it can't listen to them. - public ExternalOutputProcessor(Action onConsoleText, Action onRichOutput, Action? onShare = null, ProgramVisuals? visuals = null) + /// Called with the prompt and whether it is for a secret when the program asks for a line of input; without it + /// the request is shown as the text it is. + public ExternalOutputProcessor(Action onConsoleText, Action onRichOutput, Action? onShare = null, ProgramVisuals? visuals = null, Action? onInputRequest = null) { _onConsoleText = onConsoleText ?? throw new ArgumentNullException(nameof(onConsoleText)); _onRichOutput = onRichOutput ?? throw new ArgumentNullException(nameof(onRichOutput)); _onShare = onShare; + _onInputRequest = onInputRequest; _visuals = visuals ?? new ProgramVisuals(sink: null); } @@ -112,7 +123,7 @@ private static bool CouldBeProtocolPrefix(StringBuilder sb) // Check if prefix of DisplayMarker ("__FRY_DISPLAY__") or ShareMarker ("__FRY_SHARE__") if (first == '_') { - return IsPrefixOf(sb, DisplayMarker) || IsPrefixOf(sb, ShareMarker); + return IsPrefixOf(sb, DisplayMarker) || IsPrefixOf(sb, ShareMarker) || IsPrefixOf(sb, InputMarker); } // Check if prefix of {"type":"display" @@ -152,6 +163,12 @@ private void ProcessLine(string line, bool isTrailing = false) return; } + // 0c. The program asks for a line of input + if (_onInputRequest != null && trimmed.StartsWith(InputMarker, StringComparison.Ordinal) && TryHandleInputPayload(trimmed[InputMarker.Length..].Trim())) + { + return; + } + // 1. Explicit Fry display marker: __FRY_DISPLAY__ {json} if (trimmed.StartsWith(DisplayMarker, StringComparison.Ordinal)) { @@ -189,6 +206,26 @@ private void ProcessLine(string line, bool isTrailing = false) _onConsoleText(isTrailing ? line : line + "\n"); } + private bool TryHandleInputPayload(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + var prompt = root.TryGetProperty("prompt", out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? string.Empty : string.Empty; + var secret = root.TryGetProperty("password", out var s) && s.ValueKind == JsonValueKind.True; + _onInputRequest!(prompt, secret); + return true; + } + catch (JsonException) + { + // Not a request: it's ordinary output. + } + + return false; + } + private bool TryHandleSharePayload(string json) { try diff --git a/FrySharp.Core/Services/Processes/ProcessLauncher.cs b/FrySharp.Core/Services/Processes/ProcessLauncher.cs index 58628f58..569a004a 100644 --- a/FrySharp.Core/Services/Processes/ProcessLauncher.cs +++ b/FrySharp.Core/Services/Processes/ProcessLauncher.cs @@ -16,6 +16,13 @@ public sealed record ProcessStartSpec /// Variables to set on top of the studio's own environment; a null value removes one. public IReadOnlyDictionary Environment { get; init; } = NoEnvironmentChanges; + /// + /// Sends the program's error output down the same pipe as its output, so the two arrive in the order the program wrote them + /// (two pipes are read by two threads, and a line to stderr can overtake one printed before it). Where it can't be done + /// (Windows has no exec to ask a shell for it) the two stay separate. The program's error text then arrives as output. + /// + public bool MergeStandardError { get; init; } + /// How the command reads in a terminal, for messages. public string CommandLine => string.Join(" ", new[] { FileName }.Concat(Arguments).Select(Quote)); @@ -63,9 +70,11 @@ public sealed class ProcessLauncher : IProcessLauncher { public IManagedProcess Start(ProcessStartSpec spec, Action onStandardOutput, Action onStandardError) { + // Run through the shell only to join the two streams: the shell replaces itself with the program, so the process id is the program's. + var merge = spec.MergeStandardError && !OperatingSystem.IsWindows() && File.Exists("/bin/sh"); var startInfo = new ProcessStartInfo { - FileName = spec.FileName, + FileName = merge ? "/bin/sh" : spec.FileName, UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = true, @@ -75,6 +84,13 @@ public IManagedProcess Start(ProcessStartSpec spec, Action onStandardOut StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), WorkingDirectory = spec.WorkingDirectory ?? string.Empty }; + if (merge) + { + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add("exec \"$0\" \"$@\" 2>&1"); + startInfo.ArgumentList.Add(spec.FileName); + } + foreach (var argument in spec.Arguments) startInfo.ArgumentList.Add(argument); foreach (var (name, value) in spec.Environment) { @@ -119,8 +135,8 @@ public ManagedProcess(Process process, Action onStandardOutput, Action _process.Dispose(), TaskScheduler.Default); } - private static Task Pump(Stream stream, Action sink) => Task.Run(async () => + // Each stream is read on a thread of its own, not a thread-pool task: the pool of a busy host (a test run full of blocked + // threads, a studio with a lot going on) can have no free thread for seconds, and the couple of seconds a program's + // output is given after it ends would pass with its output still unread (it ended with 0 and said nothing). + private static Task Pump(Stream stream, Action sink, string name) { - // Decoding the raw bytes keeps a character split across two reads intact and delivers text as soon as it's - // written, without waiting for the end of a line (a prompt printed with end="", a progress bar). - var decoder = new UTF8Encoding(false).GetDecoder(); - var bytes = new byte[8192]; - var chars = new char[Encoding.UTF8.GetMaxCharCount(bytes.Length)]; - try + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => { - int read; - while ((read = await stream.ReadAsync(bytes)) > 0) + // Decoding the raw bytes keeps a character split across two reads intact and delivers text as soon as it's + // written, without waiting for the end of a line (a prompt printed with end="", a progress bar). + var decoder = new UTF8Encoding(false).GetDecoder(); + var bytes = new byte[8192]; + var chars = new char[Encoding.UTF8.GetMaxCharCount(bytes.Length)]; + try { - var count = decoder.GetChars(bytes, 0, read, chars, 0, flush: false); - if (count > 0) Deliver(sink, new string(chars, 0, count)); + int read; + while ((read = stream.Read(bytes, 0, bytes.Length)) > 0) + { + var count = decoder.GetChars(bytes, 0, read, chars, 0, flush: false); + if (count > 0) Deliver(sink, new string(chars, 0, count)); + } + + var rest = decoder.GetChars(Array.Empty(), 0, 0, chars, 0, flush: true); + if (rest > 0) Deliver(sink, new string(chars, 0, rest)); } - - var rest = decoder.GetChars(Array.Empty(), 0, 0, chars, 0, flush: true); - if (rest > 0) Deliver(sink, new string(chars, 0, rest)); - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) - { - // The pipe closed under the reader: the process was killed or disposed. - } - }); + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) + { + // The pipe closed under the reader: the process was killed or disposed. + } + finally + { + done.TrySetResult(); + } + }) + { IsBackground = true, Name = name }; + thread.Start(); + return done.Task; + } private static void Deliver(Action sink, string text) { diff --git a/FrySharp.Core/Services/Toolchains/HostEnvironment.cs b/FrySharp.Core/Services/Toolchains/HostEnvironment.cs index 724393b3..12502c86 100644 --- a/FrySharp.Core/Services/Toolchains/HostEnvironment.cs +++ b/FrySharp.Core/Services/Toolchains/HostEnvironment.cs @@ -125,8 +125,10 @@ public async Task RunAsync(string fileName, IReadOnlyList // Nothing is typed into a probe: a program waiting for input sees end-of-file instead of hanging. try { process.StandardInput.Close(); } catch (IOException) { } - var stdout = process.StandardOutput.ReadToEndAsync(CancellationToken.None); - var stderr = process.StandardError.ReadToEndAsync(CancellationToken.None); + // Each stream is read on a thread of its own, not a thread-pool task: the pool of a busy host (a test run full of blocked + // threads, a studio with a lot going on) can have no free thread for seconds, and a compiler's messages must not be lost to that. + var stdout = ReadToEndOnAThread(process.StandardOutput); + var stderr = ReadToEndOnAThread(process.StandardError); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(timeout); @@ -143,6 +145,9 @@ public async Task RunAsync(string fileName, IReadOnlyList return new CommandResult(process.ExitCode, await Settle(stdout), await Settle(stderr), false); } + private static Task ReadToEndOnAThread(StreamReader reader) => + Task.Factory.StartNew(reader.ReadToEnd, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + // Output of a killed process may never finish reading (a grandchild can hold the pipe): give up after a moment. private static async Task Settle(Task read) { diff --git a/FrySharp.Sidecar/FrySharp.Sidecar.csproj b/FrySharp.Sidecar/FrySharp.Sidecar.csproj index b51c150d..11700620 100644 --- a/FrySharp.Sidecar/FrySharp.Sidecar.csproj +++ b/FrySharp.Sidecar/FrySharp.Sidecar.csproj @@ -6,6 +6,8 @@ Headless stdio sidecar for FrySharp: powers the VS Code extension over JSON-RPC 2.0 enable enable + + en diff --git a/FrySharp.Sidecar/Program.cs b/FrySharp.Sidecar/Program.cs index 04bc01f1..725a287a 100644 --- a/FrySharp.Sidecar/Program.cs +++ b/FrySharp.Sidecar/Program.cs @@ -88,6 +88,7 @@ public static async Task RunSidecarAsync(string workspaceRoot, CancellationToken languages, runs, notebooks, + new EngineInfoService(languages, workspace), logger); jsonRpc.AddLocalRpcTarget(rpc); diff --git a/FrySharp.Sidecar/Protocol/Protocol.cs b/FrySharp.Sidecar/Protocol/Protocol.cs index 7db294c7..fd570247 100644 --- a/FrySharp.Sidecar/Protocol/Protocol.cs +++ b/FrySharp.Sidecar/Protocol/Protocol.cs @@ -70,6 +70,23 @@ public sealed record LanguageInfo( public sealed record EmptyParams; +// ── Engine info ────────────────────────────────────────────────────────────────────────────────────────────────── + +/// What the engine runs on and which tool each language found: the answer to "why doesn't my Java cell run?". +public sealed record EngineInfo( + string ServerVersion, + int ProtocolVersion, + string Runtime, + string OperatingSystem, + string Architecture, + string WorkspaceRoot, + IReadOnlyList Toolchains); + +/// The toolchain's name and version as it calls itself ("Python 3.12.4"), when found. +/// Where it came from ("Homebrew", "PATH", the studio's own). +/// When not found: what is missing and how to get it. +public sealed record ToolchainReport(string LanguageId, string DisplayName, bool Found, string? Name, string? Version, string? Path, string? Source, string? Missing); + // ── Workspace ──────────────────────────────────────────────────────────────────────────────────────────────────── public sealed record PathParams(string Path); @@ -128,6 +145,18 @@ public sealed record DiagnosticDto(string Severity, string Code, string Message, public sealed record DiagnosticsNotification(string Path, int Version, IReadOnlyList Diagnostics); +// ── Notebook cells: language features that need no document the server holds ──────────────────────────────────────────── + +/// The cell's language id: C# and SQL have completion and hover. +/// The cell's text. +/// The caret, in characters from the start of . +/// The text of the cells before this one that are in the same language, in order: what they define is in scope. +public sealed record CellPositionParams(string Language, string Source, int Offset, IReadOnlyList? Context = null); + +public sealed record CellDiagnosticsParams(string Language, string Source, IReadOnlyList? Context = null); + +public sealed record CellDiagnosticsResult(IReadOnlyList Diagnostics); + // ── Runs ───────────────────────────────────────────────────────────────────────────────────────────────────────── public sealed record RunStartParams(string RunId, string? Path = null, string? Code = null, string? LanguageId = null, IReadOnlyList? Arguments = null); @@ -150,7 +179,9 @@ public sealed record RunProblem(string? Path, string Severity, string Code, stri // ── Notebooks ──────────────────────────────────────────────────────────────────────────────────────────────────── -public sealed record CellOutput(string Kind, string? Text = null, object? Data = null, object? Metadata = null); +/// Names a display a program may update later (a chart it redraws); only visuals have one. +/// True when this redraws the display already shown, rather than showing a new one. +public sealed record CellOutput(string Kind, string? Text = null, object? Data = null, object? Metadata = null, string? DisplayId = null, bool Update = false); public sealed record NotebookCell(string Id, string Type, string? Language, string Source, int? ExecutionCount, bool HasError, IReadOnlyList Outputs); @@ -160,16 +191,35 @@ public sealed record NotebookEdit(string Op, string CellId, int? Index = null, s public sealed record NotebookEditParams(string Path, IReadOnlyList Edits); -public sealed record ExecuteCellParams(string Path, string CellId, string Source, string? Language = null); +/// +/// Which notebook a request is about. A notebook open in an editor is named by NotebookId, any text the client +/// keeps stable for that notebook (its file, an untitled one, one outside the workspace folder); the server then holds only +/// its kernels. One named by Path, relative to the workspace, is a file the server opens, edits and saves. +/// +public sealed record NotebookRef(string? Path = null, string? NotebookId = null); + +/// The notebook file, relative to the workspace, or any name when is given. +/// Names the notebook for a client that holds it itself: the server keeps its kernels, not its cells. +/// The folder the notebook's kernels run in (its own folder); the workspace root without it. +/// Names this run. Every notification about it carries it, so a client with several cells running (or the same cell run twice) cannot mix them up. +public sealed record ExecuteCellParams(string Path, string CellId, string Source, string? Language = null, string? NotebookId = null, string? WorkingDirectory = null, string? ExecutionId = null); + +/// The number of the last notification this run sent: the reply may arrive before some of them, so a client waits until it has handled this many. +/// Where the cell went wrong, in the cell's own lines and columns (1-based): compile errors, or the line a traceback names. +public sealed record ExecuteCellResult(int ExecutionCount, bool Success, long DurationMs, int LastSeq = 0, IReadOnlyList? Diagnostics = null, CellError? Error = null); -public sealed record ExecuteCellResult(int ExecutionCount, bool Success, long DurationMs); +/// Why a cell failed, in a line: what a client shows as the cell's error (not when the cell was stopped). +/// The kind of error: an exception's class, a compiler's code (CS0103, E0308), "panic". +public sealed record CellError(string Name, string Message); public sealed record NotebookSaveParams(string Path, bool Force = false); public sealed record VariableDto(string Name, string Type, string Value, string Kernel); -public sealed record NotebookOutputNotification(string Path, string CellId, CellOutput Output); +/// 1, 2, 3... for the notifications of one run (), in the order they were produced. +public sealed record NotebookOutputNotification(string Path, string CellId, CellOutput Output, string? ExecutionId = null, int Seq = 0); -public sealed record CellStateNotification(string Path, string CellId, bool Running, int? ExecutionCount, bool? Success, long? DurationMs); +public sealed record CellStateNotification(string Path, string CellId, bool Running, int? ExecutionCount, bool? Success, long? DurationMs, string? ExecutionId = null, int Seq = 0); -public sealed record InputRequest(string Prompt, bool Password); +/// A running cell asks for a line of input (input(), Console.ReadLine()): the client shows a box and answers with the text, or null for the end of input. +public sealed record InputRequest(string Prompt, bool Password, string? Path = null, string? CellId = null, string? ExecutionId = null); diff --git a/FrySharp.Sidecar/Services/CellScript.cs b/FrySharp.Sidecar/Services/CellScript.cs new file mode 100644 index 00000000..e0b5bef8 --- /dev/null +++ b/FrySharp.Sidecar/Services/CellScript.cs @@ -0,0 +1,54 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace FrySharp.Sidecar.Services; + +/// +/// A notebook cell and what the cells before it define, as one piece of C# for the analysis that completes and checks code +/// (which reads one document). The kernel runs cells one at a time and keeps what they define; this is the same seen from +/// the text: the earlier cells first, then this one, so the variables, types and methods of the earlier ones are in scope. +/// +public static partial class CellScript +{ + [GeneratedRegex(@"^using\s+(static\s+)?[A-Za-z_][\w.]*\s*(=\s*[\w.<>,\s?\[\]]+)?;\s*$")] + private static partial Regex UsingDirective(); + + /// + /// The earlier cells (their using lines first, as C# wants them; the directives of the notebook, #!python + /// and #r, as empty lines) followed by , and where in that text the caret is. + /// + public static (string Text, int Offset, int PrefixLines) Compose(IReadOnlyList? context, string source, int offset) + { + var usings = new StringBuilder(); + var body = new StringBuilder(); + foreach (var cell in context ?? []) + { + foreach (var line in cell.Replace("\r\n", "\n").Split('\n')) + { + var trimmed = line.Trim(); + if (UsingDirective().IsMatch(trimmed)) + { + usings.Append(trimmed).Append('\n'); + } + else if (IsNotebookDirective(trimmed)) + { + body.Append('\n'); + } + else + { + body.Append(line).Append('\n'); + } + } + } + + var prefix = usings.Append(body).ToString(); + var lines = prefix.Count(c => c == '\n'); + return (prefix + source, prefix.Length + Math.Clamp(offset, 0, source.Length), lines); + } + + // #!csharp, #!share ... (a notebook's own), and the references a script loads: not something the analysis can read. + private static bool IsNotebookDirective(string trimmed) => + (trimmed.StartsWith("#!", StringComparison.Ordinal) && !trimmed.StartsWith("#![", StringComparison.Ordinal)) + || trimmed.StartsWith("#r ", StringComparison.Ordinal) + || trimmed.StartsWith("#load ", StringComparison.Ordinal); +} diff --git a/FrySharp.Sidecar/Services/EngineInfoService.cs b/FrySharp.Sidecar/Services/EngineInfoService.cs new file mode 100644 index 00000000..af3cf0c2 --- /dev/null +++ b/FrySharp.Sidecar/Services/EngineInfoService.cs @@ -0,0 +1,58 @@ +using System.Runtime.InteropServices; +using FrySharp.Sidecar.Hosting; +using FrySharp.Sidecar.Protocol; +using PdfEditorApp.Plugins.CSharpEditor.Services.Execution; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; + +namespace FrySharp.Sidecar.Services; + +/// What the engine is running on and what each language found to run with, for "FrySharp: Show Engine Info". +public sealed class EngineInfoService(LanguageRegistry languages, WorkspaceService workspace) +{ + // A probe runs the tool (python --version ...): none may hold the answer up for long. + private static readonly TimeSpan ProbeLimit = TimeSpan.FromSeconds(20); + + public async Task GetAsync(CancellationToken ct) + { + var reports = await Task.WhenAll(languages.All + .Where(language => NotebookDirectiveRunner.IsNotebookLanguage(language) || FileRunner.CanRun(language)) + .Select(language => ReportAsync(language, ct))); + + return new EngineInfo( + typeof(EngineInfoService).Assembly.GetName().Version?.ToString(3) ?? "1.0.0", + ProtocolVersion.Current, + RuntimeInformation.FrameworkDescription, + RuntimeInformation.OSDescription, + RuntimeInformation.OSArchitecture.ToString(), + workspace.Paths.Root, + reports.OrderBy(r => r.DisplayName, StringComparer.OrdinalIgnoreCase).ToList()); + } + + private static async Task ReportAsync(ILanguageEngine language, CancellationToken ct) + { + // C# runs inside the engine itself: its toolchain is the runtime the engine runs on + if (language.Toolchain == null) + { + return new ToolchainReport(language.Id, language.DisplayName, true, RuntimeInformation.FrameworkDescription, Environment.Version.ToString(3), null, "built into the engine", null); + } + + try + { + using var limit = CancellationTokenSource.CreateLinkedTokenSource(ct); + limit.CancelAfter(ProbeLimit); + var resolution = await language.Toolchain.ResolveAsync(new ToolchainQuery(), limit.Token).ConfigureAwait(false); + if (resolution.Toolchain is { } found) + { + return new ToolchainReport(language.Id, language.DisplayName, true, found.DisplayName, found.Version.ToString(), found.ExecutablePath, found.Source, null); + } + + return new ToolchainReport(language.Id, language.DisplayName, false, null, null, null, null, resolution.Missing?.ToText() ?? $"{language.DisplayName}'s tools were not found."); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new ToolchainReport(language.Id, language.DisplayName, false, null, null, null, null, "Looking for it took too long."); + } + } +} diff --git a/FrySharp.Sidecar/Services/LanguageFeatures.cs b/FrySharp.Sidecar/Services/LanguageFeatures.cs index 0767b175..eb82b954 100644 --- a/FrySharp.Sidecar/Services/LanguageFeatures.cs +++ b/FrySharp.Sidecar/Services/LanguageFeatures.cs @@ -4,7 +4,9 @@ using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; using PdfEditorApp.Plugins.CSharpEditor.Services.Roslyn; using StreamJsonRpc; @@ -43,23 +45,28 @@ public async Task CompleteAsync(SidecarSession session, Positi if (!IsCSharp(request.Path)) return new CompletionResult([]); var items = await Task.Run(() => _completion.GetCompletionsAsync(text, offset, ct: ct), ct).ConfigureAwait(false); - return new CompletionResult(items.Select(item => new CompletionItem( - item.DisplayText, - item.InsertionText, - item.Kind.ToString(), - item.Signature ?? item.ReturnType, - item.Documentation, - $"{1000 - Math.Clamp((int)item.Priority, 0, 999):D4}{item.DisplayText}")).ToList()); + return new CompletionResult(items.Select(ToItem).ToList()); } + private static CompletionItem ToItem(CSharpCompletionItem item) => new( + item.DisplayText, + item.InsertionText, + item.Kind.ToString(), + item.Signature ?? item.ReturnType, + item.Documentation, + $"{1000 - Math.Clamp((int)item.Priority, 0, 999):D4}{item.DisplayText}"); + public async Task HoverAsync(SidecarSession session, PositionParams request, CancellationToken ct) { var (text, offset) = TextAt(session, request); if (!IsCSharp(request.Path)) return null; var info = await Task.Run(() => _quickInfo.GetQuickInfoAsync(text, offset, ct), ct).ConfigureAwait(false); - if (info == null) return null; + return info == null ? null : ToHover(info); + } + private static HoverResult? ToHover(CSharpQuickInfo info) + { var parts = new List(); var signature = string.Concat(info.Signature.Select(s => s.Text)); if (signature.Length > 0) parts.Add($"```csharp\n{signature}\n```"); @@ -72,6 +79,92 @@ public async Task CompleteAsync(SidecarSession session, Positi return parts.Count == 0 ? null : new HoverResult(parts); } + public static bool IsCSharpLanguage(string language) => string.Equals(language, LanguageIds.CSharp, StringComparison.OrdinalIgnoreCase); + + /// The languages this server has completion, hover and live checks for in a notebook cell. + public static bool CanCompleteCells(string language) => IsCSharpLanguage(language) || IsSqlLanguage(language); + + public static bool IsSqlLanguage(string language) => string.Equals(language, LanguageIds.Sql, StringComparison.OrdinalIgnoreCase); + + /// Completions for a notebook cell: its text after the earlier cells, so what they define is offered. + public async Task CompleteCellAsync(CellPositionParams request, CancellationToken ct) + { + if (IsSqlLanguage(request.Language)) + { + // the tables and views the cells before create are offered too: they are the same text to the completer + var joined = string.Concat((request.Context ?? []).Select(cell => cell + "\n")); + var sqlItems = SqlCompletions.Get(joined + request.Source, joined.Length + request.Offset); + // a dot-command is offered after the dot that was typed: it inserts its name, not a second dot + return new CompletionResult(sqlItems.Select(item => ToItem(item) with { InsertText = item.DisplayText.StartsWith('.') ? item.InsertionText[1..] : item.InsertionText }).ToList()); + } + + if (!IsCSharpLanguage(request.Language)) return new CompletionResult([]); + var (text, offset, _) = CellScript.Compose(request.Context, request.Source, request.Offset); + + var items = await Task.Run(() => _completion.GetCompletionsAsync(text, offset, ct: ct), ct).ConfigureAwait(false); + return new CompletionResult(items.Select(ToItem).ToList()); + } + + /// What the symbol under the caret of a notebook cell is, as markdown. + public async Task HoverCellAsync(CellPositionParams request, CancellationToken ct) + { + if (IsSqlLanguage(request.Language)) + { + var symbol = SqlQuickInfoProvider.ExtractSymbol(request.Source, request.Offset); + if (symbol is null || SqlQuickInfoProvider.Lookup(symbol) is not { } entry) return null; + var parts = new List { $"```sql\n{entry.Signature}\n```" }; + if (!string.IsNullOrEmpty(entry.Module)) parts.Add($"*{entry.Module}*"); + parts.Add(entry.Summary); + return new HoverResult(parts); + } + + if (!IsCSharpLanguage(request.Language)) return null; + var (text, offset, _) = CellScript.Compose(request.Context, request.Source, request.Offset); + + var info = await Task.Run(() => _quickInfo.GetQuickInfoAsync(text, offset, ct), ct).ConfigureAwait(false); + return info == null ? null : ToHover(info); + } + + // What only joining cells in one text makes wrong is not the cell's: a name two cells both declare (the kernel allows it, + // the later one wins, and a cell is run again), which the joined text calls a duplicate (CS0128, CS0102...) and then + // an ambiguity (CS0229) at every use; and statements after a type (CS8803). + private static readonly HashSet JoinedTextCodes = ["CS0128", "CS0102", "CS0111", "CS0101", "CS0100", "CS0136", "CS0229", "CS0121", "CS8803", "CS8805"]; + + /// + /// Where a notebook cell goes wrong, in the cell's own lines: its directives (#!python, #!share) in any language, + /// and for C# what the compiler finds with the earlier cells before it. + /// + public async Task DiagnoseCellAsync(CellDiagnosticsParams request, CancellationToken ct) + { + var diagnostics = new List(); + + var directives = NotebookCellDirectives.Parse(request.Source, _languages, request.Language); + foreach (var error in directives.Errors) + { + // "Line 3: "#!pyton" isn't a directive. ..." + var match = System.Text.RegularExpressions.Regex.Match(error, @"^Line (\d+): (.*)$"); + var line = match.Success ? int.Parse(match.Groups[1].Value) : 1; + var message = match.Success ? match.Groups[2].Value : error; + var lineText = request.Source.Replace("\r\n", "\n").Split('\n').ElementAtOrDefault(line - 1) ?? string.Empty; + diagnostics.Add(new DiagnosticDto("error", "FRY0001", message, line, 1, line, Math.Max(2, lineText.Length + 1))); + } + + if (IsCSharpLanguage(directives.LanguageId ?? request.Language) && directives.Errors.Count == 0) + { + var (text, _, prefixLines) = CellScript.Compose(request.Context, request.Source, 0); + var found = await Task.Run(() => _compiler.CheckDiagnostics(text), ct).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + diagnostics.AddRange(found + .Where(d => d.Severity != DiagnosticSeverity.Hidden && d.Line > prefixLines && !JoinedTextCodes.Contains(d.Id)) + .Select(d => ToDto(d) with + { + StartLine = d.Line - prefixLines, + EndLine = Math.Max(d.Line, d.EndLine) - prefixLines + })); + } + return new CellDiagnosticsResult(diagnostics); + } + public void DocumentChanged(SidecarSession session, OpenDocument document) { if (!IsCSharp(document.Path)) return; diff --git a/FrySharp.Sidecar/Services/NotebookService.cs b/FrySharp.Sidecar/Services/NotebookService.cs index a25163de..b6b65f85 100644 --- a/FrySharp.Sidecar/Services/NotebookService.cs +++ b/FrySharp.Sidecar/Services/NotebookService.cs @@ -117,9 +117,20 @@ public void Edit(SidecarSession session, NotebookEditParams request) } } + /// The key a notebook is held under: its id when the client holds it, else its path. + public static string KeyOf(NotebookRef request) => KeyOf(request.NotebookId, request.Path); + + public static string KeyOf(string? notebookId, string? path) => + notebookId is { Length: > 0 } ? notebookId + : path is { Length: > 0 } ? path + : throw Error(ErrorCodes.Invalid, "Name the notebook with notebookId or path."); + public async Task ExecuteAsync(SidecarSession session, ExecuteCellParams request, CancellationToken ct) { - var notebook = await GetOrOpenAsync(session, request.Path, ct).ConfigureAwait(false); + var key = KeyOf(request.NotebookId, request.Path); + var notebook = request.NotebookId is { Length: > 0 } + ? GetOrCreateHeld(key, request.WorkingDirectory) + : await GetOrOpenAsync(session, key, ct).ConfigureAwait(false); await notebook.ExecutionGate.WaitAsync(ct).ConfigureAwait(false); try { @@ -153,35 +164,36 @@ public async Task ExecuteAsync(SidecarSession session, Execut notebook.Dirty = true; } - session.Notify(ClientMethods.NotebookCellState, new CellStateNotification(notebook.Path, cell.Id, true, executionCount, null, null)); + // Everything this run tells the client is numbered, in the order it was made: the reply can overtake the last + // of it on the way, and the client waits for LastSeq before it calls the cell finished. + var sequence = new Sequence(session, notebook.Path, cell.Id, request.ExecutionId); + sequence.State(running: true, executionCount, success: null, durationMs: null); + using var execution = new CancellationTokenSource(); notebook.BeginExecution(execution); var clock = Stopwatch.StartNew(); - var success = false; + var outcome = CellOutcome.Failed; - using var output = new RunOutput(text => session.Notify(ClientMethods.NotebookOutput, - new NotebookOutputNotification(notebook.Path, cell.Id, new CellOutput("stream", text)))); + using var output = new RunOutput(text => sequence.Output(new CellOutput("stream", text))); void Write(string text) { - lock (notebook.Gate) cell.OutputText += text; + if (notebook.KeepsOutputs) lock (notebook.Gate) cell.OutputText += text; output.Append(text); } void Show(RichCellOutput rich) { output.Flush(); - lock (notebook.Gate) Keep(cell, rich); + if (notebook.KeepsOutputs) lock (notebook.Gate) Keep(cell, rich); if (RichOutputMime.ToBundle(rich, out var metadata) is { } bundle) { - var data = Newtonsoft.Json.Linq.JObject.Parse(bundle.ToJsonString()); - var meta = metadata.Count > 0 ? Newtonsoft.Json.Linq.JObject.Parse(metadata.ToJsonString()) : null; - session.Notify(ClientMethods.NotebookOutput, - new NotebookOutputNotification(notebook.Path, cell.Id, new CellOutput("display", Data: data, Metadata: meta))); + sequence.Output(Display(bundle, metadata, rich.Visual?.DisplayId, update: false)); + if (rich.Visual is { } visual) Redraw(sequence, visual); } } try { - success = await RunCellAsync(notebook, cell, session, Write, Show, execution.Token).ConfigureAwait(false); + outcome = await RunCellAsync(notebook, cell, request, sequence, Write, Show, execution.Token).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -206,14 +218,15 @@ void Show(RichCellOutput rich) lock (notebook.Gate) { cell.IsExecuting = false; - cell.HasError = !success; + cell.HasError = !outcome.Success; cell.ExecutionTimeText = $"{clock.Elapsed.TotalMilliseconds:N0} ms"; } - session.Notify(ClientMethods.NotebookCellState, - new CellStateNotification(notebook.Path, cell.Id, false, executionCount, success, clock.ElapsedMilliseconds)); + sequence.State(running: false, executionCount, outcome.Success, clock.ElapsedMilliseconds); } - return new ExecuteCellResult(executionCount, success, clock.ElapsedMilliseconds); + // Send what was queued before the reply does. + await session.DrainAsync().ConfigureAwait(false); + return new ExecuteCellResult(executionCount, outcome.Success, clock.ElapsedMilliseconds, sequence.Last, outcome.Diagnostics, outcome.Error); } finally { @@ -221,7 +234,30 @@ void Show(RichCellOutput rich) } } - private async Task RunCellAsync(OpenNotebook notebook, NotebookCellItem cell, SidecarSession session, + // A display as the client gets it: the engine's MIME bundle, and the id a program can update it by. + private static CellOutput Display(System.Text.Json.Nodes.JsonObject bundle, System.Text.Json.Nodes.JsonObject metadata, string? displayId, bool update) + { + var data = Newtonsoft.Json.Linq.JObject.Parse(bundle.ToJsonString()); + var meta = metadata.Count > 0 ? Newtonsoft.Json.Linq.JObject.Parse(metadata.ToJsonString()) : null; + return new CellOutput("display", Data: data, Metadata: meta, DisplayId: displayId, Update: update); + } + + // A chart a program redraws (handle.update(...)) changes the visual itself: send each new picture as an update of the display. + private static void Redraw(Sequence sequence, VisualOutput visual) + { + void OnChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(VisualOutput.Spec)) return; + var redrawn = new RichCellOutput { Visual = visual }; + if (RichOutputMime.ToBundle(redrawn, out var metadata) is { } bundle) + { + sequence.Output(Display(bundle, metadata, visual.DisplayId, update: true)); + } + } + visual.PropertyChanged += OnChanged; + } + + private async Task RunCellAsync(OpenNotebook notebook, NotebookCellItem cell, ExecuteCellParams request, Sequence sequence, Action write, Action show, CancellationToken ct) { var own = cell.Language ?? notebook.Document.Kernel; @@ -234,46 +270,101 @@ private async Task RunCellAsync(OpenNotebook notebook, NotebookCellItem ce write(language == null ? $"❌ This cell is written in \"{own}\", which cannot run here.\n" : $"❌ {language.DisplayName} code cannot run in notebook cells.\n"); - return false; + return CellOutcome.Failed; } if (directives.Errors.Count > 0) { foreach (var error in directives.Errors) write($"❌ {error}\n"); - return false; + return CellOutcome.Failed; } var query = new ToolchainQuery(Path.GetDirectoryName(notebook.FullPath), _workspace.Paths.Root); - if (!await notebook.Directives.RunAsync(directives, language, kernel, query, write, ct).ConfigureAwait(false)) return false; + if (!await notebook.Directives.RunAsync(directives, language, kernel, query, write, ct).ConfigureAwait(false)) return CellOutcome.Failed; - var request = new KernelExecutionRequest + var kernelRequest = new KernelExecutionRequest { Code = directives.Code, SourceId = cell.Id, Label = $"[{cell.ExecutionCount}]", OnConsole = write, OnRichOutput = show, - OnInputRequest = (prompt, password, token) => session.RequestInputAsync(new InputRequest(prompt, password), token), + OnInputRequest = (prompt, password, token) => sequence.Session.RequestInputAsync(new InputRequest(prompt, password, notebook.Path, cell.Id, request.ExecutionId), token), }; var execution = kernel.CanForceStop - ? kernel.ExecuteAsync(request, ct) - : Task.Run(() => kernel.ExecuteAsync(request, ct), CancellationToken.None); + ? kernel.ExecuteAsync(kernelRequest, ct) + : Task.Run(() => kernel.ExecuteAsync(kernelRequest, ct), CancellationToken.None); if (!kernel.CanForceStop && !await ExecutionAbandonment.WaitWithGraceAsync(execution, ct).ConfigureAwait(false)) { write("\n⚠️ The cell did not respond to Stop and was abandoned; its kernel keeps running in the background.\n"); - return false; + return CellOutcome.Failed; } var result = await execution.ConfigureAwait(false); + var diagnostics = new List(); if (!result.Success && result.Diagnostics.Count > 0) { foreach (var diagnostic in result.Diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)) { write($"{diagnostic.LocationString}: {diagnostic.Id} {diagnostic.Message}\n"); + diagnostics.Add(LanguageFeatures.ToDto(diagnostic)); + } + } + else if (!result.Success && !result.WasCancelled && result.ErrorLine is { } line) + { + // A traceback in Python or JavaScript names the line: whole line, as far as the editor goes. + diagnostics.Add(new DiagnosticDto("error", result.ErrorName ?? "Error", result.ErrorMessage, line, 1, line, int.MaxValue)); + } + return new CellOutcome(result.Success, diagnostics, !result.Success && !result.WasCancelled ? ErrorOf(result) : null); + } + + // Why a failed cell failed, in a line: the kind of error and its message (the text printed above has all of it). + private static CellError ErrorOf(KernelExecutionResult result) + { + var errors = result.Diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList(); + var name = !string.IsNullOrWhiteSpace(result.ErrorName) ? result.ErrorName!.Trim() + : errors.FirstOrDefault() is { Id.Length: > 0 } first ? first.Id + : "Error"; + var message = !string.IsNullOrWhiteSpace(result.ErrorMessage) + ? result.ErrorMessage!.Trim().Split('\n')[0].Trim() + : errors.FirstOrDefault()?.Message.Split('\n')[0].Trim() ?? "The cell failed."; + if (message.Length > 300) message = message[..300] + "…"; + if (errors.Count > 1) message += $" (and {errors.Count - 1} more)"; + return new CellError(name, message); + } + + private sealed record CellOutcome(bool Success, IReadOnlyList Diagnostics, CellError? Error = null) + { + public static CellOutcome Failed { get; } = new(false, []); + } + + /// Numbers the notifications of one run, and sends them in that order. + private sealed class Sequence(SidecarSession session, string path, string cellId, string? executionId) + { + private readonly Lock _gate = new(); + private int _last; + + public SidecarSession Session { get; } = session; + + /// The number of the last notification made. + public int Last + { + get + { + lock (_gate) return _last; } } - return result.Success; + + public void Output(CellOutput output) + { + lock (_gate) Session.Notify(ClientMethods.NotebookOutput, new NotebookOutputNotification(path, cellId, output, executionId, ++_last)); + } + + public void State(bool running, int? executionCount, bool? success, long? durationMs) + { + lock (_gate) Session.Notify(ClientMethods.NotebookCellState, new CellStateNotification(path, cellId, running, executionCount, success, durationMs, executionId, ++_last)); + } } public void Interrupt(string path) => Get(path).CancelExecution(); @@ -362,6 +453,20 @@ private async Task GetOrOpenAsync(SidecarSession session, string p return _open.GetOrAdd(relative, created); } + // A notebook the client holds (VS Code has its cells and its outputs): the server keeps only its kernels, and the folder + // they run in, which need not be inside the workspace. + private OpenNotebook GetOrCreateHeld(string id, string? workingDirectory) + { + if (_open.TryGetValue(id, out var existing)) return existing; + + var directory = workingDirectory is { Length: > 0 } && Directory.Exists(workingDirectory) ? workingDirectory : _workspace.Paths.Root; + var created = new OpenNotebook(id, System.IO.Path.Combine(directory, "notebook.frynb"), new NotebookDocumentItem { Title = id, Kernel = LanguageIds.CSharp }, + NotebookFileKind.Frynb, "0", _languages, _workspace.Paths.Root) { KeepsOutputs = false }; + var winner = _open.GetOrAdd(id, created); + if (!ReferenceEquals(winner, created)) created.Dispose(); + return winner; + } + private OpenNotebook Get(string path) { if (_open.TryGetValue(path, out var notebook)) return notebook; @@ -497,6 +602,9 @@ public OpenNotebook(string path, string fullPath, NotebookDocumentItem document, private CancellationTokenSource? _execution; public Lock Gate { get; } = new(); + + /// False for a notebook the client holds: its cells and outputs are the client's, so the server does not keep them. + public bool KeepsOutputs { get; init; } = true; public string Path { get; } public string FullPath { get; } public NotebookDocumentItem Document { get; } diff --git a/FrySharp.Sidecar/Sessions/SidecarRpc.cs b/FrySharp.Sidecar/Sessions/SidecarRpc.cs index 2512dbd3..95a2102a 100644 --- a/FrySharp.Sidecar/Sessions/SidecarRpc.cs +++ b/FrySharp.Sidecar/Sessions/SidecarRpc.cs @@ -21,6 +21,7 @@ public sealed class SidecarRpc( LanguageRegistry languages, RunService runs, NotebookService notebooks, + EngineInfoService engineInfo, ILogger logger) { private SidecarSession? _session; @@ -47,6 +48,9 @@ public InitializeResult Initialize(InitializeParams request) options.MaxOpenFileBytes); } + [JsonRpcMethod("fry/engine/info", UseSingleObjectParameterDeserialization = true)] + public Task EngineInfoAsync(EmptyParams request, CancellationToken ct) => engineInfo.GetAsync(ct); + private List Languages() { return languages.All.Select(l => new LanguageInfo( @@ -55,7 +59,7 @@ private List Languages() l.Aliases, l.LineCommentPrefix, l.IconKind, l.AccentHex, CanRun: FileRunner.CanRun(l), CanRunCells: NotebookDirectiveRunner.IsNotebookLanguage(l), - HasCompletion: l.Id == LanguageIds.CSharp, + HasCompletion: LanguageFeatures.CanCompleteCells(l.Id), l.NewFileTemplate)).ToList(); } @@ -147,6 +151,15 @@ public Task CompletionAsync(PositionParams request, Cancellati public Task HoverAsync(PositionParams request, CancellationToken ct) => languageFeatures.HoverAsync(Require(), request, ct); + [JsonRpcMethod("fry/language/completeCell", UseSingleObjectParameterDeserialization = true)] + public Task CompleteCellAsync(CellPositionParams request, CancellationToken ct) => languageFeatures.CompleteCellAsync(request, ct); + + [JsonRpcMethod("fry/language/hoverCell", UseSingleObjectParameterDeserialization = true)] + public Task HoverCellAsync(CellPositionParams request, CancellationToken ct) => languageFeatures.HoverCellAsync(request, ct); + + [JsonRpcMethod("fry/language/diagnoseCell", UseSingleObjectParameterDeserialization = true)] + public Task DiagnoseCellAsync(CellDiagnosticsParams request, CancellationToken ct) => languageFeatures.DiagnoseCellAsync(request, ct); + // ── Runs ───────────────────────────────────────────────────────────────────────────────────────────────────── [JsonRpcMethod("fry/run/start", UseSingleObjectParameterDeserialization = true)] @@ -173,20 +186,20 @@ public Task CompletionAsync(PositionParams request, Cancellati public Task ExecuteCellAsync(ExecuteCellParams request, CancellationToken ct) => notebooks.ExecuteAsync(Require(), request, ct); [JsonRpcMethod("fry/notebook/interrupt", UseSingleObjectParameterDeserialization = true)] - public void InterruptNotebook(PathParams request) => notebooks.Interrupt(request.Path); + public void InterruptNotebook(NotebookRef request) => notebooks.Interrupt(NotebookService.KeyOf(request)); [JsonRpcMethod("fry/notebook/restart", UseSingleObjectParameterDeserialization = true)] - public void RestartNotebook(PathParams request) => notebooks.Restart(request.Path); + public void RestartNotebook(NotebookRef request) => notebooks.Restart(NotebookService.KeyOf(request)); [JsonRpcMethod("fry/notebook/variables", UseSingleObjectParameterDeserialization = true)] - public Task> NotebookVariablesAsync(PathParams request, CancellationToken ct) => - notebooks.VariablesAsync(request.Path, ct); + public Task> NotebookVariablesAsync(NotebookRef request, CancellationToken ct) => + notebooks.VariablesAsync(NotebookService.KeyOf(request), ct); [JsonRpcMethod("fry/notebook/save", UseSingleObjectParameterDeserialization = true)] public Task SaveNotebookAsync(NotebookSaveParams request, CancellationToken ct) => notebooks.SaveAsync(request, ct); [JsonRpcMethod("fry/notebook/close", UseSingleObjectParameterDeserialization = true)] - public Task CloseNotebookAsync(PathParams request) => notebooks.CloseAsync(request.Path); + public Task CloseNotebookAsync(NotebookRef request) => notebooks.CloseAsync(NotebookService.KeyOf(request)); private SidecarSession Require() { diff --git a/FrySharp.Sidecar/Sessions/SidecarSession.cs b/FrySharp.Sidecar/Sessions/SidecarSession.cs index 94d37610..49abaf91 100644 --- a/FrySharp.Sidecar/Sessions/SidecarSession.cs +++ b/FrySharp.Sidecar/Sessions/SidecarSession.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text; +using System.Threading.Channels; using FrySharp.Sidecar.Protocol; using Microsoft.Extensions.Logging; using StreamJsonRpc; @@ -20,18 +21,23 @@ public sealed class OpenDocument(string path, string fullPath, string text, int } /// -/// The sidecar's active session with VS Code over the stdio pipe. +/// The sidecar's active session with VS Code over the stdio pipe. Notifications leave through one queue and are sent one +/// at a time, so the client receives them in the order they were made; waits until those made so +/// far have been sent (a reply that must not overtake them waits for it). /// public sealed class SidecarSession : IAsyncDisposable { private readonly JsonRpc _connection; private readonly ILogger _logger; + private readonly Channel> _outbox = Channel.CreateUnbounded>(new UnboundedChannelOptions { SingleReader = true }); + private readonly Task _pump; public SidecarSession(string clientId, JsonRpc connection, ILogger logger) { ClientId = clientId; _connection = connection; _logger = logger; + _pump = Task.Run(PumpAsync); } public string ClientId { get; } @@ -42,14 +48,47 @@ public SidecarSession(string clientId, JsonRpc connection, ILogger logger) public void Notify(string method, object payload) { - if (_connection.IsDisposed) return; - try + _outbox.Writer.TryWrite(async () => + { + if (_connection.IsDisposed) return; + try + { + await _connection.NotifyWithParameterObjectAsync(method, payload).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send notification {Method}", method); + } + }); + } + + /// Completes once every notification made before this call has been sent. + public Task DrainAsync() + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_outbox.Writer.TryWrite(() => + { + done.TrySetResult(); + return Task.CompletedTask; + })) { - _ = _connection.NotifyWithParameterObjectAsync(method, payload); + done.TrySetResult(); } - catch (Exception ex) + return done.Task; + } + + private async Task PumpAsync() + { + await foreach (var send in _outbox.Reader.ReadAllAsync().ConfigureAwait(false)) { - _logger.LogWarning(ex, "Failed to send notification {Method}", method); + try + { + await send().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "A queued notification failed."); + } } } @@ -66,8 +105,9 @@ public void Notify(string method, object payload) } } - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { - return ValueTask.CompletedTask; + _outbox.Writer.TryComplete(); + await _pump.ConfigureAwait(false); } } diff --git a/Makefile b/Makefile index 8e8dee88..ca4cf62c 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,18 @@ ext-install: cd vscode-frysharp && npm install ext-build: - cd vscode-frysharp && npm run compile + cd vscode-frysharp && npm run build +# The installable extension (vscode-frysharp/frysharp-.vsix): a fresh sidecar published beside it, then the bundle. +# The folder is emptied first so an old engine can never ride along. ext-package: - cd vscode-frysharp && npx @vscode/vsce package + rm -rf vscode-frysharp/bin/sidecar + dotnet publish FrySharp.Sidecar/FrySharp.Sidecar.csproj -c Release -o vscode-frysharp/bin/sidecar + cd vscode-frysharp && npx @vscode/vsce package --no-dependencies + +# The extension's own tests: the notebook file formats and the serializer's adapter (Node), against the fixtures Tests.Core writes. +ext-test: + cd vscode-frysharp && npm run lint && npm run typecheck && npm test test: ifeq ($(time),1) @@ -53,4 +61,4 @@ clean: dotnet clean CSharpEditorPlugin.slnx rm -rf vscode-frysharp/out vscode-frysharp/*.vsix -.PHONY: run run-release sidecar cli build ext-install ext-build ext-package test test-time test-core test-sidecar test-all verify-core clean +.PHONY: run run-release sidecar cli build ext-install ext-build ext-package ext-test test test-time test-core test-sidecar test-all verify-core clean diff --git a/Services/Languages/Cpp/CppDisplayRuntime.cs b/Services/Languages/Cpp/CppDisplayRuntime.cs deleted file mode 100644 index 5e634d37..00000000 --- a/Services/Languages/Cpp/CppDisplayRuntime.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.IO; -using System.Reflection; -using System.Text; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; - -/// -/// Provides zero-configuration runtime support for C++ scripts and algorithms in FrySharp, -/// staging <fry/display.hpp> and "display.hpp" into the compiler include path. -/// User scripts can call canonical visuals (fry::line_chart, fry::tree, etc.), -/// tables, HTML, images, and handles with interactive Results (.DUMP) deck rendering. -/// -public static class CppDisplayRuntime -{ - public static string DisplayHppContent => GetDisplayHppContent(); - - private static string GetDisplayHppContent() - { - var asm = typeof(CppDisplayRuntime).Assembly; - using var stream = asm.GetManifestResourceStream("CppRuntime.display.hpp"); - if (stream != null) - { - using var reader = new StreamReader(stream, Encoding.UTF8); - return reader.ReadToEnd(); - } - - // Fallback to local source file if not yet compiled into assembly - var localSource = Path.Combine(AppContext.BaseDirectory, "Services", "Languages", "Cpp", "Runtime", "display.hpp"); - if (File.Exists(localSource)) return File.ReadAllText(localSource); - - var candidate = Path.Combine(Directory.GetCurrentDirectory(), "Services", "Languages", "Cpp", "Runtime", "display.hpp"); - if (File.Exists(candidate)) return File.ReadAllText(candidate); - - return ""; - } - - public static async Task EnsureIncludeDirectoryAsync(string buildDir, CancellationToken ct = default) - { - var includeDir = Path.Combine(buildDir, "include"); - var fryIncludeDir = Path.Combine(includeDir, "fry"); - - try - { - Directory.CreateDirectory(fryIncludeDir); - - var header1 = Path.Combine(fryIncludeDir, "display.hpp"); - var header2 = Path.Combine(includeDir, "display.hpp"); - var header3 = Path.Combine(includeDir, "fry_display.hpp"); - - var content = GetDisplayHppContent(); - await File.WriteAllTextAsync(header1, content, ct).ConfigureAwait(false); - await File.WriteAllTextAsync(header2, content, ct).ConfigureAwait(false); - await File.WriteAllTextAsync(header3, content, ct).ConfigureAwait(false); - } - catch - { - // Gracefully ignore filesystem contention - } - - return includeDir; - } -} diff --git a/Services/Languages/Cpp/CppLanguage.cs b/Services/Languages/Cpp/CppLanguage.cs index 08e57cd7..a986ee1c 100644 --- a/Services/Languages/Cpp/CppLanguage.cs +++ b/Services/Languages/Cpp/CppLanguage.cs @@ -2,70 +2,22 @@ using AvaloniaEdit.Highlighting; using AvaloniaEdit.Indentation; using PdfEditorApp.Plugins.CSharpEditor.Services.Debugging; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; /// -/// C++, compiled with clang++, g++, or MSVC cl.exe and executed natively for source files, -/// supporting two-phase compilation, structured compiler diagnostics in Problems, and interactive standard input. +/// C++ in the studio: (builds, scripts, the notebook kernel and packages) with the editor's syntax highlighting, +/// static completion and hover, folding and indentation, and the lldb-dap debugger. /// -public sealed class CppLanguage : LanguageDefinition +public sealed class CppLanguage : EngineLanguageDefinition { - private static readonly string[] CppAliases = ["cpp", "c++", "cc", "cplusplus"]; - private static readonly string[] CppExtensions = [".cpp", ".cc", ".cxx", ".hpp", ".h"]; - public CppLanguage(StudioLanguageServices services) + : base(new CppEngine(services.Engines)) { - var toolchain = new CppToolchainProvider( - services.Host, - services.Processes, - services.ToolchainSettings, - Path.Combine(services.BaseDirectory, "cpp")); - CppToolchain = toolchain; - ScriptRunner = new CppBuildAndRunScriptRunner(services.Host); - Packages = new CppPackageManager(toolchain, services.Processes, services.Host); - Debugger = new CppDebuggerProvider(toolchain, services.Processes, services.Host, services.AdapterManager); - NotebookKernels = new DelegateKernelFactory(context => - new CppNotebookKernel(toolchain, services.Processes, services.Host, context)); + Debugger = new CppDebuggerProvider(Engine.CppToolchain, services.Processes, services.Host, services.AdapterManager); } - public CppToolchainProvider CppToolchain { get; } - - public override string Id => LanguageIds.Cpp; - public override string DisplayName => "C++"; - public override JupyterLanguageInfo Jupyter { get; } = new("cpp", "C++", "c++", "c++", ".cpp", "text/x-c++src"); - public override string ShortName => "CPP"; - public override IReadOnlyList Aliases => CppAliases; - public override IReadOnlyList FileExtensions => CppExtensions; - public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; - public override bool IsCompiled => true; - - public override LanguageCapabilities Capabilities => - LanguageCapabilities.StandardInput | - LanguageCapabilities.QuickInfo | - LanguageCapabilities.Completion | - LanguageCapabilities.Debugging | - LanguageCapabilities.Breakpoints | - LanguageCapabilities.NotebookCells | - LanguageCapabilities.ValueSharing | - LanguageCapabilities.Packages; - - public override string IconKind => "LanguageCpp"; - public override string AccentHex => "#00599C"; - public override string LineCommentPrefix => "//"; - public override string RuntimeDescription => "C++ (Clang / GCC / MSVC)"; - - public override string NewFileTemplate => - "// Runs with the C++ compiler (clang++, g++, or cl.exe) installed on this computer (F5).\n" + - "#include \n\n" + - "int main() {\n" + - " std::cout << \"Hello from C++20!\" << std::endl;\n" + - " return 0;\n" + - "}\n"; + public CppToolchainProvider CppToolchain => Engine.CppToolchain; public override IEditorAssistantFactory EditorAssistants { get; } = CppEditorAssistantFactory.Instance; @@ -75,10 +27,5 @@ public CppLanguage(StudioLanguageServices services) public override ILanguageFolding Folding { get; } = new CppFoldingStrategy(); - public override IToolchainProvider Toolchain => CppToolchain; - public override IScriptRunner ScriptRunner { get; } - public override IDebuggerProvider? Debugger { get; } - public override IDiagnosticParser RunDiagnostics { get; } = new ClangGccDiagnosticParser(); - public override IPackageManager Packages { get; } - public override INotebookKernelFactory? NotebookKernels { get; } + public override IDebuggerProvider Debugger { get; } } diff --git a/Services/Languages/Cpp/CppNotebookKernel.CodePreparation.cs b/Services/Languages/Cpp/CppNotebookKernel.CodePreparation.cs deleted file mode 100644 index f384b99a..00000000 --- a/Services/Languages/Cpp/CppNotebookKernel.CodePreparation.cs +++ /dev/null @@ -1,279 +0,0 @@ -using System.Text; -using System.Text.Json; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; - -public sealed partial class CppNotebookKernel -{ - private void ExtractIncludesAndTopLevel(string code, out List includes, out List topLevelItems, out string body) - { - includes = new List(); - topLevelItems = new List(); - var bodyLines = new List(); - - if (code.Contains("int main(") || code.Contains("int main ()") || code.Contains("void main(")) - { - var codeLines = code.Split('\n'); - foreach (var raw in codeLines) - { - var trimmed = raw.Trim(); - if (trimmed.StartsWith("#include", StringComparison.Ordinal)) - includes.Add(trimmed); - else - bodyLines.Add(raw); - } - body = string.Join("\n", bodyLines); - return; - } - - var lines = code.Split('\n'); - var currentBlock = new List(); - bool isTopLevelBlock = false; - int braceDepth = 0; - - foreach (var raw in lines) - { - var line = raw.TrimEnd(); - var trimmed = line.Trim(); - - if (trimmed.StartsWith("#include", StringComparison.Ordinal) && braceDepth == 0) - { - includes.Add(trimmed); - continue; - } - - if (braceDepth == 0) - { - if (string.IsNullOrWhiteSpace(trimmed)) - { - continue; - } - - if (IsStatement(trimmed)) - { - isTopLevelBlock = false; - bodyLines.Add(line); - } - else - { - isTopLevelBlock = true; - currentBlock.Add(line); - } - } - else - { - if (isTopLevelBlock) - { - currentBlock.Add(line); - } - else - { - bodyLines.Add(line); - } - } - - foreach (var ch in trimmed) - { - if (ch == '{') braceDepth++; - else if (ch == '}') braceDepth = Math.Max(0, braceDepth - 1); - } - - if (braceDepth == 0 && isTopLevelBlock && currentBlock.Count > 0) - { - topLevelItems.Add(string.Join("\n", currentBlock)); - currentBlock.Clear(); - isTopLevelBlock = false; - } - } - - if (currentBlock.Count > 0) - { - if (isTopLevelBlock) - topLevelItems.Add(string.Join("\n", currentBlock)); - else - bodyLines.AddRange(currentBlock); - } - - body = string.Join("\n", bodyLines); - } - - private static bool IsStatement(string trimmed) - { - if (trimmed.StartsWith("std::cout", StringComparison.Ordinal) || - trimmed.StartsWith("cout", StringComparison.Ordinal) || - trimmed.StartsWith("std::cin", StringComparison.Ordinal) || - trimmed.StartsWith("cin", StringComparison.Ordinal) || - trimmed.StartsWith("std::cerr", StringComparison.Ordinal) || - trimmed.StartsWith("cerr", StringComparison.Ordinal) || - trimmed.StartsWith("printf", StringComparison.Ordinal) || - trimmed.StartsWith("puts", StringComparison.Ordinal) || - trimmed.StartsWith("fry::", StringComparison.Ordinal) || - trimmed.StartsWith("Display::", StringComparison.Ordinal) || - trimmed.StartsWith("display(", StringComparison.Ordinal) || - trimmed.StartsWith("for ", StringComparison.Ordinal) || - trimmed.StartsWith("for(", StringComparison.Ordinal) || - trimmed.StartsWith("while ", StringComparison.Ordinal) || - trimmed.StartsWith("while(", StringComparison.Ordinal) || - trimmed.StartsWith("if ", StringComparison.Ordinal) || - trimmed.StartsWith("if(", StringComparison.Ordinal) || - trimmed.StartsWith("else", StringComparison.Ordinal) || - trimmed.StartsWith("switch", StringComparison.Ordinal) || - trimmed.StartsWith("return", StringComparison.Ordinal) || - trimmed.StartsWith("break;", StringComparison.Ordinal) || - trimmed.StartsWith("continue;", StringComparison.Ordinal) || - trimmed.StartsWith("try", StringComparison.Ordinal) || - trimmed.StartsWith("catch", StringComparison.Ordinal) || - trimmed.StartsWith("assert", StringComparison.Ordinal)) - { - return true; - } - - var endOfWord = trimmed.IndexOfAny(new[] { ' ', '.', '-', '[', '+', '=', '(' }); - if (endOfWord > 0) - { - var firstWord = trimmed[..endOfWord]; - if (IsValidCppIdentifier(firstWord) && !IsTypeKeyword(firstWord)) - { - var remainder = trimmed[endOfWord..].TrimStart(); - if (remainder.StartsWith('.') || remainder.StartsWith("->") || remainder.StartsWith('[') || - remainder.StartsWith("++") || remainder.StartsWith("--") || remainder.StartsWith('=')) - { - return true; - } - } - } - - return false; - } - - private static bool IsTypeKeyword(string word) => word switch - { - "int" or "double" or "float" or "char" or "bool" or "long" or "short" or "unsigned" or "signed" or - "auto" or "const" or "static" or "constexpr" or "consteval" or "size_t" or "void" or - "string" or "vector" or "map" or "set" or "pair" or "tuple" or "array" or - "uint8_t" or "uint16_t" or "uint32_t" or "uint64_t" or - "int8_t" or "int16_t" or "int32_t" or "int64_t" => true, - _ => false - }; - - private static bool IsValidCppIdentifier(string token) - { - if (string.IsNullOrEmpty(token)) return false; - if (!char.IsLetter(token[0]) && token[0] != '_') return false; - return token.All(c => char.IsLetterOrDigit(c) || c == '_'); - } - - private static string ExtractDeclarationKey(string item) - { - var trimmed = item.Trim(); - if (trimmed.StartsWith("#define", StringComparison.Ordinal)) - { - var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); - return parts.Length > 1 ? parts[1] : item; - } - if (trimmed.StartsWith("using", StringComparison.Ordinal)) - { - var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length > 1 && parts[1] != "namespace") return parts[1]; - return item; - } - if (trimmed.StartsWith("struct ", StringComparison.Ordinal) || - trimmed.StartsWith("class ", StringComparison.Ordinal) || - trimmed.StartsWith("enum ", StringComparison.Ordinal)) - { - var parts = trimmed.Split(new[] { ' ', '{', ':', ';' }, StringSplitOptions.RemoveEmptyEntries); - return parts.Length > 1 ? parts[1] : item; - } - - var endIdx = trimmed.IndexOfAny(new[] { '=', ';', '(' }); - if (endIdx > 0) - { - var prefix = trimmed[..endIdx].Trim(); - var tokens = prefix.Split(new[] { ' ', '*', '&', '>', '\t' }, StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length > 0) - { - var lastToken = tokens[^1].Trim(); - if (IsValidCppIdentifier(lastToken)) - { - return lastToken; - } - } - } - return item; - } - - private string BuildCellProgram(string body, IEnumerable? currentCellTopLevel = null) - { - var sb = new StringBuilder(); - foreach (var inc in _cumulativeIncludes) - { - sb.AppendLine(inc); - } - sb.AppendLine(); - sb.AppendLine("using namespace fry;"); - sb.AppendLine("using namespace fry::display;"); - sb.AppendLine(); - - // Emit shared variable declarations - foreach (var (name, (type, json)) in _sharedVariables) - { - sb.AppendLine($"// Shared variable: {name}"); - sb.AppendLine(GenerateVariableDeclaration(name, type, json)); - } - - foreach (var top in _cumulativeTopLevel.Values) - { - sb.AppendLine(top); - } - - if (currentCellTopLevel != null) - { - foreach (var top in currentCellTopLevel) - { - var key = ExtractDeclarationKey(top); - if (!_cumulativeTopLevel.ContainsKey(key)) - { - sb.AppendLine(top); - } - } - } - - // Check if body already has main - if (body.Contains("int main(") || body.Contains("int main ()") || body.Contains("void main(")) - { - sb.AppendLine(body); - } - else - { - sb.AppendLine("int main() {"); - sb.AppendLine(body); - sb.AppendLine(" return 0;"); - sb.AppendLine("}"); - } - - return sb.ToString(); - } - - private static string GenerateVariableDeclaration(string name, string typeName, string json) - { - var trimmed = json.Trim(); - if (trimmed == "true" || trimmed == "false") return $"bool {name} = {trimmed};"; - if (long.TryParse(trimmed, out var l)) return $"long long {name} = {l}LL;"; - if (double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out var d)) return $"double {name} = {d};"; - if (trimmed.StartsWith('"') && trimmed.EndsWith('"')) return $"std::string {name} = {trimmed};"; - if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) - { - var items = trimmed[1..^1].Trim(); - if (items.Length == 0) return $"std::vector {name};"; - if (long.TryParse(items.Split(',')[0].Trim(), out _)) - { - return $"std::vector {name} = {{ {items} }};"; - } - if (double.TryParse(items.Split(',')[0].Trim(), System.Globalization.CultureInfo.InvariantCulture, out _)) - { - return $"std::vector {name} = {{ {items} }};"; - } - } - return $"// Complex shared object\nstd::string {name}_json = {JsonSerializer.Serialize(json)};"; - } -} diff --git a/Services/Languages/Cpp/CppNotebookKernel.cs b/Services/Languages/Cpp/CppNotebookKernel.cs deleted file mode 100644 index 8fc2edf5..00000000 --- a/Services/Languages/Cpp/CppNotebookKernel.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System.Diagnostics; -using System.Text; -using PdfEditorApp.Plugins.CSharpEditor.Models; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; -using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; - -/// -/// Polyglot interactive notebook kernel for C++. Compiles and executes C++ cell snippets, -/// accumulates headers and functions, supports interactive visual dumps, and enables cross-kernel variable sharing. -/// -public sealed partial class CppNotebookKernel : INotebookKernel -{ - private readonly CppToolchainProvider _toolchain; - private readonly IProcessLauncher _processes; - private readonly IHostEnvironment _host; - private readonly KernelCreationContext _context; - - private readonly HashSet _cumulativeIncludes = new(StringComparer.Ordinal) - { - "#include ", - "#include ", - "#include ", - "#include ", - "#include ", - "#include " - }; - - private readonly Dictionary _cumulativeTopLevel = new(StringComparer.Ordinal); - private readonly Dictionary _sharedVariables = new(StringComparer.Ordinal); - private int _executionCount; - private bool _isDisposed; - - public CppNotebookKernel( - CppToolchainProvider toolchain, - IProcessLauncher processes, - IHostEnvironment host, - KernelCreationContext context) - { - _toolchain = toolchain ?? throw new ArgumentNullException(nameof(toolchain)); - _processes = processes ?? throw new ArgumentNullException(nameof(processes)); - _host = host ?? throw new ArgumentNullException(nameof(host)); - _context = context ?? throw new ArgumentNullException(nameof(context)); - } - - public string LanguageId => LanguageIds.Cpp; - public string DisplayName => "C++20 (Clang / GCC / MSVC)"; - public bool IsSessionActive => _executionCount > 0 || _sharedVariables.Count > 0 || _cumulativeTopLevel.Count > 0; - public bool CanForceStop => true; - - public async Task ExecuteAsync(KernelExecutionRequest request, CancellationToken ct) - { - var clock = Stopwatch.StartNew(); - _executionCount++; - - var workingFolder = _context.WorkingDirectory(); - if (string.IsNullOrWhiteSpace(workingFolder) || !Directory.Exists(workingFolder)) - { - workingFolder = _host.HomeDirectory; - } - - var resolution = await _toolchain.ResolveAsync(new ToolchainQuery(workingFolder, _context.WorkspaceRoot?.Invoke()), ct).ConfigureAwait(false); - if (!resolution.IsFound || resolution.Toolchain == null) - { - var guidance = resolution.Missing ?? CppGuidance.NotInstalled(_host); - var err = guidance.ToText(); - request.OnConsole?.Invoke(err + "\n"); - return new KernelExecutionResult - { - Success = false, - ErrorMessage = guidance.Summary, - ConsoleOutput = err, - Elapsed = clock.Elapsed - }; - } - - var code = request.Code.Trim(); - ExtractIncludesAndTopLevel(code, out var includes, out var topLevelItems, out var body); - - foreach (var inc in includes) _cumulativeIncludes.Add(inc); - - var hash = Math.Abs((workingFolder + "_cpp_cell_" + _executionCount).GetHashCode(StringComparison.OrdinalIgnoreCase)).ToString("x8"); - var outDir = Path.Combine(Path.GetTempPath(), "FryStudio", "cpp_cells", hash); - Directory.CreateDirectory(outDir); - - var includeDir = await CppDisplayRuntime.EnsureIncludeDirectoryAsync(outDir, ct).ConfigureAwait(false); - - var fullProgram = BuildCellProgram(body, topLevelItems); - var sourcePath = Path.Combine(outDir, "cell.cpp"); - await File.WriteAllTextAsync(sourcePath, fullProgram, ct).ConfigureAwait(false); - - var compilerPath = resolution.Toolchain.ExecutablePath; - var fileName = Path.GetFileName(compilerPath.Replace('\\', '/')); - var isCl = fileName.Equals("cl.exe", StringComparison.OrdinalIgnoreCase) || fileName.Equals("cl", StringComparison.OrdinalIgnoreCase); - - var binName = (_host.IsWindows || isCl) ? "cell.exe" : "cell"; - var binPath = Path.Combine(outDir, binName); - - var compileArgs = new List(); - if (isCl) - { - compileArgs.Add("/std:c++20"); - compileArgs.Add("/EHsc"); - compileArgs.Add("/W4"); - compileArgs.Add($"/Fe:{binPath}"); - compileArgs.Add($"/Fo:{outDir}\\"); - compileArgs.Add($"/I{workingFolder}"); - compileArgs.Add($"/I{includeDir}"); - compileArgs.Add(sourcePath); - } - else - { - compileArgs.Add("-std=c++20"); - compileArgs.Add("-O2"); - compileArgs.Add("-Wall"); - compileArgs.Add($"-I{workingFolder}"); - compileArgs.Add($"-I{includeDir}"); - compileArgs.Add("-o"); - compileArgs.Add(binPath); - compileArgs.Add(sourcePath); - if (_host.IsWindows) - { - compileArgs.Add("-lws2_32"); - } - } - - var compileResult = await _host.RunAsync(compilerPath, compileArgs, TimeSpan.FromSeconds(30), ct).ConfigureAwait(false); - if (ct.IsCancellationRequested) return Cancelled(clock); - if (compileResult.ExitCode != 0) - { - var parser = new ClangGccDiagnosticParser(); - var parseResult = parser.Parse(compileResult.StandardOutput + "\n" + compileResult.StandardError, sourcePath); - var errMessage = !string.IsNullOrWhiteSpace(compileResult.StandardError) ? compileResult.StandardError : compileResult.StandardOutput; - request.OnConsole?.Invoke(errMessage); - return new KernelExecutionResult - { - Success = false, - ErrorMessage = "Compilation failed", - ConsoleOutput = errMessage, - Diagnostics = parseResult.Diagnostics, - Elapsed = clock.Elapsed - }; - } - - // On successful compilation, register top-level declarations into cumulative state - foreach (var top in topLevelItems) - { - var key = ExtractDeclarationKey(top); - _cumulativeTopLevel[key] = top; - } - - var env = await CppProcessEnvironment.ForAsync(_host, resolution.Toolchain, ct).ConfigureAwait(false); - var spec = new ProcessStartSpec - { - FileName = binPath, - Arguments = Array.Empty(), - WorkingDirectory = workingFolder, - Environment = env - }; - - // The cell's visuals, and the events on them its code listens to while it runs (over the event socket). - using var visuals = new ExternalVisualSession(); - var consoleBuilder = new StringBuilder(); - var processor = new ExternalOutputProcessor( - onConsoleText: text => - { - consoleBuilder.Append(text); - request.OnConsole?.Invoke(text); - }, - onRichOutput: bundle => - { - request.OnRichOutput?.Invoke(bundle); - }, - visuals: visuals.Visuals); - - using var managedProcess = _processes.Start( - visuals.Apply(spec), - onStandardOutput: text => processor.ProcessChunk(text), - onStandardError: err => processor.ProcessChunk(err)); - - // A cell can't be typed into, so a program that reads its input sees the end of it rather than waiting for good. - managedProcess.CloseInput(); - - try - { - // No limit of the kernel's own: how long a cell may take is the studio's ExecutionTimeoutSeconds setting, which - // arrives as the cancellation token. - var exitCode = await managedProcess.WaitForExitOrKillAsync(ct).ConfigureAwait(false); - processor.Flush(); - - return new KernelExecutionResult - { - Success = exitCode == 0, - ConsoleOutput = consoleBuilder.ToString(), - ErrorMessage = exitCode == 0 ? string.Empty : $"Program exited with code {exitCode}", - Elapsed = clock.Elapsed - }; - } - catch (OperationCanceledException) - { - return Cancelled(clock); - } - } - - private static KernelExecutionResult Cancelled(Stopwatch clock) => new() - { - WasCancelled = true, - ErrorMessage = "Cell execution was cancelled.", - Elapsed = clock.Elapsed - }; - - public Task> GetVariablesAsync(CancellationToken ct) - { - var list = new List(); - foreach (var (k, v) in _sharedVariables) - { - list.Add(new NotebookVariableInfo - { - Name = k, - TypeName = v.TypeName, - ValueDisplay = v.JsonValue, - Kind = "Variable", - Kernel = "C++" - }); - } - return Task.FromResult>(list); - } - - public Task GetValueJsonAsync(string name, CancellationToken ct) - { - if (_sharedVariables.TryGetValue(name, out var val)) - { - return Task.FromResult(val.JsonValue); - } - throw new KernelValueException($"C++ kernel has no variable named '{name}'."); - } - - public Task SetValueFromJsonAsync(string name, string json, CancellationToken ct) - { - var typeName = "auto"; - var trimmed = json.Trim(); - if (trimmed == "true" || trimmed == "false") typeName = "bool"; - else if (long.TryParse(trimmed, out _)) typeName = "long long"; - else if (double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out _)) typeName = "double"; - else if (trimmed.StartsWith('"')) typeName = "std::string"; - else if (trimmed.StartsWith('[')) typeName = "std::vector"; - - _sharedVariables[name] = (typeName, json); - return Task.CompletedTask; - } - - public void HardReset() - { - _sharedVariables.Clear(); - _cumulativeTopLevel.Clear(); - _cumulativeIncludes.Clear(); - _cumulativeIncludes.Add("#include "); - _cumulativeIncludes.Add("#include "); - _cumulativeIncludes.Add("#include "); - _cumulativeIncludes.Add("#include "); - _cumulativeIncludes.Add("#include "); - _cumulativeIncludes.Add("#include "); - _executionCount = 0; - } - - public void Dispose() - { - if (_isDisposed) return; - _isDisposed = true; - HardReset(); - } -} diff --git a/Services/Languages/FSharp/FSharpDisplayRuntime.cs b/Services/Languages/FSharp/FSharpDisplayRuntime.cs deleted file mode 100644 index ecd788a2..00000000 --- a/Services/Languages/FSharp/FSharpDisplayRuntime.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.IO; -using System.Reflection; -using System.Text; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; - -/// -/// Provides display and inspection runtime support for F# scripts and notebooks in FrySharp, -/// emitting rich display MIME bundles for interactive visuals, charts, HTML, images, and tables in Results (.DUMP). -/// -public static class FSharpDisplayRuntime -{ - public static string DisplayFsxContent => GetDisplayFsxContent(); - - private static string GetDisplayFsxContent() - { - var asm = typeof(FSharpDisplayRuntime).Assembly; - using var stream = asm.GetManifestResourceStream("FSharpRuntime.Display.fsx"); - if (stream != null) - { - using var reader = new StreamReader(stream, Encoding.UTF8); - return reader.ReadToEnd(); - } - - // Fallback to local source file - var localSource = Path.Combine(AppContext.BaseDirectory, "Services", "Languages", "FSharp", "Runtime", "Display.fsx"); - if (File.Exists(localSource)) return File.ReadAllText(localSource); - - var candidate = Path.Combine(Directory.GetCurrentDirectory(), "Services", "Languages", "FSharp", "Runtime", "Display.fsx"); - if (File.Exists(candidate)) return File.ReadAllText(candidate); - - return ""; - } - - public static async Task EnsureDisplayPackageAsync(string buildDir, CancellationToken ct = default) - { - var displayDir = Path.Combine(buildDir, "fry"); - try - { - Directory.CreateDirectory(displayDir); - var content = GetDisplayFsxContent(); - var file1 = Path.Combine(displayDir, "Display.fsx"); - var file2 = Path.Combine(buildDir, "Display.fsx"); - await File.WriteAllTextAsync(file1, content, ct).ConfigureAwait(false); - await File.WriteAllTextAsync(file2, content, ct).ConfigureAwait(false); - } - catch - { - // Gracefully ignore directory creation issues - } - - return displayDir; - } -} diff --git a/Services/Languages/FSharp/FSharpLanguage.cs b/Services/Languages/FSharp/FSharpLanguage.cs index 13c6bd3f..45a838c7 100644 --- a/Services/Languages/FSharp/FSharpLanguage.cs +++ b/Services/Languages/FSharp/FSharpLanguage.cs @@ -2,71 +2,21 @@ using AvaloniaEdit.Highlighting; using AvaloniaEdit.Indentation; using PdfEditorApp.Plugins.CSharpEditor.Services.Debugging; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; /// -/// F# language module: executes F# scripts (.fsx) via F# Interactive (dotnet fsi) with full -/// interactive standard input, structured compiler diagnostics in Problems, rich Results (.DUMP) output, -/// and polyglot interactive notebook cells. +/// F# in the studio: (F# Interactive, scripts and notebook kernels) with the editor's syntax +/// highlighting, completion, hover, folding and indentation. /// -public sealed class FSharpLanguage : LanguageDefinition +public sealed class FSharpLanguage : EngineLanguageDefinition { - private static readonly string[] FSharpAliases = ["fsharp", "fs", "fsx"]; - private static readonly string[] FSharpExtensions = [".fsx", ".fs"]; - public FSharpLanguage(StudioLanguageServices services) + : base(new FSharpEngine(services.Engines)) { - var toolchain = new FSharpToolchainProvider( - services.Host, - services.Processes, - services.ToolchainSettings); - FSharpToolchain = toolchain; - ScriptRunner = new FSharpScriptRunner(); - Packages = new FSharpPackageManager(); - NotebookKernels = new DelegateKernelFactory(context => - new FSharpNotebookKernel(toolchain, services.Processes, services.Host, context)); } - public FSharpToolchainProvider FSharpToolchain { get; } - - public override string Id => LanguageIds.FSharp; - public override string DisplayName => "F#"; - public override JupyterLanguageInfo Jupyter { get; } = new("fsharp", "F#", "fsharp", "fsharp", ".fsx", "text/x-fsharp"); - public override string ShortName => "FS"; - public override IReadOnlyList Aliases => FSharpAliases; - public override IReadOnlyList FileExtensions => FSharpExtensions; - public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; - public override bool IsCompiled => false; - - public override LanguageCapabilities Capabilities => - LanguageCapabilities.StandardInput | - LanguageCapabilities.QuickInfo | - LanguageCapabilities.Completion | - LanguageCapabilities.NotebookCells | - LanguageCapabilities.ValueSharing | - LanguageCapabilities.Packages | - LanguageCapabilities.Formatting; - - public override string IconKind => "FunctionVariant"; - public override string AccentHex => "#30B9DB"; - public override string LineCommentPrefix => "//"; - public override string RuntimeDescription => "F# (.NET 10 fsi)"; - - public override string NewFileTemplate => - "// Runs with F# Interactive (.NET SDK dotnet fsi) on this computer (F5).\n" + - "open System\n\n" + - "printfn \"🚀 Hello from F# in FrySharp!\"\n\n" + - "let numbers = [ 1 .. 10 ]\n" + - "let sumOfSquares =\n" + - " numbers\n" + - " |> List.map (fun x -> x * x)\n" + - " |> List.sum\n\n" + - "printfn \"Sum of squares: %d\" sumOfSquares\n"; + public FSharpToolchainProvider FSharpToolchain => Engine.FSharpToolchain; public override IEditorAssistantFactory EditorAssistants { get; } = FSharpEditorAssistantFactory.Instance; @@ -76,10 +26,5 @@ public FSharpLanguage(StudioLanguageServices services) public override ILanguageFolding Folding { get; } = new FSharpFoldingStrategy(); - public override IToolchainProvider Toolchain => FSharpToolchain; - public override IScriptRunner ScriptRunner { get; } public override IDebuggerProvider? Debugger => null; - public override IDiagnosticParser RunDiagnostics { get; } = new FSharpCompilerDiagnosticParser(); - public override IPackageManager Packages { get; } - public override INotebookKernelFactory NotebookKernels { get; } } diff --git a/Services/Languages/FSharp/FSharpNotebookKernel.CodePreparation.cs b/Services/Languages/FSharp/FSharpNotebookKernel.CodePreparation.cs deleted file mode 100644 index fa4d76fe..00000000 --- a/Services/Languages/FSharp/FSharpNotebookKernel.CodePreparation.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Text; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; - -public sealed partial class FSharpNotebookKernel -{ - private void ExtractDirectivesAndDeclarations( - string code, - out List directives, - out List opens, - out List topLevelItems, - out string body) - { - directives = new List(); - opens = new List(); - topLevelItems = new List(); - var bodyLines = new List(); - - var lines = code.Split('\n'); - var currentBlock = new List(); - bool isTopLevelBlock = false; - - for (int i = 0; i < lines.Length; i++) - { - var raw = lines[i]; - var line = raw.TrimEnd(); - var trimmed = line.Trim(); - - if (string.IsNullOrWhiteSpace(trimmed)) continue; - - // Directives: #r, #load, #I, #time - if (trimmed.StartsWith("#r ", StringComparison.Ordinal) || - trimmed.StartsWith("#load ", StringComparison.Ordinal) || - trimmed.StartsWith("#I ", StringComparison.Ordinal)) - { - directives.Add(trimmed); - continue; - } - - // Open statements - if (trimmed.StartsWith("open ", StringComparison.Ordinal)) - { - opens.Add(trimmed); - continue; - } - - int indent = line.Length - line.TrimStart().Length; - - if (indent == 0) - { - // Flush previous block - if (currentBlock.Count > 0) - { - if (isTopLevelBlock) - topLevelItems.Add(string.Join("\n", currentBlock)); - else - bodyLines.AddRange(currentBlock); - currentBlock.Clear(); - } - - if (IsTopLevelDeclaration(trimmed)) - { - isTopLevelBlock = true; - currentBlock.Add(line); - } - else - { - isTopLevelBlock = false; - currentBlock.Add(line); - } - } - else - { - currentBlock.Add(line); - } - } - - if (currentBlock.Count > 0) - { - if (isTopLevelBlock) - topLevelItems.Add(string.Join("\n", currentBlock)); - else - bodyLines.AddRange(currentBlock); - } - - body = string.Join("\n", bodyLines); - } - - private static bool IsTopLevelDeclaration(string trimmed) - { - return trimmed.StartsWith("type ", StringComparison.Ordinal) || - trimmed.StartsWith("module ", StringComparison.Ordinal) || - trimmed.StartsWith("namespace ", StringComparison.Ordinal) || - trimmed.StartsWith("exception ", StringComparison.Ordinal); - } - - private string BuildCellProgram(string outDir, string body, List topLevelItems) - { - var sb = new StringBuilder(); - - // 1. Directives - var displayFsx = Path.Combine(outDir, "fry", "Display.fsx").Replace('\\', '/'); - sb.AppendLine($"#load \"{displayFsx}\""); - - foreach (var directive in _cumulativeDirectives) - { - sb.AppendLine(directive); - } - sb.AppendLine(); - - // 2. Open statements - foreach (var op in _cumulativeOpens) - { - sb.AppendLine(op); - } - sb.AppendLine("open Fry"); - sb.AppendLine(); - - // 3. Cumulative top-level items - foreach (var item in _cumulativeTopLevel.Values) - { - sb.AppendLine(item); - sb.AppendLine(); - } - - // 4. This cell's top-level items - foreach (var item in topLevelItems) - { - sb.AppendLine(item); - sb.AppendLine(); - } - - // 5. Injected shared variables - foreach (var (name, info) in _sharedVariables) - { - sb.AppendLine(BuildInjectedVariable(name, info.TypeName, info.JsonValue)); - } - - // 6. Cell body - if (!string.IsNullOrWhiteSpace(body)) - { - sb.AppendLine(body); - } - - return sb.ToString(); - } - - private static string BuildInjectedVariable(string name, string typeName, string json) - { - var trimmed = json.Trim(); - if (trimmed == "true" || trimmed == "false") - return $"let {name} : bool = {trimmed}"; - if (long.TryParse(trimmed, out var l)) - return $"let {name} : int64 = {l}L"; - if (double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out var d)) - return $"let {name} : float = {d.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; - if (trimmed.StartsWith('"')) - return $"let {name} : string = {trimmed}"; - - // Complex types via JSON deserialization - var escaped = json.Replace("\"", "\\\""); - return $"let {name} = System.Text.Json.JsonDocument.Parse(\"{escaped}\").RootElement"; - } -} diff --git a/Services/Languages/FSharp/FSharpNotebookKernel.cs b/Services/Languages/FSharp/FSharpNotebookKernel.cs deleted file mode 100644 index b3dfa853..00000000 --- a/Services/Languages/FSharp/FSharpNotebookKernel.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.Diagnostics; -using System.Text; -using PdfEditorApp.Plugins.CSharpEditor.Models; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; -using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; - -/// -/// Polyglot interactive notebook kernel for F#. Executes F# cell snippets via dotnet fsi, -/// accumulates open statements and top-level definitions across cells, supports interactive visual dumps, -/// and enables cross-kernel variable sharing. -/// -public sealed partial class FSharpNotebookKernel : INotebookKernel -{ - private readonly FSharpToolchainProvider _toolchain; - private readonly IProcessLauncher _processes; - private readonly IHostEnvironment _host; - private readonly KernelCreationContext _context; - - private readonly HashSet _cumulativeOpens = new(StringComparer.Ordinal) - { - "open System", - "open System.IO", - "open System.Collections.Generic" - }; - - private readonly HashSet _cumulativeDirectives = new(StringComparer.Ordinal); - private readonly Dictionary _cumulativeTopLevel = new(StringComparer.Ordinal); - private readonly Dictionary _sharedVariables = new(StringComparer.Ordinal); - private int _executionCount; - private bool _isDisposed; - - public FSharpNotebookKernel( - FSharpToolchainProvider toolchain, - IProcessLauncher processes, - IHostEnvironment host, - KernelCreationContext context) - { - _toolchain = toolchain ?? throw new ArgumentNullException(nameof(toolchain)); - _processes = processes ?? throw new ArgumentNullException(nameof(processes)); - _host = host ?? throw new ArgumentNullException(nameof(host)); - _context = context ?? throw new ArgumentNullException(nameof(context)); - } - - public string LanguageId => LanguageIds.FSharp; - public string DisplayName => "F# (dotnet fsi)"; - public bool IsSessionActive => _executionCount > 0 || _sharedVariables.Count > 0 || _cumulativeTopLevel.Count > 0; - public bool CanForceStop => true; - - public async Task ExecuteAsync(KernelExecutionRequest request, CancellationToken ct) - { - var clock = Stopwatch.StartNew(); - _executionCount++; - - var workingFolder = _context.WorkingDirectory(); - if (string.IsNullOrWhiteSpace(workingFolder) || !Directory.Exists(workingFolder)) - { - workingFolder = _host.HomeDirectory; - } - - var resolution = await _toolchain.ResolveAsync(new ToolchainQuery(workingFolder, _context.WorkspaceRoot?.Invoke()), ct).ConfigureAwait(false); - if (!resolution.IsFound || resolution.Toolchain == null) - { - var guidance = resolution.Missing ?? FSharpGuidance.NotInstalled(_host); - var err = guidance.ToText(); - request.OnConsole?.Invoke(err + "\n"); - return new KernelExecutionResult - { - Success = false, - ErrorMessage = guidance.Summary, - ConsoleOutput = err, - Elapsed = clock.Elapsed - }; - } - - var code = request.Code.Trim(); - ExtractDirectivesAndDeclarations(code, out var directives, out var opens, out var topLevelItems, out var body); - - foreach (var d in directives) _cumulativeDirectives.Add(d); - foreach (var o in opens) _cumulativeOpens.Add(o); - - var hash = Math.Abs((workingFolder + "_fs_cell_" + _executionCount).GetHashCode(StringComparison.OrdinalIgnoreCase)).ToString("x8"); - var outDir = Path.Combine(Path.GetTempPath(), "FryStudio", "fsharp_cells", hash); - Directory.CreateDirectory(outDir); - - await FSharpDisplayRuntime.EnsureDisplayPackageAsync(outDir, ct).ConfigureAwait(false); - - var fullProgram = BuildCellProgram(outDir, body, topLevelItems); - var sourcePath = Path.Combine(outDir, "cell.fsx"); - await File.WriteAllTextAsync(sourcePath, fullProgram, ct).ConfigureAwait(false); - - var executable = resolution.Toolchain.ExecutablePath; - var isDirectFsi = Path.GetFileNameWithoutExtension(executable).Equals("fsi", StringComparison.OrdinalIgnoreCase); - - var runArgs = isDirectFsi - ? new List { "--nologo", "--exec", sourcePath } - : new List { "fsi", "--nologo", "--exec", sourcePath }; - - // The cell's visuals, and the events on them its code listens to while it runs (over the event socket). - using var visuals = new ExternalVisualSession(); - var consoleBuilder = new StringBuilder(); - var processor = new ExternalOutputProcessor( - onConsoleText: text => - { - consoleBuilder.Append(text); - request.OnConsole?.Invoke(text); - }, - onRichOutput: bundle => - { - request.OnRichOutput?.Invoke(bundle); - }, - visuals: visuals.Visuals); - - var spec = visuals.Apply(new ProcessStartSpec - { - FileName = executable, - Arguments = runArgs, - WorkingDirectory = outDir - }); - - using var managedProcess = _processes.Start( - spec, - onStandardOutput: text => processor.ProcessChunk(text), - onStandardError: err => processor.ProcessChunk(err)); - - try - { - // No limit of the kernel's own: how long a cell may take is the studio's ExecutionTimeoutSeconds setting, which - // arrives as the cancellation token. - var exitCode = await managedProcess.WaitForExitOrKillAsync(ct).ConfigureAwait(false); - processor.Flush(); - - if (exitCode == 0) - { - for (var i = 0; i < topLevelItems.Count; i++) - { - _cumulativeTopLevel[$"decl_{_executionCount}_{i}"] = topLevelItems[i]; - } - } - - var fullConsole = consoleBuilder.ToString(); - var parser = new FSharpCompilerDiagnosticParser(); - var parseResult = parser.Parse(fullConsole, sourcePath); - var hasErrors = parseResult.Diagnostics.Any(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error); - - return new KernelExecutionResult - { - Success = exitCode == 0 && !hasErrors, - ErrorMessage = exitCode != 0 ? (parseResult.Diagnostics.FirstOrDefault(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)?.Message ?? $"F# execution failed with exit code {exitCode}.") : string.Empty, - ConsoleOutput = fullConsole, - Diagnostics = parseResult.Diagnostics, - Elapsed = clock.Elapsed - }; - } - catch (OperationCanceledException) - { - return new KernelExecutionResult - { - WasCancelled = true, - ErrorMessage = "Cell execution was cancelled.", - Elapsed = clock.Elapsed - }; - } - } - - public Task> GetVariablesAsync(CancellationToken ct) - { - var list = new List(); - foreach (var (k, v) in _sharedVariables) - { - list.Add(new NotebookVariableInfo - { - Name = k, - TypeName = v.TypeName, - ValueDisplay = v.JsonValue, - Kind = "Variable", - Kernel = "F#" - }); - } - return Task.FromResult>(list); - } - - public Task GetValueJsonAsync(string name, CancellationToken ct) - { - if (_sharedVariables.TryGetValue(name, out var val)) - { - return Task.FromResult(val.JsonValue); - } - throw new KernelValueException($"F# kernel has no variable named '{name}'."); - } - - public Task SetValueFromJsonAsync(string name, string json, CancellationToken ct) - { - var typeName = "obj"; - var trimmed = json.Trim(); - if (trimmed == "true" || trimmed == "false") typeName = "bool"; - else if (long.TryParse(trimmed, out _)) typeName = "int64"; - else if (double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out _)) typeName = "float"; - else if (trimmed.StartsWith('"')) typeName = "string"; - - _sharedVariables[name] = (typeName, json); - return Task.CompletedTask; - } - - public void HardReset() - { - _sharedVariables.Clear(); - _cumulativeTopLevel.Clear(); - _cumulativeDirectives.Clear(); - _cumulativeOpens.Clear(); - _cumulativeOpens.Add("open System"); - _cumulativeOpens.Add("open System.IO"); - _cumulativeOpens.Add("open System.Collections.Generic"); - _executionCount = 0; - } - - public void Dispose() - { - if (_isDisposed) return; - _isDisposed = true; - HardReset(); - } -} diff --git a/Services/Languages/FSharp/FSharpPackageManager.cs b/Services/Languages/FSharp/FSharpPackageManager.cs deleted file mode 100644 index 0c0f0a9e..00000000 --- a/Services/Languages/FSharp/FSharpPackageManager.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.RegularExpressions; -using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.CSharp; -using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; - -/// -/// Provides NuGet package resolution, directive parsing, and installation for F# scripts and notebook cells. -/// Supports inline #r "nuget: ..." directives and package quick-fixes. -/// -public sealed partial class FSharpPackageManager : IPackageManager -{ - private readonly NuGetPackageManager _inner; - - [GeneratedRegex(@"^\s*#r\s+""nuget:\s*(?[a-zA-Z0-9_\-\.]+)(?:,\s*(?[^""]+))?""\s*;?$", RegexOptions.IgnoreCase)] - private static partial Regex FSharpNuGetDirectiveRegex(); - - [GeneratedRegex(@"^[%#!]nuget\s+(?:install|add)\s+(?[a-zA-Z0-9_\-\.]+)(?:\s+(?[^\s]+))?$", RegexOptions.IgnoreCase)] - private static partial Regex NuGetMagicRegex(); - - public FSharpPackageManager(NuGetPackageManager? inner = null) - { - _inner = inner ?? new NuGetPackageManager(); - } - - public string ToolName => "NuGet"; - - public bool TryParseDirective(string line, out PackageCommand command) - { - var trimmed = line.Trim(); - var matchDirective = FSharpNuGetDirectiveRegex().Match(trimmed); - if (matchDirective.Success) - { - var id = matchDirective.Groups["id"].Value; - var ver = matchDirective.Groups["ver"].Success ? matchDirective.Groups["ver"].Value.Trim() : null; - var args = ver != null ? new[] { "install", id, ver } : new[] { "install", id }; - command = new PackageCommand(trimmed, args); - return true; - } - - var matchMagic = NuGetMagicRegex().Match(trimmed); - if (matchMagic.Success) - { - var id = matchMagic.Groups["id"].Value; - var ver = matchMagic.Groups["ver"].Success ? matchMagic.Groups["ver"].Value.Trim() : null; - var args = ver != null ? new[] { "install", id, ver } : new[] { "install", id }; - command = new PackageCommand(trimmed, args); - return true; - } - - return _inner.TryParseDirective(line, out command); - } - - public PackageCommand InstallCommand(string package) - { - var trimmed = package.Trim(); - return new PackageCommand($"#r \"nuget: {trimmed}\"", ["install", trimmed]); - } - - public string PackageForMissingDependency(string missingName) => - _inner.PackageForMissingDependency(missingName); - - public Task RunAsync( - PackageCommand command, - ToolchainInfo toolchain, - Action output, - CancellationToken ct = default) => - _inner.RunAsync(command, toolchain, output, ct); -} diff --git a/Services/Languages/Go/GoDisplayRuntime.cs b/Services/Languages/Go/GoDisplayRuntime.cs deleted file mode 100644 index aaeaa26b..00000000 --- a/Services/Languages/Go/GoDisplayRuntime.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Reflection; -using System.Text; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; - -/// -/// Provides display and inspection runtime support for Go scripts and notebooks in C# Code Studio, -/// emitting rich display MIME bundles for interactive visuals, charts, HTML, images, and tables. -/// -public static class GoDisplayRuntime -{ - private static string GetFryGoContent() - { - var asm = typeof(GoDisplayRuntime).Assembly; - using var stream = asm.GetManifestResourceStream("GoRuntime.fry.go"); - if (stream != null) - { - using var reader = new StreamReader(stream, Encoding.UTF8); - return reader.ReadToEnd(); - } - - // Fallback to local source file if not yet compiled into assembly - var localSource = Path.Combine(AppContext.BaseDirectory, "Services", "Languages", "Go", "Runtime", "fry.go"); - if (File.Exists(localSource)) return File.ReadAllText(localSource); - return ""; - } - - public static async Task EnsureDisplayPackageAsync(string buildDir, CancellationToken ct = default) - { - var content = GetFryGoContent(); - - // 1. Module package: buildDir/fry/fry.go + go.mod - var fryDir = Path.Combine(buildDir, "fry"); - Directory.CreateDirectory(fryDir); - await File.WriteAllTextAsync(Path.Combine(fryDir, "fry.go"), content, ct).ConfigureAwait(false); - var fryMod = Path.Combine(fryDir, "go.mod"); - if (!File.Exists(fryMod)) - { - await File.WriteAllTextAsync(fryMod, "module fry\n\ngo 1.20\n", ct).ConfigureAwait(false); - } - - // 2. GOPATH package: buildDir/src/fry/fry.go - var gopathDir = Path.Combine(buildDir, "src", "fry"); - Directory.CreateDirectory(gopathDir); - await File.WriteAllTextAsync(Path.Combine(gopathDir, "fry.go"), content, ct).ConfigureAwait(false); - - // 3. Local root go.mod: if cell.go or main.go runs directly in buildDir - var rootMod = Path.Combine(buildDir, "go.mod"); - if (!File.Exists(rootMod)) - { - await File.WriteAllTextAsync(rootMod, "module cell\n\ngo 1.20\n\nrequire fry v0.0.0\nreplace fry => ./fry\n", ct).ConfigureAwait(false); - } - - // 4. Backwards compatibility: buildDir/fry/display/display.go - var displayDir = Path.Combine(buildDir, "fry", "display"); - Directory.CreateDirectory(displayDir); - await File.WriteAllTextAsync(Path.Combine(displayDir, "display.go"), """ - package display - - import "fry" - - func Html(c string) { fry.Html(c) } - func Image(p any) { fry.Image(p) } - func Json(j string) { fry.Json(j) } - func Dump(v any) { fry.Dump(v) } - """, ct).ConfigureAwait(false); - - // 5. Package fry_display in GOPATH - var fryDisplayGopath = Path.Combine(buildDir, "src", "fry_display"); - Directory.CreateDirectory(fryDisplayGopath); - await File.WriteAllTextAsync(Path.Combine(fryDisplayGopath, "display.go"), """ - package fry_display - - import "fry" - - type ChartOptions struct { - Type string - Title string - X []float64 - Y []float64 - } - - func Chart(opts ChartOptions) *fry.DisplayHandle { - if len(opts.Y) > 0 { - return fry.Chart(opts.Y, fry.Title(opts.Title)) - } - return fry.Chart(opts.X, fry.Title(opts.Title)) - } - """, ct).ConfigureAwait(false); - - return fryDir; - } -} diff --git a/Services/Languages/Go/GoLanguage.cs b/Services/Languages/Go/GoLanguage.cs index dda6d342..2124db05 100644 --- a/Services/Languages/Go/GoLanguage.cs +++ b/Services/Languages/Go/GoLanguage.cs @@ -2,71 +2,22 @@ using AvaloniaEdit.Highlighting; using AvaloniaEdit.Indentation; using PdfEditorApp.Plugins.CSharpEditor.Services.Debugging; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; /// -/// Go, compiled with the official Go toolchain (go build / go run) and executed natively for source files, -/// supporting two-phase compilation, structured compiler diagnostics in Problems, interactive standard input, -/// and interactive polyglot notebook cells. +/// Go in the studio: (builds, scripts, the notebook kernel and packages) with the editor's syntax highlighting, +/// static completion and hover, folding and indentation, and the Delve debugger. /// -public sealed class GoLanguage : LanguageDefinition +public sealed class GoLanguage : EngineLanguageDefinition { - private static readonly string[] GoAliases = ["go", "golang"]; - private static readonly string[] GoExtensions = [".go"]; - public GoLanguage(StudioLanguageServices services) + : base(new GoEngine(services.Engines)) { - var toolchain = new GoToolchainProvider( - services.Host, - services.Processes, - services.ToolchainSettings, - Path.Combine(services.BaseDirectory, "go")); - GoToolchain = toolchain; - ScriptRunner = new GoBuildAndRunScriptRunner(services.Host); - Packages = new GoPackageManager(toolchain, services.Processes, services.Host); - Debugger = new GoDebuggerProvider(toolchain, services.Processes, services.Host, services.AdapterManager); - NotebookKernels = new DelegateKernelFactory(context => - new GoNotebookKernel(toolchain, services.Processes, services.Host, context)); + Debugger = new GoDebuggerProvider(Engine.GoToolchain, services.Processes, services.Host, services.AdapterManager); } - public GoToolchainProvider GoToolchain { get; } - - public override string Id => LanguageIds.Go; - public override string DisplayName => "Go"; - public override JupyterLanguageInfo Jupyter { get; } = new("go", "Go", "go", "go", ".go", "text/x-gosrc"); - public override string ShortName => "GO"; - public override IReadOnlyList Aliases => GoAliases; - public override IReadOnlyList FileExtensions => GoExtensions; - public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; - public override bool IsCompiled => true; - - public override LanguageCapabilities Capabilities => - LanguageCapabilities.StandardInput | - LanguageCapabilities.QuickInfo | - LanguageCapabilities.Completion | - LanguageCapabilities.Debugging | - LanguageCapabilities.Breakpoints | - LanguageCapabilities.NotebookCells | - LanguageCapabilities.ValueSharing | - LanguageCapabilities.Packages; - - public override string IconKind => "LanguageGo"; - public override string AccentHex => "#00ADD8"; - public override string LineCommentPrefix => "//"; - public override string RuntimeDescription => "Go (gc / go toolchain)"; - - public override string NewFileTemplate => - "// Runs with the Go toolchain installed on this computer (F5).\n" + - "package main\n\n" + - "import \"fmt\"\n\n" + - "func main() {\n" + - " fmt.Println(\"Hello from Go!\")\n" + - "}\n"; + public GoToolchainProvider GoToolchain => Engine.GoToolchain; public override IEditorAssistantFactory EditorAssistants { get; } = GoEditorAssistantFactory.Instance; @@ -76,10 +27,5 @@ public GoLanguage(StudioLanguageServices services) public override ILanguageFolding Folding { get; } = new GoFoldingStrategy(); - public override IToolchainProvider Toolchain => GoToolchain; - public override IScriptRunner ScriptRunner { get; } - public override IDebuggerProvider? Debugger { get; } - public override IDiagnosticParser RunDiagnostics { get; } = new GoCompilerDiagnosticParser(); - public override IPackageManager Packages { get; } - public override INotebookKernelFactory NotebookKernels { get; } + public override IDebuggerProvider Debugger { get; } } diff --git a/Services/Languages/Go/GoNotebookKernel.CodePreparation.cs b/Services/Languages/Go/GoNotebookKernel.CodePreparation.cs deleted file mode 100644 index 36d2ac1d..00000000 --- a/Services/Languages/Go/GoNotebookKernel.CodePreparation.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; - -public sealed partial class GoNotebookKernel -{ - private void ExtractImportsAndTopLevel(string code, out List imports, out List topLevelItems, out string body) - { - imports = new List(); - topLevelItems = new List(); - var bodyLines = new List(); - - // If the code already contains func main(), treat as a full program - if (code.Contains("func main(") || code.Contains("func main ()")) - { - var codeLines = code.Split('\n'); - foreach (var raw in codeLines) - { - var trimmed = raw.Trim(); - if (trimmed.StartsWith("package ", StringComparison.Ordinal)) continue; - bodyLines.Add(raw); - } - body = string.Join("\n", bodyLines); - return; - } - - var lines = code.Split('\n'); - var currentBlock = new List(); - bool isTopLevelBlock = false; - int braceDepth = 0; - int parenDepth = 0; - - for (int i = 0; i < lines.Length; i++) - { - var raw = lines[i]; - var line = raw.TrimEnd(); - var trimmed = line.Trim(); - - if (trimmed.StartsWith("package ", StringComparison.Ordinal)) continue; - - // Handle import lines at top level - if (trimmed.StartsWith("import ", StringComparison.Ordinal) || trimmed == "import (") - { - imports.Add(trimmed); - if (trimmed.EndsWith('(')) - { - // Multi-line import block - i++; - while (i < lines.Length) - { - var impLine = lines[i].Trim(); - imports.Add(impLine); - if (impLine.StartsWith(')')) break; - i++; - } - } - continue; - } - - if (braceDepth == 0 && parenDepth == 0) - { - if (string.IsNullOrWhiteSpace(trimmed)) continue; - - if (IsTopLevelDeclaration(trimmed)) - { - isTopLevelBlock = true; - currentBlock.Add(line); - } - else - { - isTopLevelBlock = false; - bodyLines.Add(line); - } - } - else - { - if (isTopLevelBlock) - currentBlock.Add(line); - else - bodyLines.Add(line); - } - - foreach (var ch in trimmed) - { - if (ch == '{') braceDepth++; - else if (ch == '}') braceDepth = Math.Max(0, braceDepth - 1); - else if (ch == '(') parenDepth++; - else if (ch == ')') parenDepth = Math.Max(0, parenDepth - 1); - } - - if (braceDepth == 0 && parenDepth == 0 && isTopLevelBlock && currentBlock.Count > 0) - { - topLevelItems.Add(string.Join("\n", currentBlock)); - currentBlock.Clear(); - isTopLevelBlock = false; - } - } - - if (currentBlock.Count > 0) - { - if (isTopLevelBlock) - topLevelItems.Add(string.Join("\n", currentBlock)); - else - bodyLines.AddRange(currentBlock); - } - - body = string.Join("\n", bodyLines); - } - - private static bool IsTopLevelDeclaration(string trimmed) - { - return trimmed.StartsWith("func ", StringComparison.Ordinal) || - trimmed.StartsWith("type ", StringComparison.Ordinal) || - trimmed.StartsWith("const ", StringComparison.Ordinal) || - trimmed.StartsWith("var ", StringComparison.Ordinal); - } - - private string BuildCellProgram(string body, List topLevelItems) - { - // If body already has func main(), build directly - if (body.Contains("func main(") || body.Contains("func main ()")) - { - var sbFull = new StringBuilder(); - sbFull.AppendLine("package main"); - sbFull.AppendLine(); - sbFull.AppendLine(body); - return sbFull.ToString(); - } - - var sb = new StringBuilder(); - sb.AppendLine("package main"); - sb.AppendLine(); - - // Collect all code in this compilation unit to inspect package usage - var codeInspect = new StringBuilder(); - foreach (var item in _cumulativeTopLevel.Values) codeInspect.AppendLine(item); - foreach (var item in topLevelItems) codeInspect.AppendLine(item); - foreach (var (name, info) in _sharedVariables) codeInspect.AppendLine(BuildInjectedVariable(name, info.TypeName, info.JsonValue)); - codeInspect.AppendLine(body); - var fullCode = codeInspect.ToString(); - - // Filter cumulative imports so only referenced packages (or blank/dot imports) are emitted, - // preventing Go compiler errors ("imported and not used"). - var neededImports = new List(); - foreach (var imp in _cumulativeImports) - { - var clean = imp.Trim(); - if (clean.StartsWith("import ", StringComparison.Ordinal)) - { - clean = clean["import ".Length..].Trim(); - } - if (clean == "(" || clean == ")") continue; - - if (IsImportUsed(clean, fullCode)) - { - neededImports.Add(clean); - } - } - - if (neededImports.Count > 0) - { - sb.AppendLine("import ("); - foreach (var imp in neededImports) - { - sb.AppendLine($"\t{imp}"); - } - sb.AppendLine(")"); - sb.AppendLine(); - } - - // Top level items - foreach (var item in _cumulativeTopLevel.Values) - { - sb.AppendLine(item); - sb.AppendLine(); - } - foreach (var item in topLevelItems) - { - sb.AppendLine(item); - sb.AppendLine(); - } - - // func main() - sb.AppendLine("func main() {"); - - // Injected variables - foreach (var (name, info) in _sharedVariables) - { - sb.AppendLine(BuildInjectedVariable(name, info.TypeName, info.JsonValue)); - } - - if (!string.IsNullOrWhiteSpace(body)) - { - sb.AppendLine(body); - } - - sb.AppendLine("}"); - return sb.ToString(); - } - - private static string BuildInjectedVariable(string name, string typeName, string json) - { - var trimmed = json.Trim(); - if (trimmed == "true" || trimmed == "false") - return $"\tvar {name} bool = {trimmed}"; - if (long.TryParse(trimmed, out var l)) - return $"\tvar {name} int64 = {l}"; - if (double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out var d)) - return $"\tvar {name} float64 = {d.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; - if (trimmed.StartsWith('"')) - return $"\tvar {name} string = {trimmed}"; - - // Complex types via json unmarshal - var escaped = json.Replace("`", "` + \"`\" + `"); - return $"\tvar {name} interface{{}}\n\t_ = json.Unmarshal([]byte(`{escaped}`), &{name})"; - } - - private static bool IsImportUsed(string cleanImport, string fullCode) - { - if (string.IsNullOrWhiteSpace(cleanImport)) return false; - - // Strip trailing inline comments - var commentIdx = cleanImport.IndexOf("//", StringComparison.Ordinal); - if (commentIdx >= 0) - { - cleanImport = cleanImport[..commentIdx].Trim(); - } - - // Side-effect import: _ "pkg" - if (cleanImport.StartsWith("_ ", StringComparison.Ordinal) || cleanImport.StartsWith("_\"", StringComparison.Ordinal)) - { - return true; - } - - // Dot import: . "pkg" - if (cleanImport.StartsWith(". ", StringComparison.Ordinal) || cleanImport.StartsWith(".\"", StringComparison.Ordinal)) - { - return true; - } - - // Aliased import: alias "pkg/path" - var parts = cleanImport.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length == 2 && parts[1].StartsWith('"')) - { - var alias = parts[0]; - return Regex.IsMatch(fullCode, $@"\b{Regex.Escape(alias)}\."); - } - - // Standard import: "pkg" or "path/to/pkg" - var path = cleanImport.Trim('"', ' ', '\t', ';'); - var lastSlash = path.LastIndexOf('/'); - var pkg = lastSlash >= 0 ? path[(lastSlash + 1)..] : path; - - return Regex.IsMatch(fullCode, $@"\b{Regex.Escape(pkg)}\."); - } -} diff --git a/Services/Languages/Go/GoNotebookKernel.cs b/Services/Languages/Go/GoNotebookKernel.cs deleted file mode 100644 index f2b00ca9..00000000 --- a/Services/Languages/Go/GoNotebookKernel.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System.Diagnostics; -using System.Text; -using PdfEditorApp.Plugins.CSharpEditor.Models; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; -using PdfEditorApp.Plugins.CSharpEditor.Visuals.Interaction; - -namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; - -/// -/// Polyglot interactive notebook kernel for Go. Compiles and executes Go cell snippets, -/// accumulates imports and top-level functions/structs across cells, supports interactive visual dumps, -/// and enables cross-kernel variable sharing. -/// -public sealed partial class GoNotebookKernel : INotebookKernel -{ - private readonly GoToolchainProvider _toolchain; - private readonly IProcessLauncher _processes; - private readonly IHostEnvironment _host; - private readonly KernelCreationContext _context; - - private readonly HashSet _cumulativeImports = new(StringComparer.Ordinal) - { - "\"fmt\"", - "\"strings\"", - "\"time\"", - "\"os\"", - "\"encoding/json\"", - "\"fry\"" - }; - - private readonly Dictionary _cumulativeTopLevel = new(StringComparer.Ordinal); - private readonly Dictionary _sharedVariables = new(StringComparer.Ordinal); - private int _executionCount; - private bool _isDisposed; - - public GoNotebookKernel( - GoToolchainProvider toolchain, - IProcessLauncher processes, - IHostEnvironment host, - KernelCreationContext context) - { - _toolchain = toolchain ?? throw new ArgumentNullException(nameof(toolchain)); - _processes = processes ?? throw new ArgumentNullException(nameof(processes)); - _host = host ?? throw new ArgumentNullException(nameof(host)); - _context = context ?? throw new ArgumentNullException(nameof(context)); - } - - public string LanguageId => LanguageIds.Go; - public string DisplayName => "Go (gc toolchain)"; - public bool IsSessionActive => _executionCount > 0 || _sharedVariables.Count > 0 || _cumulativeTopLevel.Count > 0; - public bool CanForceStop => true; - - public async Task ExecuteAsync(KernelExecutionRequest request, CancellationToken ct) - { - var clock = Stopwatch.StartNew(); - _executionCount++; - - var workingFolder = _context.WorkingDirectory(); - if (string.IsNullOrWhiteSpace(workingFolder) || !Directory.Exists(workingFolder)) - { - workingFolder = _host.HomeDirectory; - } - - var resolution = await _toolchain.ResolveAsync(new ToolchainQuery(workingFolder, _context.WorkspaceRoot?.Invoke()), ct).ConfigureAwait(false); - if (!resolution.IsFound || resolution.Toolchain == null) - { - var guidance = resolution.Missing ?? GoGuidance.NotInstalled(_host); - var err = guidance.ToText(); - request.OnConsole?.Invoke(err + "\n"); - return new KernelExecutionResult - { - Success = false, - ErrorMessage = guidance.Summary, - ConsoleOutput = err, - Elapsed = clock.Elapsed - }; - } - - var code = request.Code.Trim(); - ExtractImportsAndTopLevel(code, out var imports, out var topLevelItems, out var body); - - foreach (var imp in imports) _cumulativeImports.Add(imp); - - var hash = Math.Abs((workingFolder + "_go_cell_" + _executionCount).GetHashCode(StringComparison.OrdinalIgnoreCase)).ToString("x8"); - var outDir = Path.Combine(Path.GetTempPath(), "FryStudio", "go_cells", hash); - Directory.CreateDirectory(outDir); - - await GoDisplayRuntime.EnsureDisplayPackageAsync(outDir, ct).ConfigureAwait(false); - - var fullProgram = BuildCellProgram(body, topLevelItems); - var sourcePath = Path.Combine(outDir, "cell.go"); - await File.WriteAllTextAsync(sourcePath, fullProgram, ct).ConfigureAwait(false); - - var goExecutable = resolution.Toolchain.ExecutablePath; - var binName = _host.IsWindows ? "cell.exe" : "cell"; - var binPath = Path.Combine(outDir, binName); - - var buildArgs = new List { "build", "-o", binPath, sourcePath }; - var buildEnv = new Dictionary(StringComparer.Ordinal) - { - ["GOCACHE"] = Path.Combine(outDir, ".gocache") - }; - - var buildOutput = new StringBuilder(); - int buildExitCode; - try - { - using var buildProcess = _processes.Start(new ProcessStartSpec - { - FileName = goExecutable, - Arguments = buildArgs, - WorkingDirectory = outDir, - Environment = buildEnv - }, outText => buildOutput.Append(outText), errText => buildOutput.Append(errText)); - - // No limit of the kernel's own: how long a cell may take is the studio's ExecutionTimeoutSeconds setting, which - // arrives as the cancellation token. - buildExitCode = await buildProcess.WaitForExitOrKillAsync(ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return Cancelled(clock); - } - - if (buildExitCode != 0) - { - var err = buildOutput.ToString().Trim(); - request.OnConsole?.Invoke(err + "\n"); - return new KernelExecutionResult - { - Success = false, - ErrorMessage = err, - ConsoleOutput = err, - Elapsed = clock.Elapsed - }; - } - - // Commit top-level items on successful compilation - for (int i = 0; i < topLevelItems.Count; i++) - { - _cumulativeTopLevel[$"item_{_executionCount}_{i}"] = topLevelItems[i]; - } - - var spec = new ProcessStartSpec - { - FileName = binPath, - Arguments = Array.Empty(), - WorkingDirectory = workingFolder - }; - - // The cell's visuals, and the events on them its code listens to while it runs (over the event socket). - using var visuals = new ExternalVisualSession(); - var consoleBuilder = new StringBuilder(); - var processor = new ExternalOutputProcessor( - onConsoleText: text => - { - consoleBuilder.Append(text); - request.OnConsole?.Invoke(text); - }, - onRichOutput: bundle => - { - request.OnRichOutput?.Invoke(bundle); - }, - visuals: visuals.Visuals); - - using var managedProcess = _processes.Start( - visuals.Apply(spec), - onStandardOutput: text => processor.ProcessChunk(text), - onStandardError: err => processor.ProcessChunk(err)); - - // A cell can't be typed into, so a program that reads its input sees the end of it rather than waiting for good. - managedProcess.CloseInput(); - - try - { - var exitCode = await managedProcess.WaitForExitOrKillAsync(ct).ConfigureAwait(false); - processor.Flush(); - - return new KernelExecutionResult - { - Success = exitCode == 0, - ConsoleOutput = consoleBuilder.ToString(), - ErrorMessage = exitCode == 0 ? string.Empty : $"Program exited with code {exitCode}", - Elapsed = clock.Elapsed - }; - } - catch (OperationCanceledException) - { - return Cancelled(clock); - } - } - - private static KernelExecutionResult Cancelled(Stopwatch clock) => new() - { - WasCancelled = true, - ErrorMessage = "Cell execution was cancelled.", - Elapsed = clock.Elapsed - }; - - public Task> GetVariablesAsync(CancellationToken ct) - { - var list = new List(); - foreach (var (k, v) in _sharedVariables) - { - list.Add(new NotebookVariableInfo - { - Name = k, - TypeName = v.TypeName, - ValueDisplay = v.JsonValue, - Kind = "Variable", - Kernel = "Go" - }); - } - return Task.FromResult>(list); - } - - public Task GetValueJsonAsync(string name, CancellationToken ct) - { - if (_sharedVariables.TryGetValue(name, out var val)) - { - return Task.FromResult(val.JsonValue); - } - throw new KernelValueException($"Go kernel has no variable named '{name}'."); - } - - public Task SetValueFromJsonAsync(string name, string json, CancellationToken ct) - { - var typeName = "interface{}"; - var trimmed = json.Trim(); - if (trimmed == "true" || trimmed == "false") typeName = "bool"; - else if (long.TryParse(trimmed, out _)) typeName = "int64"; - else if (double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out _)) typeName = "float64"; - else if (trimmed.StartsWith('"')) typeName = "string"; - else if (trimmed.StartsWith('[')) typeName = "[]interface{}"; - - _sharedVariables[name] = (typeName, json); - return Task.CompletedTask; - } - - public void HardReset() - { - _sharedVariables.Clear(); - _cumulativeTopLevel.Clear(); - _cumulativeImports.Clear(); - _cumulativeImports.Add("\"fmt\""); - _cumulativeImports.Add("\"strings\""); - _cumulativeImports.Add("\"time\""); - _cumulativeImports.Add("\"os\""); - _cumulativeImports.Add("\"encoding/json\""); - _cumulativeImports.Add("\"fry\""); - _executionCount = 0; - } - - public void Dispose() - { - if (_isDisposed) return; - _isDisposed = true; - HardReset(); - } -} diff --git a/Services/Languages/Java/JavaLanguage.cs b/Services/Languages/Java/JavaLanguage.cs index 212334ee..57db73e5 100644 --- a/Services/Languages/Java/JavaLanguage.cs +++ b/Services/Languages/Java/JavaLanguage.cs @@ -2,81 +2,30 @@ using AvaloniaEdit.Highlighting; using AvaloniaEdit.Indentation; using PdfEditorApp.Plugins.CSharpEditor.Services.Debugging; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Java; /// -/// Java, compiled with javac and executed by java for source files, and run in a stateful JShell kernel -/// speaking the Fry kernel protocol for notebook cells and cross-language variable sharing. +/// Java in the studio: (the JDK, source files and the JShell notebook kernel) with the editor's +/// syntax highlighting, completion, folding and indentation, and the debugger. /// -public sealed class JavaLanguage : LanguageDefinition +public sealed class JavaLanguage : EngineLanguageDefinition { - private static readonly string[] JavaAliases = ["java", "jdk"]; - private static readonly string[] JavaExtensions = [".java"]; - public JavaLanguage(StudioLanguageServices services) + : base(new JavaEngine(services.Engines)) { - var toolchain = new JavaToolchainProvider( - services.Host, - services.Processes, - services.ToolchainSettings, - Path.Combine(services.BaseDirectory, "java")); - JavaToolchain = toolchain; - ScriptRunner = new JavaBuildAndRunScriptRunner(services.Host); - Packages = new MavenPackageManager(new MavenCentralResolver(services.Host), services.Host); - Debugger = new JavaDebuggerProvider(toolchain, services.Processes, services.Host, services.AdapterManager); - - var launcher = new JavaKernelLauncher(toolchain, services.Host, Path.Combine(services.BaseDirectory, "java", "kernel")); - NotebookKernels = new DelegateKernelFactory(context => - new ProtocolKernel(LanguageIds.Java, "Java", launcher, services.Processes, context)); + Debugger = new JavaDebuggerProvider(Engine.JavaToolchain, services.Processes, services.Host, services.AdapterManager); } - public JavaToolchainProvider JavaToolchain { get; } - - public override string Id => LanguageIds.Java; - public override string DisplayName => "Java"; - public override JupyterLanguageInfo Jupyter { get; } = new("java", "Java", "java", "java", ".java", "text/x-java-source"); - public override string ShortName => "JAVA"; - public override IReadOnlyList Aliases => JavaAliases; - public override IReadOnlyList FileExtensions => JavaExtensions; - public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; - public override bool IsCompiled => true; - - public override LanguageCapabilities Capabilities => - LanguageCapabilities.StandardInput | LanguageCapabilities.NotebookCells | - LanguageCapabilities.ValueSharing | LanguageCapabilities.Debugging | - LanguageCapabilities.Breakpoints | LanguageCapabilities.Packages | - LanguageCapabilities.Completion; + public JavaToolchainProvider JavaToolchain => Engine.JavaToolchain; public override IEditorAssistantFactory EditorAssistants { get; } = JavaEditorAssistantFactory.Instance; - public override string IconKind => "LanguageJava"; - public override string AccentHex => "#EA2D2E"; - public override string LineCommentPrefix => "//"; - public override string RuntimeDescription => "Java (JDK)"; - - public override string NewFileTemplate => - "// Runs with the Java Development Kit (JDK) installed on this computer (F5).\n" + - "public class Main {\n" + - " public static void main(String[] args) {\n" + - " System.out.println(\"Hello from Java \" + System.getProperty(\"java.version\") + \"!\");\n" + - " }\n" + - "}\n"; - public override IHighlightingDefinition GetHighlighting(bool isDark) => JavaSyntaxHighlighting.Get(isDark); public override IIndentationStrategy CreateIndentationStrategy(TextEditorOptions options) => new JavaIndentationStrategy(options); public override ILanguageFolding Folding { get; } = new JavaFoldingStrategy(); - public override IToolchainProvider Toolchain => JavaToolchain; - public override IScriptRunner ScriptRunner { get; } - public override IDebuggerProvider? Debugger { get; } - public override IDiagnosticParser RunDiagnostics { get; } = new JavaCompilerDiagnosticParser(); - public override IPackageManager Packages { get; } - public override INotebookKernelFactory NotebookKernels { get; } + public override IDebuggerProvider Debugger { get; } } diff --git a/Services/Languages/Rust/RustLanguage.cs b/Services/Languages/Rust/RustLanguage.cs index ce1d63bf..d08bec09 100644 --- a/Services/Languages/Rust/RustLanguage.cs +++ b/Services/Languages/Rust/RustLanguage.cs @@ -2,67 +2,22 @@ using AvaloniaEdit.Highlighting; using AvaloniaEdit.Indentation; using PdfEditorApp.Plugins.CSharpEditor.Services.Debugging; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Rust; /// -/// Rust, built with Cargo and run natively for source files: a two-phase build and run, rustc's messages in Problems, -/// interactive standard input, and crates named in // #crate: comments. +/// Rust in the studio: (Cargo builds, scripts and the notebook kernel) with the editor's syntax +/// highlighting, static completion and hover, folding and indentation, and the lldb-dap debugger. /// -public sealed class RustLanguage : LanguageDefinition +public sealed class RustLanguage : EngineLanguageDefinition { - private static readonly string[] RustAliases = ["rust", "rs"]; - private static readonly string[] RustExtensions = [".rs"]; - public RustLanguage(StudioLanguageServices services) + : base(new RustEngine(services.Engines)) { - var rustRoot = Path.Combine(services.BaseDirectory, "rust"); - var toolchain = new RustToolchainProvider(services.Host, services.Processes, services.ToolchainSettings, rustRoot); - RustToolchain = toolchain; - var packages = new RustPackageManager(rustRoot, toolchain, services.Processes, services.Host); - Packages = packages; - Debugger = new RustDebuggerProvider(rustRoot, toolchain, services.Processes, services.Host, services.AdapterManager); - ScriptRunner = new RustBuildAndRunScriptRunner(services.Host, rustRoot); - NotebookKernels = new DelegateKernelFactory(context => - new RustNotebookKernel(toolchain, services.Processes, services.Host, rustRoot, packages.NotebookDependencies, context)); + Debugger = new RustDebuggerProvider(Engine.RustRoot, Engine.RustToolchain, services.Processes, services.Host, services.AdapterManager); } - public RustToolchainProvider RustToolchain { get; } - - public override string Id => LanguageIds.Rust; - public override string DisplayName => "Rust"; - public override JupyterLanguageInfo Jupyter { get; } = new("rust", "Rust", "rust", "rust", ".rs", "text/rust"); - public override string ShortName => "RS"; - public override IReadOnlyList Aliases => RustAliases; - public override IReadOnlyList FileExtensions => RustExtensions; - public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; - public override bool IsCompiled => true; - - public override LanguageCapabilities Capabilities => - LanguageCapabilities.StandardInput | - LanguageCapabilities.QuickInfo | - LanguageCapabilities.Completion | - LanguageCapabilities.Debugging | - LanguageCapabilities.Breakpoints | - LanguageCapabilities.NotebookCells | - LanguageCapabilities.ValueSharing | - LanguageCapabilities.Packages; - - public override string IconKind => "LanguageRust"; - public override string AccentHex => "#DEA584"; - public override string LineCommentPrefix => "//"; - public override string RuntimeDescription => "Rust (rustc / Cargo)"; - - public override string NewFileTemplate => - "// Builds with Cargo and runs with the Rust toolchain installed on this computer (F5).\n" + - "// Add a crate with a comment such as: // #crate: rand = \"0.8\"\n" + - "fn main() {\n" + - " println!(\"Hello from Rust!\");\n" + - "}\n"; + public RustToolchainProvider RustToolchain => Engine.RustToolchain; public override IEditorAssistantFactory EditorAssistants { get; } = RustEditorAssistantFactory.Instance; @@ -72,10 +27,5 @@ public RustLanguage(StudioLanguageServices services) public override ILanguageFolding Folding { get; } = new RustFoldingStrategy(); - public override IToolchainProvider Toolchain => RustToolchain; - public override IScriptRunner ScriptRunner { get; } - public override IDiagnosticParser RunDiagnostics { get; } = new RustDiagnosticParser(); - public override IPackageManager Packages { get; } - public override IDebuggerProvider? Debugger { get; } - public override INotebookKernelFactory NotebookKernels { get; } + public override IDebuggerProvider Debugger { get; } } diff --git a/Services/Languages/Sql/SqlCompletionService.cs b/Services/Languages/Sql/SqlCompletionService.cs index acf05305..e10d252a 100644 --- a/Services/Languages/Sql/SqlCompletionService.cs +++ b/Services/Languages/Sql/SqlCompletionService.cs @@ -1,171 +1,16 @@ -using System.Text.RegularExpressions; using PdfEditorApp.Plugins.CSharpEditor.Models; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; /// -/// Provides code completion suggestions for SQL scripts and notebook cells: -/// SQLite dot-commands (after .), DDL/DML keywords, data types, built-in functions, and local table definitions. +/// The editor's completion for SQL scripts and notebook cells: , off the UI thread's way. /// -public sealed partial class SqlCompletionService : ILanguageCompletionService +public sealed class SqlCompletionService : ILanguageCompletionService { - [GeneratedRegex(@"\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?[a-zA-Z_][a-zA-Z0-9_]*)", RegexOptions.IgnoreCase | RegexOptions.Multiline)] - private static partial Regex CreateTableRegex(); - - [GeneratedRegex(@"\bCREATE\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?(?[a-zA-Z_][a-zA-Z0-9_]*)", RegexOptions.IgnoreCase | RegexOptions.Multiline)] - private static partial Regex CreateViewRegex(); - public Task> GetCompletionsAsync( string code, int caretOffset, EditorAssistantContext context, - CancellationToken ct = default) - { - if (string.IsNullOrEmpty(code) || caretOffset < 0 || caretOffset > code.Length) - { - return Task.FromResult>(Array.Empty()); - } - - // Check if cursor is immediately after a dot (SQLite dot command) - int wordStart = caretOffset; - while (wordStart > 0 && (char.IsLetterOrDigit(code[wordStart - 1]) || code[wordStart - 1] == '_')) - { - wordStart--; - } - - if (wordStart > 0 && code[wordStart - 1] == '.') - { - var prefix = code[wordStart..caretOffset]; - return Task.FromResult(GetDotCommandCompletions(prefix)); - } - - var generalPrefix = code[wordStart..caretOffset]; - return Task.FromResult(GetGeneralCompletions(code, generalPrefix)); - } - - private static IReadOnlyList GetDotCommandCompletions(string prefix) - { - var dotCommands = new (string Name, string Description)[] - { - (".tables", "List names of tables matching LIKE pattern"), - (".schema", "Show the CREATE statements matching pattern"), - (".mode", "Set output mode: table, column, json, csv, line"), - (".header", "Turn display of headers on or off"), - (".read", "Execute SQL from a file"), - (".databases", "List names and files of attached databases"), - (".indices", "List names of indexes"), - (".dump", "Render database content as SQL"), - (".nullvalue", "Use STRING in place of NULL values"), - (".show", "Show the current values for various settings"), - (".exit", "Exit SQLite prompt") - }; - - var list = new List(); - foreach (var (cmd, desc) in dotCommands) - { - var clean = cmd.TrimStart('.'); - Add(list, prefix, cmd, cmd, desc, CompletionItemKind.Method); - } - return list; - } - - private static IReadOnlyList GetGeneralCompletions(string code, string prefix) - { - var list = new List(); - - // Keywords and snippets - var keywords = new (string Word, string Snippet, string Desc, CompletionItemKind Kind)[] - { - ("SELECT", "SELECT ", "Retrieves data from one or more tables.", CompletionItemKind.Keyword), - ("FROM", "FROM ", "Specifies the table or view to query from.", CompletionItemKind.Keyword), - ("WHERE", "WHERE ", "Filters rows based on a search condition.", CompletionItemKind.Keyword), - ("GROUP BY", "GROUP BY ", "Groups rows with identical values in summary columns.", CompletionItemKind.Keyword), - ("HAVING", "HAVING ", "Filters groups created by GROUP BY.", CompletionItemKind.Keyword), - ("ORDER BY", "ORDER BY ", "Sorts query result set by specified columns.", CompletionItemKind.Keyword), - ("LIMIT", "LIMIT ", "Constrains the number of rows returned.", CompletionItemKind.Keyword), - ("OFFSET", "OFFSET ", "Skips specified number of rows before returning rows.", CompletionItemKind.Keyword), - ("JOIN", "JOIN ", "Combines rows from two or more tables.", CompletionItemKind.Keyword), - ("LEFT JOIN", "LEFT JOIN ", "Returns all records from the left table and matched from right.", CompletionItemKind.Keyword), - ("INNER JOIN", "INNER JOIN ", "Returns records that have matching values in both tables.", CompletionItemKind.Keyword), - ("INSERT INTO", "INSERT INTO ", "Inserts new rows into a table.", CompletionItemKind.Keyword), - ("VALUES", "VALUES ", "Specifies row values to insert.", CompletionItemKind.Keyword), - ("UPDATE", "UPDATE ", "Modifies existing rows in a table.", CompletionItemKind.Keyword), - ("SET", "SET ", "Specifies columns and values to update.", CompletionItemKind.Keyword), - ("DELETE FROM", "DELETE FROM ", "Deletes rows from a table.", CompletionItemKind.Keyword), - ("CREATE TABLE", "CREATE TABLE IF NOT EXISTS ", "Creates a new database table.", CompletionItemKind.Keyword), - ("DROP TABLE", "DROP TABLE IF EXISTS ", "Removes a table definition and data.", CompletionItemKind.Keyword), - ("ALTER TABLE", "ALTER TABLE ", "Modifies an existing table structure.", CompletionItemKind.Keyword), - ("PRAGMA", "PRAGMA ", "Queries or modifies SQLite operational parameters.", CompletionItemKind.Keyword), - ("UNION ALL", "UNION ALL ", "Combines results of two queries without removing duplicates.", CompletionItemKind.Keyword), - ("DISTINCT", "DISTINCT ", "Filters duplicate rows from result set.", CompletionItemKind.Keyword), - ("PRIMARY KEY", "PRIMARY KEY ", "Uniquely identifies each record in a table.", CompletionItemKind.Keyword), - ("FOREIGN KEY", "FOREIGN KEY ", "Defines a relationship to another table.", CompletionItemKind.Keyword), - ("AUTOINCREMENT", "AUTOINCREMENT", "Automatically increments integer primary key.", CompletionItemKind.Keyword) - }; - - foreach (var (word, snippet, desc, kind) in keywords) - { - Add(list, prefix, word, snippet, desc, kind); - } - - // Data types - var types = new[] { "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE", "VARCHAR(255)" }; - foreach (var t in types) - { - Add(list, prefix, t, t, $"SQLite data type {t}", CompletionItemKind.Class); - } - - // Functions - var functions = new (string Name, string Snippet, string Desc)[] - { - ("COUNT", "COUNT(*)", "Returns the number of rows matching criteria."), - ("SUM", "SUM()", "Returns total sum of a numeric column."), - ("AVG", "AVG()", "Returns the average value of a numeric column."), - ("MIN", "MIN()", "Returns the smallest value in a column."), - ("MAX", "MAX()", "Returns the largest value in a column."), - ("COALESCE", "COALESCE()", "Returns first non-null argument."), - ("IFNULL", "IFNULL()", "Returns first non-null argument, or second argument."), - ("NULLIF", "NULLIF()", "Returns NULL if both arguments are equal."), - ("ROUND", "ROUND()", "Rounds numeric expression to specified decimals."), - ("LOWER", "LOWER()", "Converts string to lowercase."), - ("UPPER", "UPPER()", "Converts string to uppercase."), - ("LENGTH", "LENGTH()", "Returns character or byte length of expression."), - ("SUBSTR", "SUBSTR()", "Returns substring of a string."), - ("PRINTF", "PRINTF()", "Formats text according to format string."), - ("STRFTIME", "STRFTIME()", "Formats date/time string."), - ("DATE", "DATE('now')", "Returns current date (YYYY-MM-DD)."), - ("DATETIME", "DATETIME('now')", "Returns current date and time.") - }; - - foreach (var (name, snippet, desc) in functions) - { - Add(list, prefix, name, snippet, desc, CompletionItemKind.Method); - } - - // Tables defined in code - foreach (Match match in CreateTableRegex().Matches(code)) - { - var tbl = match.Groups["name"].Value; - Add(list, prefix, tbl, tbl, $"User table '{tbl}'", CompletionItemKind.Struct); - } - - return list; - } - - private static void Add(List list, string prefix, string text, string snippet, string doc, CompletionItemKind kind) - { - if (!string.IsNullOrEmpty(prefix) && !text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - list.Add(new CSharpCompletionItem - { - DisplayText = text, - InsertionText = snippet, - Signature = text, - Documentation = doc, - Kind = kind - }); - } + CancellationToken ct = default) => + Task.FromResult(SqlCompletions.Get(code, caretOffset)); } diff --git a/Services/Languages/Sql/SqlLanguage.cs b/Services/Languages/Sql/SqlLanguage.cs index aa7d30ae..e5e7e9aa 100644 --- a/Services/Languages/Sql/SqlLanguage.cs +++ b/Services/Languages/Sql/SqlLanguage.cs @@ -2,80 +2,21 @@ using AvaloniaEdit.Highlighting; using AvaloniaEdit.Indentation; using PdfEditorApp.Plugins.CSharpEditor.Services.Debugging; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; -using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; namespace PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; /// -/// SQL language module: executes SQL scripts (.sql) and polyglot notebook cells via SQLite CLI (sqlite3) -/// with formatted table output, interactive stdin streaming, syntax highlighting, and cross-kernel variable sharing. +/// SQL in the studio: (the SQLite 3 CLI, scripts and notebook kernels) with the editor's syntax +/// highlighting, completion, hover, folding and indentation. /// -public sealed class SqlLanguage : LanguageDefinition +public sealed class SqlLanguage : EngineLanguageDefinition { - private static readonly string[] SqlAliases = ["sql", "sqlite", "sqlite3"]; - private static readonly string[] SqlExtensions = [".sql"]; - public SqlLanguage(StudioLanguageServices services) + : base(new SqlEngine(services.Engines)) { - var toolchain = new SqlToolchainProvider( - services.Host, - services.Processes, - services.ToolchainSettings); - SqlToolchain = toolchain; - ScriptRunner = new SqlScriptRunner(); - Packages = new SqlPackageManager(); - NotebookKernels = new DelegateKernelFactory(context => - new SqlNotebookKernel(toolchain, services.Processes, services.Host, context)); } - public SqlToolchainProvider SqlToolchain { get; } - - public override string Id => LanguageIds.Sql; - public override string DisplayName => "SQL"; - public override JupyterLanguageInfo Jupyter { get; } = new("sql", "SQL", "sql", "sql", ".sql", "application/sql"); - public override string ShortName => "SQL"; - public override IReadOnlyList Aliases => SqlAliases; - public override IReadOnlyList FileExtensions => SqlExtensions; - public override LanguageStorageKind Storage => LanguageStorageKind.SourceFile; - public override bool IsCompiled => false; - - public override LanguageCapabilities Capabilities => - LanguageCapabilities.StandardInput | - LanguageCapabilities.QuickInfo | - LanguageCapabilities.Completion | - LanguageCapabilities.NotebookCells | - LanguageCapabilities.ValueSharing | - LanguageCapabilities.Packages | - LanguageCapabilities.Formatting; - - public override string IconKind => "Database"; - public override string AccentHex => "#F29111"; - public override string LineCommentPrefix => "--"; - public override string RuntimeDescription => "SQLite 3 Engine"; - - public override string NewFileTemplate => - "-- Runs with SQLite 3 (sqlite3) on this computer (F5).\n" + - "-- :database :memory:\n\n" + - "CREATE TABLE IF NOT EXISTS developers (\n" + - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + - " name TEXT NOT NULL,\n" + - " role TEXT NOT NULL,\n" + - " rating REAL DEFAULT 5.0\n" + - ");\n\n" + - "INSERT INTO developers (name, role, rating) VALUES\n" + - " ('Antigravity', 'AI Architect', 5.0),\n" + - " ('Ada Lovelace', 'First Programmer', 5.0),\n" + - " ('Alan Turing', 'Computer Pioneer', 5.0);\n\n" + - "SELECT \n" + - " id,\n" + - " name,\n" + - " role,\n" + - " printf('★ %.1f', rating) AS score\n" + - "FROM developers\n" + - "ORDER BY id ASC;\n"; + public SqlToolchainProvider SqlToolchain => Engine.SqlToolchain; public override IEditorAssistantFactory EditorAssistants { get; } = SqlEditorAssistantFactory.Instance; @@ -85,10 +26,5 @@ public SqlLanguage(StudioLanguageServices services) public override ILanguageFolding Folding { get; } = new SqlFoldingStrategy(); - public override IToolchainProvider Toolchain => SqlToolchain; - public override IScriptRunner ScriptRunner { get; } public override IDebuggerProvider? Debugger => null; - public override IDiagnosticParser RunDiagnostics { get; } = new SqlCompilerDiagnosticParser(); - public override IPackageManager Packages { get; } - public override INotebookKernelFactory NotebookKernels { get; } } diff --git a/Tests.Core/CppEngineTests.cs b/Tests.Core/CppEngineTests.cs new file mode 100644 index 00000000..6d02d630 --- /dev/null +++ b/Tests.Core/CppEngineTests.cs @@ -0,0 +1,347 @@ +using System.Text.Json; +using CSharpEditorPlugin.Tests; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Cpp; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// One engine for the class: the staged headers are written once; each test gets its own kernel, as each notebook does. +public sealed class CppFixture : IDisposable +{ + public CppFixture() + { + Folder = new TempFolder(); + Engine = new CppEngine(LanguageEngineServices.Create(Folder.Path)); + } + + public TempFolder Folder { get; } + + public CppEngine Engine { get; } + + public void Dispose() => Folder.Dispose(); +} + +/// C++ cells run by a real compiler, through the engine a headless host has (no Avalonia). +public class CppEngineTests(CppFixture fixture) : IClassFixture, IDisposable +{ + private readonly List _kernels = []; + + public void Dispose() + { + foreach (var kernel in _kernels) kernel.Dispose(); + } + + private INotebookKernel NewKernel() + { + var kernel = fixture.Engine.NotebookKernels.Create(new KernelCreationContext(() => fixture.Folder.Path)); + _kernels.Add(kernel); + return kernel; + } + + private sealed record Ran(KernelExecutionResult Result, List Text, List Rich) + { + public string Console => string.Concat(Text); + } + + private static async Task RunAsync(INotebookKernel kernel, string code, Func? input = null, CancellationToken ct = default) + { + var text = new List(); + var rich = new List(); + var result = await kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = code, + OnConsole = text.Add, + OnRichOutput = rich.Add, + OnInputRequest = input is null ? null : (prompt, password, _) => Task.FromResult(input(prompt, password)) + }, ct); + return new Ran(result, text, rich); + } + + private static async Task MustRunAsync(INotebookKernel kernel, string code) + { + var ran = await RunAsync(kernel, code); + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Result.ConsoleOutput + ran.Console); + return ran; + } + + [RealCppFact] + public async Task WhatACellMakes_IsThereForTheNextCell() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "struct Person {\n std::string name;\n int age;\n};\n\nint double_it(int n) { return n * 2; }\n\nint x = 21;\nstd::vector names{\"Ada\"};"); + + var ran = await MustRunAsync(kernel, "names.push_back(\"Bob\");\nPerson p{names[1], 36};\nstd::cout << p.name << \" is \" << p.age << \", \" << double_it(x) << \"\\n\";"); + + Assert.Equal("Bob is 36, 42\n", ran.Console); + Assert.Equal("2\n", (await MustRunAsync(kernel, "names.size()")).Console); + Assert.Equal("\"Bob\"\n", (await MustRunAsync(kernel, "p.name")).Console); + Assert.Equal("Person", Assert.Single((await MustRunAsync(kernel, "p")).Rich).InspectorNode!.HeaderTitle); + } + + [RealCppFact] + public async Task AStaticVariable_KeepsWhatLaterCellsDoToIt_AndAFunctionSeesIt() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "static int counter = 10;\n\nvoid bump() { counter++; }"); + await MustRunAsync(kernel, "bump();\ncounter += 5;"); + + Assert.Equal("16\n", (await MustRunAsync(kernel, "counter")).Console); + } + + [RealCppFact] + public async Task ADeclarationWrittenAgain_TakesTheOldOnesPlace_AndOverloadsLiveTogether() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "std::string greet() { return \"first\"; }\nint n = 1;\nint f(int a) { return a; }"); + + await MustRunAsync(kernel, "std::string greet() { return \"second\"; }\nint n = 2;\ndouble f(double a) { return a * 2; }"); + + Assert.Equal("\"second\"\n", (await MustRunAsync(kernel, "greet()")).Console); + Assert.Equal("2\n", (await MustRunAsync(kernel, "n")).Console); + Assert.Equal("7\n", (await MustRunAsync(kernel, "f(7)")).Console); + Assert.Equal("3\n", (await MustRunAsync(kernel, "f(1.5)")).Console); + } + + [RealCppFact] + public async Task TheLastExpression_IsText_ForAScalarAShortListAnEmptyListAndACallThatReturnsNothing() + { + var kernel = NewKernel(); + + Assert.Equal("42\n", (await MustRunAsync(kernel, "int x = 6;\nx * 7")).Console); + Assert.Equal("\"Ferris\"\n", (await MustRunAsync(kernel, "std::string(\"Ferris\")")).Console); + Assert.Equal("[ 1, 2, 3 ]\n", (await MustRunAsync(kernel, "std::vector{1, 2, 3}")).Console); + Assert.Equal("[ ]\n", (await MustRunAsync(kernel, "std::vector{}")).Console); + Assert.Equal("3.5\n", (await MustRunAsync(kernel, "1.75 * 2")).Console); + + // a statement that prints (or ends with ;) is not a value; a call that returns nothing is run as it is + var printed = await MustRunAsync(kernel, "std::cout << \"only this\""); + Assert.Equal("only this", printed.Console); + Assert.Empty(printed.Rich); + Assert.Equal(string.Empty, (await MustRunAsync(kernel, "x * 7;")).Console); + await MustRunAsync(kernel, "void hello() { std::cout << \"hi\\n\"; }"); + Assert.Equal("hi\n", (await MustRunAsync(kernel, "hello()")).Console); + } + + [RealCppFact] + public async Task ADeepValue_IsListedFourLevelsDown_AndTheFifthLevelIsATitle() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "struct E { int v; };\nstruct D { int v; E next; };\nstruct C { int v; D next; };\nstruct B { int v; C next; };\nstruct A { int v; B next; };"); + + var shown = Assert.Single((await MustRunAsync(kernel, "A{1, {2, {3, {4, {5}}}}}")).Rich); + + InspectorChain.AssertFiveLevels(shown.InspectorNode!, "next", ["v", "next"], cycle: false); + } + + [RealCppFact] + public async Task AListOfStructs_IsATable_AStructAMapAndAPair_AnObjectExplorerOrATable() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "struct Point { int x; int y; };\nstruct Person {\n std::string name;\n std::vector tags;\n Point home;\n std::optional score;\n};"); + + var table = Assert.Single((await MustRunAsync(kernel, "std::vector{{1, 2}, {3, 4}}")).Rich).TableResult!; + Assert.Equal(["x", "y"], table.Columns.Select(c => c.Header)); + Assert.Equal([true, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal(["1", "3"], table.Rows.Select(r => r.Cells[0].DisplayText)); + + var person = Assert.Single((await MustRunAsync(kernel, "Person{\"Ada\", {\"a\", \"b\"}, {1, 2}, 7}")).Rich).InspectorNode!; + Assert.Equal("Person", person.HeaderTitle); + Assert.Equal(["name", "tags", "home", "score"], person.Properties.Select(p => p.Name)); + Assert.Equal("Ada", person.Properties[0].SimpleValueText); + Assert.Equal(["[0]", "[1]"], person.Properties[1].ChildNode!.Properties.Select(p => p.Name)); + Assert.Equal(["x", "y"], person.Properties[2].ChildNode!.Properties.Select(p => p.Name)); + Assert.Equal("7", person.Properties[3].SimpleValueText); + + var map = Assert.Single((await MustRunAsync(kernel, "std::map{{\"b\", 2}, {\"a\", 1}}")).Rich).TableResult!; + Assert.Equal(["key", "value"], map.Columns.Select(c => c.Header)); + Assert.Equal(["a", "b"], map.Rows.Select(r => r.Cells[0].DisplayText)); + + Assert.Equal(["first", "second"], Assert.Single((await MustRunAsync(kernel, "std::make_pair(1, std::string(\"x\"))")).Rich).InspectorNode!.Properties.Select(p => p.Name)); + } + + [RealCppFact] + public async Task Markdown_AndAChart_AreOutputs() + { + var kernel = NewKernel(); + + Assert.Contains("# Title", Assert.Single((await MustRunAsync(kernel, "fry::markdown(\"# Title\\n\\nsome **text**\");")).Rich).MarkdownSource); + Assert.Equal(CellOutputKind.Chart, Assert.Single((await MustRunAsync(kernel, "fry::line_chart(std::vector{3, 1, 4}, \"Numbers\");")).Rich).Kind); + } + + [RealCppFact] + public async Task WhatTheProgramPrints_ComesInTheOrderItWasPrinted_StderrAmongIt() + { + var ran = await MustRunAsync(NewKernel(), "std::cout << \"a\\n\";\nstd::cerr << \"to stderr\\n\";\nstd::cout << \"b\\n\";\nprintf(\"héllo ✓ 日本\\n\");"); + + Assert.Equal("a\nto stderr\nb\nhéllo ✓ 日本\n", ran.Console); + } + + [RealCppFact] + public async Task ACompileError_IsOnTheCellsOwnLine_InTheDiagnosticsAndInTheText() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "int helper() { return 1; }"); + + var ran = await RunAsync(kernel, "int ok = helper();\n\nint wrong = \"three\";\nstd::cout << ok << wrong;"); + + Assert.False(ran.Result.Success); + var error = Assert.Single(ran.Result.Diagnostics, d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error); + Assert.Equal(3, error.Line); + var shown = ran.Result.ConsoleOutput; + Assert.Contains("cell:3:", shown); + Assert.DoesNotContain("cell.cpp", shown); + Assert.Contains("3 | int wrong", shown); + } + + [RealCppFact] + public async Task ACompileErrorInADeclaration_IsOnItsLineToo() + { + var ran = await RunAsync(NewKernel(), "int f() {\n return 1;\n}\n\nint g() { return undefined_name; }"); + + Assert.False(ran.Result.Success); + Assert.Equal(5, Assert.Single(ran.Result.Diagnostics, d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).Line); + } + + [RealCppFact] + public async Task AnUncaughtException_EndsTheCellNamingTheCause_AndTheNextCellStillWorks() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "int kept = 7;"); + + var ran = await RunAsync(kernel, "std::cout << \"before\\n\";\nthrow std::runtime_error(\"boom\");"); + + Assert.False(ran.Result.Success); + Assert.Contains("before\n", ran.Console); + Assert.Contains("boom", ran.Result.ErrorMessage); + Assert.Equal("7\n", (await MustRunAsync(kernel, "kept")).Console); + } + + [RealCppFact] + public async Task FryInput_AsksTheNotebook_AndNoAnswerIsTheEndOfInput() + { + var kernel = NewKernel(); + var prompts = new List(); + var answers = new Queue(["Ada", null]); + + var ran = await RunAsync(kernel, "auto a = fry::input(\"Name? \");\nauto b = fry::input(\"Again? \");\nstd::cout << a.value_or(\"?\") << \" \" << b.has_value() << \"\\n\";", (prompt, password) => + { + prompts.Add(prompt + (password ? " (secret)" : string.Empty)); + return answers.Dequeue(); + }); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Result.ConsoleOutput); + Assert.Equal(["Name? ", "Again? "], prompts); + Assert.Equal("Ada 0\n", ran.Console); + } + + [RealCppFact] + public async Task FryPassword_AsksForASecret_AndAPlainCinRead_SeesTheEnd() + { + var secret = false; + var ran = await RunAsync(NewKernel(), "auto p = fry::password(\"Key: \");\nstd::cout << p->size() << \"\\n\";", (_, password) => + { + secret = password; + return "hunter2"; + }); + Assert.True(secret); + Assert.Equal("7\n", ran.Console); + + var plain = await MustRunAsync(NewKernel(), "std::string line;\nbool ok = static_cast(std::getline(std::cin, line));\nstd::cout << ok << \"\\n\";"); + Assert.Equal("0\n", plain.Console); + } + + [RealCppFact] + public async Task AValueIsSharedOut_AndAValueFromAnotherLanguageIsThereAsATypedVariable() + { + var kernel = NewKernel(); + + var shared = await MustRunAsync(kernel, "std::vector scores{90, 85, 77};\nfry::share(\"best\", 90);"); + Assert.DoesNotContain("__FRY_SHARE__", shared.Console); + Assert.Equal("90", await kernel.GetValueJsonAsync("best", CancellationToken.None)); + Assert.Equal("[90,85,77]", await kernel.GetValueJsonAsync("scores", CancellationToken.None)); // any variable that can be saved + + await kernel.SetValueFromJsonAsync("nums", "[1,2,3,4]", CancellationToken.None); + await kernel.SetValueFromJsonAsync("ratio", "0.5", CancellationToken.None); + await kernel.SetValueFromJsonAsync("label", "\"fry\"", CancellationToken.None); + await kernel.SetValueFromJsonAsync("config", "{\"name\":\"fry\",\"sizes\":[1,2.5],\"on\":true}", CancellationToken.None); + await kernel.SetValueFromJsonAsync("rows", "[{\"n\":1},{\"n\":2}]", CancellationToken.None); + var ran = await MustRunAsync(kernel, "long long sum = 0;\nfor (auto n : nums) sum += n;\nstd::cout << sum << \" \" << ratio * 4 << \" \" << label << \" \" << config[\"name\"].as_string() << \" \" << rows.size() << \" \" << rows[1][\"n\"].as_int() << \" \" << scores[0] << \"\\n\";"); + Assert.Equal("10 2 fry fry 2 2 90\n", ran.Console); + + await Assert.ThrowsAsync(() => kernel.GetValueJsonAsync("nothing_like_it", CancellationToken.None)); + await Assert.ThrowsAsync(() => kernel.SetValueFromJsonAsync("class", "1", CancellationToken.None)); + } + + [RealCppFact] + public async Task AVariableThatCantBeKept_SaysSoOnce_AndSaysWhyWhenALaterCellNeedsIt() + { + var kernel = NewKernel(); + + var first = await MustRunAsync(kernel, "int arr[3] = {1, 2, 3};\nint ok = 5;"); + Assert.Contains("note: arr isn't kept for later cells", first.Console); + + var later = await RunAsync(kernel, "std::cout << arr[0];"); + Assert.False(later.Result.Success); + Assert.Contains("arr was made in an earlier cell", later.Result.ConsoleOutput); + Assert.Equal("5\n", (await MustRunAsync(kernel, "ok")).Console); + } + + [RealCppFact] + public async Task AnIncludeOfACell_IsThereForTheNext() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "#include \nint r = 1;"); + + Assert.Equal("\"00000101\"\n", (await MustRunAsync(kernel, "std::bitset<8>(5).to_string()")).Console); + } + + [RealCppFact] + public async Task TheKernelsVariables_AreWhatTheCellsMade_AndARestartForgetsThem() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "struct T { int a; };\nint add(int a, int b) { return a + b; }\nint answer = 42;"); + + var variables = await kernel.GetVariablesAsync(CancellationToken.None); + Assert.Contains(variables, v => v.Name == "add" && v.Kind == "Function"); + Assert.Contains(variables, v => v.Name == "T" && v.Kind == "Type"); + Assert.Contains(variables, v => v.Name == "answer" && v.TypeName == "int" && v.ValueDisplay == "42"); + + kernel.HardReset(); + + Assert.Empty(await kernel.GetVariablesAsync(CancellationToken.None)); + Assert.False((await RunAsync(kernel, "answer")).Result.Success); + } + + [RealCppFact] + public async Task Stop_EndsTheRunningProgram_AndWhatTheCellsMadeBeforeIsKept() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "int kept = 7;"); + using var cts = new CancellationTokenSource(); + var started = false; + + var running = kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = "std::cout << \"started\\n\";\nwhile (true) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); }", + OnConsole = text => { if (text.Contains("started")) started = true; } + }, cts.Token); + Assert.True(SpinWait.SpinUntil(() => started, TimeSpan.FromMinutes(2)), "the program never started"); + cts.Cancel(); + var stopped = await running; + + Assert.True(stopped.WasCancelled); + Assert.Equal("7\n", (await MustRunAsync(kernel, "kept")).Console); + } + + [RealCppFact] + public async Task AWholeProgram_IsRunAsItIs() + { + var ran = await MustRunAsync(NewKernel(), "std::string greet() { return \"whole program\"; }\n\nint main() {\n std::cout << greet() << \"\\n\";\n return 0;\n}"); + + Assert.Equal("whole program\n", ran.Console); + } +} diff --git a/Tests.Core/ExtensionFixturesTests.cs b/Tests.Core/ExtensionFixturesTests.cs new file mode 100644 index 00000000..254e6aee --- /dev/null +++ b/Tests.Core/ExtensionFixturesTests.cs @@ -0,0 +1,203 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Nodes; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; +using PdfEditorApp.Plugins.CSharpEditor.Visuals.Building; +using PdfEditorApp.Plugins.CSharpEditor.Visuals.Output; +using PdfEditorApp.Plugins.CSharpEditor.Visuals.Spec; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// The notebook files shared with the VS Code extension (vscode-frysharp/test/fixtures). desktop.frynb is written +/// here, by the desktop's own writer, with every kind of output; the extension's tests read it, write it back, and the +/// files they write (written/) are read here by the desktop's reader. So neither side can change the format alone. +/// Set FRY_UPDATE_FIXTURES=1 to rewrite desktop.frynb after changing what the desktop writes. +/// +public sealed class ExtensionFixturesTests +{ + // 1 x 1 pixel PNG + private static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=="); + + private static string FixturesFolder + { + get + { + for (var folder = new DirectoryInfo(AppContext.BaseDirectory); folder != null; folder = folder.Parent) + { + if (File.Exists(Path.Combine(folder.FullName, "CSharpEditorPlugin.slnx"))) return Path.Combine(folder.FullName, "vscode-frysharp", "test", "fixtures"); + } + throw new InvalidOperationException("The repository root (CSharpEditorPlugin.slnx) was not found above the test's folder."); + } + } + + private static NotebookDocumentItem DesktopNotebook() + { + var notebook = new NotebookDocumentItem + { + Id = "fixture-notebook", + Title = "Desktop fixture", + Created = new DateTime(2026, 10, 13, 8, 0, 0, DateTimeKind.Utc), + LastModified = new DateTime(2026, 10, 13, 9, 0, 0, DateTimeKind.Utc), + ExecutionCount = 7 + }; + + notebook.Cells.Add(new NotebookCellItem { Id = "intro", Type = CellType.Markdown, Source = "# Desktop fixture\nEvery kind of output.", IsMarkdownPreviewMode = true }); + + notebook.Cells.Add(new NotebookCellItem { Id = "text", Source = "Console.WriteLine(\"hi\");", OutputText = "hi\n", ExecutionCount = 1, ExecutionTimeText = "3 ms" }); + + notebook.Cells.Add(new NotebookCellItem { Id = "failed", Source = "throw new Exception(\"boom\");", OutputText = "❌ boom\n", HasError = true, ExecutionCount = 2 }); + + var table = DumpTableBuilder.Create(new[] { new { Name = "Ada", Age = 36, Active = true }, new { Name = "Grace", Age = 45, Active = false } }, "People"); + notebook.Cells.Add(new NotebookCellItem + { + Id = "table", + Source = "people.Dump();", + OutputText = "before the table\n", + ExecutionCount = 3, + TableSnapshot = table.ToSnapshot(), + HtmlContent = InteractiveTableHtmlRenderer.Render(table) + }); + + var inspector = ObjectInspectorBuilder.Build(new { Name = "Code", Numbers = new[] { 1, 2, 3 }, Inner = new { X = 1, Y = "two" } }); + notebook.Cells.Add(new NotebookCellItem + { + Id = "inspector", + Source = "obj", + ExecutionCount = 4, + InspectorSnapshot = inspector.ToSnapshot(), + HtmlContent = InteractiveTableHtmlRenderer.RenderInspector(inspector) + }); + + var chart = ChartSpecBuilder.FromSeries(ChartType.Line, new[] { ("Sales", (IEnumerable)new[] { 1.0, 3.0, 2.0 }) }); + chart.Title = "Sales"; + Assert.True(VisualSvgRenderer.TryRender(chart, out var chartHtml)); + notebook.Cells.Add(new NotebookCellItem + { + Id = "chart", + Source = "Display.LineChart(sales);", + ExecutionCount = 5, + Visuals = [VisualOutputSnapshot.From(VisualOutput.Create(chart, "fixture-chart"))], + HtmlContent = chartHtml + }); + + notebook.Cells.Add(new NotebookCellItem + { + Id = "image", + Source = "Display.Image(png);", + ExecutionCount = 6, + ImageBytes = Png, + ImageFormat = "PNG", + ImageWidth = 1, + ImageHeight = 1, + HtmlContent = "
" + }); + + notebook.Cells.Add(new NotebookCellItem + { + Id = "collapsed", + Source = "print('python')", + Language = "python", + OutputText = "python\n", + ExecutionCount = 7, + ExecutionTimeText = "12 ms", + IsInputCollapsed = true, + IsOutputCollapsed = true, + IsOutputScrolled = true, + HadInteractiveControl = true + }); + + return notebook; + } + + // The pages the desktop draws a table or chart with carry a new random element id each time: compare everything else. + private static JsonNode WithoutPages(string notebook) + { + var parsed = JsonNode.Parse(notebook)!.AsObject(); + foreach (var cell in parsed["Cells"]!.AsArray()) cell!.AsObject().Remove("HtmlContent"); + return parsed; + } + + [Fact] + public void TheDesktopFixture_IsWhatTheDesktopWritesNow() + { + var path = Path.Combine(FixturesFolder, "desktop.frynb"); + var written = NotebookFileFormat.WriteFrynb(DesktopNotebook()); + + if (Environment.GetEnvironmentVariable("FRY_UPDATE_FIXTURES") == "1") + { + Directory.CreateDirectory(FixturesFolder); + File.WriteAllText(path, written); + } + + Assert.True(File.Exists(path), $"{path} is missing: run this test with FRY_UPDATE_FIXTURES=1."); + Assert.True(JsonNode.DeepEquals(WithoutPages(written), WithoutPages(File.ReadAllText(path))), + "The desktop now writes a different notebook than vscode-frysharp/test/fixtures/desktop.frynb: run this test with FRY_UPDATE_FIXTURES=1, then the extension's tests."); + } + + [Fact] + public void ADesktopNotebook_SavedByTheExtensionWithoutChanges_IsTheSameNotebook() + { + var path = Path.Combine(FixturesFolder, "written", "desktop.frynb"); + Assert.True(File.Exists(path), $"{path} is missing: run the extension's tests (npm test in vscode-frysharp)."); + + var original = JsonNode.Parse(File.ReadAllText(Path.Combine(FixturesFolder, "desktop.frynb")))!.AsObject(); + var returned = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + original.Remove("LastModified"); // the save stamps it + returned.Remove("LastModified"); + + Assert.True(JsonNode.DeepEquals(original, returned), "The extension changed the notebook it read and wrote back."); + + // and the desktop reads it + var read = NotebookFileFormat.ReadFrynb(File.ReadAllText(path)); + Assert.Equal(DesktopNotebook().Cells.Select(c => c.Id), read.Cells.Select(c => c.Id)); + Assert.Equal(2, read.Cells.Single(c => c.Id == "table").TableSnapshot!.Rows.Count); + Assert.Equal("Sales", Assert.Single(read.Cells.Single(c => c.Id == "chart").Visuals!).Title); + } + + [Fact] + public void WhatTheExtensionSavesAfterARun_ReadsInTheDesktopWithItsTableChartInspectorAndImage() + { + var path = Path.Combine(FixturesFolder, "written", "after-a-run.frynb"); + Assert.True(File.Exists(path), $"{path} is missing: run the extension's tests (npm test in vscode-frysharp)."); + + var notebook = NotebookFileFormat.ReadFrynb(File.ReadAllText(path)); + + var cell = notebook.Cells.Single(c => c.Id == "run"); + Assert.Equal("Result:\n", cell.OutputText); + Assert.Equal(3, cell.ExecutionCount); + Assert.Equal("12 ms", cell.ExecutionTimeText); + Assert.False(cell.HasError); + + var table = cell.TableSnapshot!; + Assert.Equal("People", table.Title); + Assert.Equal(["Name", "Age"], table.Columns.Select(c => c.Header)); + Assert.Equal([false, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal("Ada", table.Rows[0].Cells[0].DisplayText); + Assert.True(table.Rows[1].Cells[1].IsNull); + + var inspector = cell.InspectorSnapshot!; + Assert.Equal("People", inspector.HeaderTitle); + Assert.Equal("Code", inspector.Properties[0].SimpleValueText); + Assert.True(inspector.Properties[1].ChildNode!.IsCircularReference); + + var visual = Assert.Single(cell.Visuals!); + Assert.Equal("application/vnd.fry.chart.v1+json", visual.MimeType); + Assert.Equal("Sales", visual.Title); + Assert.Equal(JsonValueKind.Object, visual.Data!.Value.ValueKind); + + Assert.Equal(Png, cell.ImageBytes); + Assert.Equal("PNG", cell.ImageFormat); + Assert.Equal(1, cell.ImageWidth); + + // the desktop keeps one page for a cell, the last output's (the extension also saves the whole list, which the desktop ignores) + Assert.Contains(" c.Id == "failed"); + Assert.True(failed.HasError); + Assert.Equal("boom\n", failed.OutputText); + } +} diff --git a/Tests.Core/FSharpEngineTests.cs b/Tests.Core/FSharpEngineTests.cs new file mode 100644 index 00000000..1e288131 --- /dev/null +++ b/Tests.Core/FSharpEngineTests.cs @@ -0,0 +1,396 @@ +using System.Diagnostics; +using CSharpEditorPlugin.Tests; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// F# cells run by the real dotnet fsi, through the engine a headless host has (no Avalonia). +public class FSharpEngineTests : IDisposable +{ + private readonly TempFolder _folder = new(); + private readonly INotebookKernel _kernel; + + public FSharpEngineTests() + { + var engine = new FSharpEngine(LanguageEngineServices.Create(_folder.Path)); + _kernel = engine.NotebookKernels.Create(new KernelCreationContext(() => _folder.Path)); + } + + public void Dispose() + { + _kernel.Dispose(); + _folder.Dispose(); + } + + private sealed record Ran(KernelExecutionResult Result, List Text, List Rich) + { + public string Console => string.Concat(Text); + } + + private async Task RunAsync(string code, Func? input = null, CancellationToken ct = default) + { + var text = new List(); + var rich = new List(); + var result = await _kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = code, + OnConsole = text.Add, + OnRichOutput = rich.Add, + OnInputRequest = input is null ? null : (prompt, password, _) => Task.FromResult(input(prompt, password)) + }, ct); + return new Ran(result, text, rich); + } + + private async Task MustRunAsync(string code) + { + var ran = await RunAsync(code); + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + return ran; + } + + [RealFSharpFact] + public async Task WhatACellDefines_IsThereForTheNextCell() + { + await MustRunAsync("let x = 5\nlet add a b = a + b\ntype Person = { Name: string; Age: int }"); + + var ran = await MustRunAsync("let p = { Name = \"Ada\"; Age = add x 31 }\nprintfn \"%s is %d\" p.Name p.Age"); + + Assert.Contains("Ada is 36", ran.Console); + } + + [RealFSharpFact] + public async Task TheValueOfTheLastExpression_IsShown_AScalarAsText_AUnitNotAtAll() + { + Assert.Equal("6\n", (await MustRunAsync("let x = 5\nx + 1")).Console); + Assert.Equal("text\n", (await MustRunAsync("\"text\"")).Console); + Assert.Equal("2024-01-02 03:04:05\n", (await MustRunAsync("System.DateTime(2024, 1, 2, 3, 4, 5)")).Console); + Assert.Equal("", (await MustRunAsync("let y = 1")).Console); + Assert.Equal("hi\n", (await MustRunAsync("printfn \"hi\"")).Console); // unit: only what it printed + } + + [RealFSharpFact] + public async Task AShortListOfScalars_IsOneLine_AsInTheOtherLanguages() + { + Assert.Equal("[ 1, 2, 3 ]\n", (await MustRunAsync("[1; 2; 3]")).Console); + Assert.Equal("[ \"a\", \"b\" ]\n", (await MustRunAsync("[| \"a\"; \"b\" |]")).Console); + Assert.Equal("[ 1, 2, 3 ]\n", (await MustRunAsync("seq { 1 .. 3 }")).Console); + } + + [RealFSharpFact] + public async Task ARecordList_IsATable_ARecord_AnObjectExplorer() + { + await MustRunAsync("type Person = { Name: string; Age: int }\nlet people = [ { Name = \"Ada\"; Age = 36 }; { Name = \"Linus\"; Age = 55 } ]"); + + var list = await MustRunAsync("people"); + var table = Assert.Single(list.Rich).TableResult!; + Assert.Equal(["Name", "Age"], table.Columns.Select(c => c.Header)); + Assert.Equal([false, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal(["Ada", "Linus"], table.Rows.Select(r => r.Cells[0].DisplayText)); + + var one = Assert.Single((await MustRunAsync("people.Head")).Rich); + Assert.Equal(CellOutputKind.ObjectInspector, one.Kind); + Assert.Equal("Person", one.InspectorNode!.HeaderTitle); + Assert.Equal(["Name", "Age"], one.InspectorNode.Properties.Select(p => p.Name)); + Assert.Equal("Ada", one.InspectorNode.Properties[0].SimpleValueText); + } + + [RealFSharpFact] + public async Task ACollectionOfScalarsPastTheLine_AMap_AClass_AreObjectExplorers() + { + var map = Assert.Single((await MustRunAsync("Map.ofList [ \"a\", 1; \"b\", 2 ]")).Rich); + Assert.Equal(["a", "b"], map.InspectorNode!.Properties.Select(p => p.Name)); + + var longList = Assert.Single((await MustRunAsync("[ 1 .. 200 ]")).Rich); + Assert.Equal(CellOutputKind.ObjectInspector, longList.Kind); + + var instance = Assert.Single((await MustRunAsync("System.Text.StringBuilder(\"abc\")")).Rich); + Assert.Equal("StringBuilder", instance.InspectorNode!.HeaderTitle); + } + + [RealFSharpFact] + public async Task AnObjectThatPointsToItself_IsListedFourLevelsDown_LikeTheDesktopShowsIt() + { + var shown = Assert.Single((await MustRunAsync(""" + type People() = + member val Name = "" with get, set + member val Another : People = Unchecked.defaultof with get, set + let person = People() + person.Name <- "Prashant" + person.Another <- person + person + """)).Rich); + + InspectorChain.AssertFiveLevels(shown.InspectorNode!, "Another", ["Name", "Another"]); + } + + [RealFSharpFact] + public async Task AnInfiniteSequence_IsShownFromItsFirstItems_NotEnumeratedForever() + { + var shown = Assert.Single((await MustRunAsync("Seq.initInfinite id")).Rich); + + Assert.True(shown.InspectorNode!.Properties.Count <= 101); + } + + [RealFSharpFact] + public async Task OutputAndErrorOutput_AreTheCellsOwn_InTheOrderPrinted_WithUnicode() + { + var ran = await MustRunAsync("printfn \"héllo ✓ 日本\"\neprintfn \"to stderr\"\nprintfn \"after\""); + + Assert.Equal("héllo ✓ 日本\nto stderr\nafter\n", ran.Console); + } + + [RealFSharpFact] + public async Task ACompileError_IsMarkedOnTheCellsLinesAndColumns_AndTheCellFails() + { + var ran = await RunAsync("let a = 1\nlet b = a + \"x\"\nlet c = 3"); + + Assert.False(ran.Result.Success); + Assert.All(ran.Result.Diagnostics, d => Assert.Equal(2, d.Line)); // (the compiler says it twice: a type mismatch and its cause) + var first = ran.Result.Diagnostics[0]; + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, first.Severity); + Assert.Equal(13, first.Column); // the "x" + Assert.Equal(16, first.EndColumn); + Assert.StartsWith("FS", first.Id); + } + + [RealFSharpFact] + public async Task ASyntaxError_IsWhereTheCompilerFoundItWrong() + { + var ran = await RunAsync("let a = 1\nlet b = a +\nlet c = 3"); + + Assert.False(ran.Result.Success); + Assert.NotEmpty(ran.Result.Diagnostics); + Assert.All(ran.Result.Diagnostics, d => Assert.InRange(d.Line, 2, 3)); + } + + [RealFSharpFact] + public async Task AnUndefinedName_IsOnItsLine_WithTheCompilersWords() + { + var ran = await RunAsync("let a = 1\n\nlet b = a + nope\nb"); + + Assert.False(ran.Result.Success); + var error = Assert.Single(ran.Result.Diagnostics); + Assert.Equal(3, error.Line); + Assert.Equal(13, error.Column); + Assert.Equal("FS0039", error.Id); + Assert.Contains("nope", error.Message); + } + + [RealFSharpFact] + public async Task ARuntimeException_NamesTheKindAndTheLineOfTheCell_AndTheKernelGoesOn() + { + var ran = await RunAsync("let f x =\n if x > 1 then failwith \"boom\" else x\nf 5"); + + Assert.False(ran.Result.Success); + Assert.Equal("Exception", ran.Result.ErrorName); + Assert.Equal(3, ran.Result.ErrorLine); + Assert.Contains("boom", ran.Result.ErrorMessage); + Assert.Contains("at FSI", ran.Console); // the stack + Assert.DoesNotContain("System.Reflection", ran.Console); + + Assert.Equal("2\n", (await MustRunAsync("1 + 1")).Console); // same session + } + + [RealFSharpFact] + public async Task AnExpressionThatOnlyRaises_IsReportedAsTheException_NotAValueRestriction() + { + var ran = await RunAsync("failwith \"generic\""); + + Assert.False(ran.Result.Success); + Assert.Equal("Exception", ran.Result.ErrorName); + Assert.Contains("generic", ran.Result.ErrorMessage); + Assert.Empty(ran.Result.Diagnostics); + } + + [RealFSharpFact] + public async Task TheVariables_AreListedWithTheirTypes_IncludingFunctions() + { + // (a function written `let add a b = ...` is a method to FSI, not a value, and is not listed; a function value is) + await MustRunAsync("let x = 5\nlet names = [ \"a\"; \"b\" ]\nlet doubleAll = List.map (fun n -> n * 2)\nlet pair = (1, \"two\")\nlet add a b = a + b"); + + var variables = (await _kernel.GetVariablesAsync(CancellationToken.None)).ToDictionary(v => v.Name); + + Assert.Equal(["doubleAll", "names", "pair", "x"], variables.Keys.Order()); + Assert.Equal("int", variables["x"].TypeName); + Assert.Equal("string list", variables["names"].TypeName); + Assert.Equal("int * string", variables["pair"].TypeName); + Assert.Equal("Function", variables["doubleAll"].Kind); + } + + [RealFSharpFact] + public async Task AValueIsSharedOut_AsJson() + { + await MustRunAsync("type Person = { Name: string; Age: int }\nlet people = [ { Name = \"Ada\"; Age = 36 } ]\nlet n = 42\nlet maybe = Some \"x\"\nlet doubleAll = List.map (fun n -> n * 2)"); + + Assert.Equal("""[{"Name":"Ada","Age":36}]""", await _kernel.GetValueJsonAsync("people", CancellationToken.None)); + Assert.Equal("42", await _kernel.GetValueJsonAsync("n", CancellationToken.None)); + Assert.Equal("\"x\"", await _kernel.GetValueJsonAsync("maybe", CancellationToken.None)); + var function = await Assert.ThrowsAsync(() => _kernel.GetValueJsonAsync("doubleAll", CancellationToken.None)); + Assert.Contains("function", function.Message); + var missing = await Assert.ThrowsAsync(() => _kernel.GetValueJsonAsync("nope", CancellationToken.None)); + Assert.Contains("nope", missing.Message); + } + + [RealFSharpFact] + public async Task AValueIsSharedIn_AsAnOrdinaryFSharpValue() + { + await _kernel.SetValueFromJsonAsync("rows", """[{"name":"Ada","age":36},{"name":"Linus","age":55}]""", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("count", "42", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("ratio", "2.5", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("nums", "[1,2,3]", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("reals", "[1,2.5]", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("flag", "true", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("tricky", "\"it's \\\"quoted\\\"\\nnew line ✓ \\\\\"", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("odd name", "7", CancellationToken.None); + + var ran = await MustRunAsync( + "printfn \"%d %d %f %d %f %b\" rows.Length (count * 2) (ratio * 2.0) (Array.sum nums) (Array.sum reals) flag\n" + + "printfn \"%s\" tricky\n" + + "rows |> Array.map (fun r -> r.[\"name\"] :?> string) |> printfn \"%A\"\n" + + "printfn \"%d\" ``odd name``"); + + Assert.Contains("2 84 5.000000 6 3.500000 true", ran.Console); + Assert.Contains("it's \"quoted\"\nnew line ✓ \\", ran.Console); + Assert.Contains("[|\"Ada\"; \"Linus\"|]", ran.Console); + Assert.Contains("7", ran.Console); + } + + [RealFSharpFact] + public async Task ACellThatReadsALine_AsksTheNotebook_AndGetsTheAnswer() + { + var asked = new List(); + var ran = await RunAsync("printf \"Name? \"\nlet name = System.Console.ReadLine()\nprintfn \"Hello %s\" name", (prompt, _) => { asked.Add(prompt); return "Ada"; }); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage); + Assert.Single(asked); + Assert.Contains("Hello Ada", ran.Console); + Assert.Contains("Name? ", ran.Console); + } + + [RealFSharpFact] + public async Task TheEndOfInput_IsNull_NotAHang() + { + var ran = await RunAsync("let line = System.Console.ReadLine()\nprintfn \"%b\" (isNull line)", (_, _) => null); + + Assert.Contains("true", ran.Console); + } + + [RealFSharpFact] + public async Task ATableMarkdownAndAChart_AreDisplayedFromACell() + { + var ran = await MustRunAsync( + "Display.table [ {| A = 1; B = \"x\" |}; {| A = 2; B = \"y\" |} ]\n" + + "Display.markdown \"# Title\"\n" + + "let chart = Display.lineChart [ 1.0; 3.0; 2.0 ]\n" + + "printfn \"after\""); + + Assert.Equal(3, ran.Rich.Count); + Assert.Equal(["A", "B"], ran.Rich[0].TableResult!.Columns.Select(c => c.Header)); + Assert.Equal("# Title", ran.Rich[1].MarkdownSource); + Assert.NotNull(ran.Rich[2].Visual); + Assert.Contains("after", ran.Console); + } + + [RealFSharpFact] + public async Task AChartTheLastExpressionNames_IsNotShownTwice() + { + var ran = await MustRunAsync("let chart = Display.lineChart [ 1.0; 2.0 ]\nchart"); + + Assert.Single(ran.Rich); + } + + [RealFSharpFact] + public async Task ARestart_ForgetsEverything_AndTheNextCellStartsAFreshSession() + { + await MustRunAsync("let kept = 1"); + + _kernel.HardReset(); + var ran = await RunAsync("kept"); + + Assert.False(ran.Result.Success); + Assert.Equal("FS0039", Assert.Single(ran.Result.Diagnostics).Id); + Assert.Equal("3\n", (await MustRunAsync("1 + 2")).Console); + } + + [RealFSharpFact] + public async Task StopEndsACellThatWaits_AndWhatTheNotebookDefinedIsStillThere() + { + await MustRunAsync("let kept = 1"); + using var stop = new CancellationTokenSource(); + var clock = Stopwatch.StartNew(); + var running = RunAsync("while true do System.Threading.Thread.Sleep 10", ct: stop.Token); + + await Task.Delay(1500); + await stop.CancelAsync(); + var ran = await running; + + Assert.True(ran.Result.WasCancelled); + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(8), clock.Elapsed.ToString()); // the kernel stopped it itself + Assert.Equal("1\n", (await MustRunAsync("kept")).Console); + } + + [RealFSharpFact] + public async Task StopEndsACellThatOnlyComputes_BySayingGoodbyeToTheSession_ThenTheNextCellStartsAFreshOne() + { + await MustRunAsync("let lost = 1"); + using var stop = new CancellationTokenSource(); + var running = RunAsync("let mutable i = 0L\nwhile true do i <- i + 1L", ct: stop.Token); + + await Task.Delay(1500); + await stop.CancelAsync(); + var ran = await running; + + Assert.True(ran.Result.WasCancelled); + Assert.Contains("variables are gone", ran.Result.ErrorMessage); // the notebook says so + var next = await RunAsync("lost"); + Assert.False(next.Result.Success); + Assert.Equal("3\n", (await MustRunAsync("1 + 2")).Console); + } + + [RealFSharpNuGetFact] + public async Task APackageLineAtTheTopOfACell_LoadsThePackage_AsFsiDoes() + { + var ran = await RunAsync("#r \"nuget: Newtonsoft.Json, 13.0.3\"\nopen Newtonsoft.Json\nJsonConvert.SerializeObject([ 1; 2 ])"); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + Assert.Contains("[1,2]", ran.Console); + } + + [RealFSharpNuGetFact] + public async Task TheJupyterWayToSayIt_PercentNuget_IsTheSameLine() + { + var ran = await RunAsync("%nuget install Newtonsoft.Json 13.0.3\nNewtonsoft.Json.JsonConvert.SerializeObject({| A = 1 |})"); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + Assert.Contains("{\"A\":1}", ran.Console); + } + + [RealFSharpFact] + public async Task TheDirectiveLinesOfACell_AreForTheNotebook_NotForFSharp() + { + var ran = await MustRunAsync("\n\nlet a = 1\na + 1"); + + Assert.Equal("2\n", ran.Console); + } + + [Fact] + public void TheEngine_IsOneAHeadlessHostHas() + { + using var folder = new TempFolder(); + var languages = HeadlessLanguages.Create(LanguageEngineServices.Create(folder.Path)); + + var fsharp = languages.Get(LanguageIds.FSharp); + + Assert.NotNull(fsharp); + Assert.True(fsharp.Has(LanguageCapabilities.NotebookCells | LanguageCapabilities.ValueSharing)); + Assert.Same(fsharp, languages.FindSourceFileLanguage("script.fsx")); + Assert.Same(fsharp, languages.Get("fs")); + Assert.Contains("Values.fsx", FSharpDisplayRuntime.Files.Keys); + Assert.Contains("Display.fsx", FSharpDisplayRuntime.Files.Keys); + } +} diff --git a/Tests.Core/FrySharp.Core.Tests.csproj b/Tests.Core/FrySharp.Core.Tests.csproj index 312032f3..421b7452 100644 --- a/Tests.Core/FrySharp.Core.Tests.csproj +++ b/Tests.Core/FrySharp.Core.Tests.csproj @@ -14,4 +14,9 @@
+ + + + + diff --git a/Tests.Core/GoEngineTests.cs b/Tests.Core/GoEngineTests.cs new file mode 100644 index 00000000..8c9f5e9a --- /dev/null +++ b/Tests.Core/GoEngineTests.cs @@ -0,0 +1,370 @@ +using System.Text.Json; +using CSharpEditorPlugin.Tests; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Go; +using PdfEditorApp.Plugins.CSharpEditor.Services.Packages; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// One engine for the class: its build cache (the standard library and the fry package, built once) is shared; each test gets its own kernel, as each notebook does. +public sealed class GoFixture : IDisposable +{ + public GoFixture() + { + Folder = new TempFolder(); + Engine = new GoEngine(LanguageEngineServices.Create(Folder.Path)); + } + + public TempFolder Folder { get; } + + public GoEngine Engine { get; } + + public void Dispose() => Folder.Dispose(); +} + +/// Go cells run by the real toolchain, through the engine a headless host has (no Avalonia). +public class GoEngineTests(GoFixture fixture) : IClassFixture, IDisposable +{ + private readonly List _kernels = []; + + public void Dispose() + { + foreach (var kernel in _kernels) kernel.Dispose(); + } + + private INotebookKernel NewKernel() + { + var kernel = fixture.Engine.NotebookKernels.Create(new KernelCreationContext(() => fixture.Folder.Path)); + _kernels.Add(kernel); + return kernel; + } + + private sealed record Ran(KernelExecutionResult Result, List Text, List Rich) + { + public string Console => string.Concat(Text); + } + + private static async Task RunAsync(INotebookKernel kernel, string code, Func? input = null, CancellationToken ct = default) + { + var text = new List(); + var rich = new List(); + var result = await kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = code, + OnConsole = text.Add, + OnRichOutput = rich.Add, + OnInputRequest = input is null ? null : (prompt, password, _) => Task.FromResult(input(prompt, password)) + }, ct); + return new Ran(result, text, rich); + } + + private static async Task MustRunAsync(INotebookKernel kernel, string code) + { + var ran = await RunAsync(kernel, code); + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + return ran; + } + + [RealGoFact] + public async Task WhatACellMakes_IsThereForTheNextCell() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "type Person struct {\n\tName string\n\tAge int\n}\n\nfunc double(n int) int { return n * 2 }\n\nx := 21\nnames := []string{\"Ada\"}"); + + var ran = await MustRunAsync(kernel, "names = append(names, \"Bob\")\np := Person{Name: names[1], Age: 36}\nfmt.Printf(\"%s is %d, %d\\n\", p.Name, p.Age, double(x))"); + + Assert.Equal("Bob is 36, 42\n", ran.Console); + Assert.Equal("2\n", (await MustRunAsync(kernel, "len(names)")).Console); + Assert.Equal("\"Bob\"\n", (await MustRunAsync(kernel, "p.Name")).Console); + } + + [RealGoFact] + public async Task AVariableDeclaredAtTheTop_KeepsWhatLaterCellsDoToIt_AndAFunctionSeesIt() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "var counter = 10\n\nfunc bump() { counter++ }"); + await MustRunAsync(kernel, "bump()\ncounter += 5"); + + Assert.Equal("16\n", (await MustRunAsync(kernel, "counter")).Console); + } + + [RealGoFact] + public async Task ADeclarationWrittenAgain_TakesTheOldOnesPlace() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "func greet() string { return \"first\" }\nn := 1"); + + // the function replaced, and the variable made again with := (a block of its own lets it) + await MustRunAsync(kernel, "func greet() string { return \"second\" }\nn := 2"); + + Assert.Equal("\"second\"\n", (await MustRunAsync(kernel, "greet()")).Console); + Assert.Equal("2\n", (await MustRunAsync(kernel, "n")).Console); + } + + [RealGoFact] + public async Task TheLastExpression_IsText_ForAScalarAShortListAnEmptyListAndAnErrorlessCall() + { + var kernel = NewKernel(); + + Assert.Equal("42\n", (await MustRunAsync(kernel, "x := 6\nx * 7")).Console); + Assert.Equal("\"Ferris\"\n", (await MustRunAsync(kernel, "\"Ferris\"")).Console); + Assert.Equal("[ 1, 2, 3 ]\n", (await MustRunAsync(kernel, "[]int{1, 2, 3}")).Console); + Assert.Equal("[ ]\n", (await MustRunAsync(kernel, "[]int{}")).Console); + Assert.Equal("12\n", (await MustRunAsync(kernel, "strconv.Atoi(\"12\")")).Console); + Assert.Equal("1m30s\n", (await MustRunAsync(kernel, "90 * time.Second")).Console); + + // a statement that prints is not a value; a call that returns nothing is run as it is + var printed = await MustRunAsync(kernel, "fmt.Println(\"only this\")"); + Assert.Equal("only this\n", printed.Console); + Assert.Empty(printed.Rich); + await MustRunAsync(kernel, "func hello() { fmt.Println(\"hi\") }"); + Assert.Equal("hi\n", (await MustRunAsync(kernel, "hello()")).Console); + } + + [RealGoFact] + public async Task APointerBackToTheTop_IsListedFourLevelsDown_LikeTheDesktopShowsIt() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "type People struct {\n\tName string\n\tNumbers []int\n\tAnother *People\n}"); + + var shown = Assert.Single((await MustRunAsync(kernel, "person := &People{Name: \"Prashant\", Numbers: []int{1, 34, 45}}\nperson.Another = person\nperson")).Rich); + + InspectorChain.AssertFiveLevels(shown.InspectorNode!, "Another", ["Name", "Numbers", "Another"]); + } + + [RealGoFact] + public async Task AListOfStructs_IsATable_AStructAMapAndAPointer_AnObjectExplorerOrATable() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "type Point struct{ X, Y int }\ntype Person struct {\n\tName string\n\tTags []string\n\tHome Point\n\tBoss *Person\n}"); + + var table = Assert.Single((await MustRunAsync(kernel, "[]Point{{1, 2}, {3, 4}}")).Rich).TableResult!; + Assert.Equal(["X", "Y"], table.Columns.Select(c => c.Header)); + Assert.Equal([true, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal(["1", "3"], table.Rows.Select(r => r.Cells[0].DisplayText)); + + var person = Assert.Single((await MustRunAsync(kernel, "Person{Name: \"Ada\", Tags: []string{\"a\", \"b\"}, Home: Point{1, 2}}")).Rich).InspectorNode!; + Assert.Equal("Person", person.HeaderTitle); + Assert.Equal(["Name", "Tags", "Home", "Boss"], person.Properties.Select(p => p.Name)); + Assert.Equal("Ada", person.Properties[0].SimpleValueText); + Assert.Equal(["[0]", "[1]"], person.Properties[1].ChildNode!.Properties.Select(p => p.Name)); + Assert.Equal(["X", "Y"], person.Properties[2].ChildNode!.Properties.Select(p => p.Name)); + Assert.True(person.Properties[3].IsNull); + + Assert.Equal("Person", Assert.Single((await MustRunAsync(kernel, "&Person{Name: \"Bob\"}")).Rich).InspectorNode!.HeaderTitle); + + var map = Assert.Single((await MustRunAsync(kernel, "map[string]int{\"b\": 2, \"a\": 1}")).Rich).TableResult!; + Assert.Equal(["key", "value"], map.Columns.Select(c => c.Header)); + Assert.Equal(["a", "b"], map.Rows.Select(r => r.Cells[0].DisplayText)); + } + + [RealGoFact] + public async Task Markdown_AndAChart_AreOutputs() + { + var kernel = NewKernel(); + + Assert.Contains("# Title", Assert.Single((await MustRunAsync(kernel, "fry.Markdown(\"# Title\\n\\nsome **text**\")")).Rich).MarkdownSource); + Assert.Equal(CellOutputKind.Chart, Assert.Single((await MustRunAsync(kernel, "fry.LineChart([]float64{3, 1, 4}, fry.Title(\"Numbers\"))")).Rich).Kind); + } + + [RealGoFact] + public async Task WhatTheProgramPrints_ComesInTheOrderItWasPrinted_StderrAmongIt() + { + var ran = await MustRunAsync(NewKernel(), "fmt.Println(\"a\")\nprintln(\"to stderr\")\nfmt.Println()\nfmt.Println(\"héllo ✓ 日本\")"); + + Assert.Equal("a\nto stderr\n\nhéllo ✓ 日本\n", ran.Console); + } + + [RealGoFact] + public async Task ACompileError_IsOnTheCellsOwnLine_InTheDiagnosticsAndInTheText() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "func helper() int { return 1 }"); + + var ran = await RunAsync(kernel, "ok := helper()\n\nwrong := \"three\" + ok\nfmt.Println(ok, wrong)"); + + Assert.False(ran.Result.Success); + var error = Assert.Single(ran.Result.Diagnostics, d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error); + Assert.Equal(3, error.Line); + Assert.Contains("mismatched types", error.Message); + var shown = ran.Result.ConsoleOutput; + Assert.Contains("cell:3:", shown); + Assert.DoesNotContain("main.go", shown); + Assert.DoesNotContain("# cell", shown); + } + + [RealGoFact] + public async Task ACompileErrorInADeclaration_IsOnItsLineToo() + { + var ran = await RunAsync(NewKernel(), "func f() int {\n\treturn 1\n}\n\nvar s string = 5"); + + Assert.False(ran.Result.Success); + Assert.Equal(5, Assert.Single(ran.Result.Diagnostics, d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).Line); + Assert.Contains("cell:5:", ran.Result.ConsoleOutput); + } + + [RealGoFact] + public async Task APanic_NamesTheCellsLine_EndsTheCellAndLeavesTheNextOneWorking() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "kept := 7"); + + var ran = await RunAsync(kernel, "v := []int{}\nfmt.Println(\"before\")\nfmt.Println(v[3])"); + + Assert.False(ran.Result.Success); + Assert.Contains("before\n", ran.Console); + Assert.Contains("index out of range", ran.Console); + Assert.Contains("cell:3", ran.Console); + Assert.DoesNotContain("main.go", ran.Console); + Assert.Equal(3, Assert.Single(ran.Result.Diagnostics, d => d.Id == "PANIC").Line); + Assert.Contains("index out of range", ran.Result.ErrorMessage); + Assert.Equal("7\n", (await MustRunAsync(kernel, "kept")).Console); + } + + [RealGoFact] + public async Task FryInput_AsksTheNotebook_AndNoAnswerIsTheEndOfInput() + { + var kernel = NewKernel(); + var prompts = new List(); + var answers = new Queue(["Ada", null]); + + var ran = await RunAsync(kernel, "a, okA := fry.Input(\"Name? \")\nb, okB := fry.Input(\"Again? \")\nfmt.Println(a, okA, b == \"\", okB)", (prompt, password) => + { + prompts.Add(prompt + (password ? " (secret)" : string.Empty)); + return answers.Dequeue(); + }); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + Assert.Equal(["Name? ", "Again? "], prompts); + Assert.Equal("Ada true true false\n", ran.Console); + } + + [RealGoFact] + public async Task FryPassword_AsksForASecret_AndAPlainStdinRead_SeesTheEnd() + { + var secret = false; + var ran = await RunAsync(NewKernel(), "p, _ := fry.Password(\"Key: \")\nfmt.Println(len(p))", (_, password) => + { + secret = password; + return "hunter2"; + }); + Assert.True(secret); + Assert.Equal("7\n", ran.Console); + + var plain = await MustRunAsync(NewKernel(), "var line string\nn, err := fmt.Scanln(&line)\nfmt.Println(n, err)"); + Assert.Equal("0 EOF\n", plain.Console); + } + + [RealGoFact] + public async Task AValueIsSharedOut_AndAValueFromAnotherLanguageIsThereAsATypedVariable() + { + var kernel = NewKernel(); + + var shared = await MustRunAsync(kernel, "scores := []int{90, 85, 77}\nfry.Share(\"best\", 90)"); + Assert.DoesNotContain("__FRY_SHARE__", shared.Console); + Assert.Equal("90", await kernel.GetValueJsonAsync("best", CancellationToken.None)); + Assert.Equal("[90,85,77]", await kernel.GetValueJsonAsync("scores", CancellationToken.None)); // any variable that can be saved + + await kernel.SetValueFromJsonAsync("nums", "[1,2,3,4]", CancellationToken.None); + await kernel.SetValueFromJsonAsync("ratio", "0.5", CancellationToken.None); + await kernel.SetValueFromJsonAsync("config", "{\"name\":\"fry\",\"sizes\":[1,2.5],\"on\":true}", CancellationToken.None); + await kernel.SetValueFromJsonAsync("rows", "[{\"n\":1},{\"n\":2}]", CancellationToken.None); + var ran = await MustRunAsync(kernel, "sum := 0\nfor _, n := range nums {\n\tsum += n\n}\nfmt.Println(sum, ratio*4, config[\"name\"], len(rows), rows[1][\"n\"], scores[0])"); + Assert.Equal("10 2 fry 2 2 90\n", ran.Console); + + await Assert.ThrowsAsync(() => kernel.GetValueJsonAsync("nothing_like_it", CancellationToken.None)); + await Assert.ThrowsAsync(() => kernel.SetValueFromJsonAsync("func", "1", CancellationToken.None)); + } + + [RealGoFact] + public async Task AVariableThatCantBeKept_SaysSoOnce_AndSaysWhyWhenALaterCellNeedsIt() + { + var kernel = NewKernel(); + + var first = await MustRunAsync(kernel, "type Secret struct{ hidden int }\nh := Secret{hidden: 1}\nok := 5"); + Assert.Contains("note: h isn't kept for later cells", first.Console); + + var later = await RunAsync(kernel, "fmt.Println(h)"); + Assert.False(later.Result.Success); + Assert.Contains("h was made in an earlier cell", later.Result.ConsoleOutput); + Assert.Equal("5\n", (await MustRunAsync(kernel, "ok")).Console); + } + + [RealGoFact] + public async Task AnImportOfACell_IsThereForTheNext_AndOnlyWhatIsUsedIsImported() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "import m \"math\"\nimport \"os\"\nr := 1"); + + Assert.Equal("4\n", (await MustRunAsync(kernel, "m.Sqrt(16)")).Console); + } + + [RealGoFact] + public async Task TheKernelsVariables_AreWhatTheCellsMade_AndARestartForgetsThem() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "type T struct{ A int }\nfunc add(a, b int) int { return a + b }\nanswer := 42"); + + var variables = await kernel.GetVariablesAsync(CancellationToken.None); + Assert.Contains(variables, v => v.Name == "add" && v.Kind == "Function"); + Assert.Contains(variables, v => v.Name == "T" && v.Kind == "Type"); + Assert.Contains(variables, v => v.Name == "answer" && v.TypeName == "int" && v.ValueDisplay == "42"); + + kernel.HardReset(); + + Assert.Empty(await kernel.GetVariablesAsync(CancellationToken.None)); + Assert.False((await RunAsync(kernel, "answer")).Result.Success); + } + + [RealGoFact] + public async Task Stop_EndsTheRunningProgram_AndWhatTheCellsMadeBeforeIsKept() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "kept := 7"); + using var cts = new CancellationTokenSource(); + var started = false; + + var running = kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = "fmt.Println(\"started\")\nfor {\n\ttime.Sleep(50 * time.Millisecond)\n}", + OnConsole = text => { if (text.Contains("started")) started = true; } + }, cts.Token); + Assert.True(SpinWait.SpinUntil(() => started, TimeSpan.FromMinutes(2)), "the program never started"); + cts.Cancel(); + var stopped = await running; + + Assert.True(stopped.WasCancelled); + Assert.Equal("7\n", (await MustRunAsync(kernel, "kept")).Console); + } + + [RealGoFact] + public async Task AWholeProgram_IsRunAsItIs() + { + var ran = await MustRunAsync(NewKernel(), "package main\n\nfunc greet() string { return \"whole program\" }\n\nfunc main() {\n\tfmt.Println(greet())\n}"); + + Assert.Equal("whole program\n", ran.Console); + } + + [RealGoNetworkFact] + public async Task AModuleAddedWithGoGet_IsImportedByThisAndLaterCells() + { + var packages = (GoPackageManager)fixture.Engine.Packages; + var kernel = NewKernel(); + var toolchain = (await fixture.Engine.GoToolchain.ResolveAsync(new PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains.ToolchainQuery(), CancellationToken.None)).Toolchain!; + + Assert.True(packages.TryParseDirective("%go get github.com/google/uuid", out var command)); + var log = new List(); + var added = await packages.RunAsync(command, toolchain, log.Add); + Assert.True(added.Success, string.Concat(log)); + + var first = await MustRunAsync(kernel, "import \"github.com/google/uuid\"\nid := uuid.New().String()\nlen(id)"); + var next = await MustRunAsync(kernel, "len(uuid.NewString())"); + + Assert.Equal("36\n", first.Console); + Assert.Equal("36\n", next.Console); + } +} diff --git a/Tests.Core/InProcessCSharpRunnerTests.cs b/Tests.Core/InProcessCSharpRunnerTests.cs index 30c7d272..a1e7b972 100644 --- a/Tests.Core/InProcessCSharpRunnerTests.cs +++ b/Tests.Core/InProcessCSharpRunnerTests.cs @@ -85,9 +85,14 @@ public async Task Stop_EndsCodeThatChecksForCancellation_WithExitCode130() [Fact] public async Task CodeThatIgnoresStop_IsAbandonedAfterTheGracePeriod() { - using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + // Stop is pressed once the code is running (on a busy machine the first compile can take longer than any fixed wait, and a Stop + // that arrives before the code starts is a cancellation, not code that ignores it). + using var stop = new CancellationTokenSource(); var clock = System.Diagnostics.Stopwatch.StartNew(); - var (result, _, _) = await Headless.RunAsync("System.Threading.Thread.Sleep(60_000);", ct: stop.Token); + var (result, _, _) = await Headless.RunAsync("Console.WriteLine(\"started\");\nSystem.Threading.Thread.Sleep(60_000);", ct: stop.Token, observe: text => + { + if (text.Contains("started")) stop.Cancel(); + }); Assert.True(result.WasAbandoned); Assert.Equal(130, result.ExitCode); diff --git a/Tests.Core/InspectorBundleTests.cs b/Tests.Core/InspectorBundleTests.cs new file mode 100644 index 00000000..9b46465d --- /dev/null +++ b/Tests.Core/InspectorBundleTests.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// An object inspector sent by a kernel in another language reads back as the studio's own inspector. +public sealed class InspectorBundleTests +{ + // What the Java kernel sends for a People object whose "another" points back at itself. + private const string JavaPeople = """ + { + "application/vnd.fry.inspector+json": { + "title": "People", + "properties": [ + { "name": "name", "value": "Prashant", "isString": true }, + { "name": "numbers", "value": "[ 1, 34, 45 ]" }, + { "name": "age", "value": 30 }, + { "name": "nickname", "value": "", "isNull": true }, + { "name": "address", "child": { "title": "Address", "properties": [ { "name": "city", "value": "Zurich", "isString": true } ] } }, + { "name": "another", "child": { "title": "People", "circular": true, "properties": [] } } + ] + }, + "text/plain": "People@1b2c6ec2" + } + """; + + private static ObjectInspectorNode Inspect(string bundle) + { + using var document = JsonDocument.Parse(bundle); + var output = MimeOutputMapper.Map(document.RootElement, default); + Assert.Null(output.Text); + var rich = Assert.IsType(output.Rich); + Assert.Equal(CellOutputKind.ObjectInspector, rich.Kind); + return Assert.IsType(rich.InspectorNode); + } + + [Fact] + public void AKernelsInspector_IsMappedToTheObjectInspector_NotToItsTextFallback() + { + var node = Inspect(JavaPeople); + + Assert.Equal("People", node.HeaderTitle); + Assert.True(node.IsExpanded); + Assert.Equal(["name", "numbers", "age", "nickname", "address", "another"], node.Properties.Select(p => p.Name)); + + var name = node.Properties[0]; + Assert.Equal("Prashant", name.SimpleValueText); + Assert.True(name.IsString); + + Assert.Equal("[ 1, 34, 45 ]", node.Properties[1].SimpleValueText); + Assert.Equal("30", node.Properties[2].SimpleValueText); + Assert.True(node.Properties[3].IsNull); + } + + [Fact] + public void ANestedObject_IsAChildNode_AndACycleIsMarkedAndClosed() + { + var node = Inspect(JavaPeople); + + var address = node.Properties[4]; + Assert.True(address.IsComplexChild); + Assert.Equal("Address", address.ChildNode!.HeaderTitle); + Assert.True(address.ChildNode.IsExpanded); + Assert.Equal("city", address.ChildNode.Properties.Single().Name); + + var another = node.Properties[5]; + Assert.True(another.ChildNode!.IsCircularReference); + Assert.False(another.ChildNode.IsExpanded); + } + + [Fact] + public void ObjectsNestedDeeperThanTheLimit_KeepTheirTitleAndLoseTheirRows() + { + var json = "{\"title\":\"leaf\",\"properties\":[{\"name\":\"x\",\"value\":\"1\"}]}"; + for (var level = 0; level < InspectorBundle.MaxDepth + 2; level++) + { + json = "{\"title\":\"n" + level + "\",\"properties\":[{\"name\":\"c\",\"child\":" + json + "}]}"; + } + + var node = Inspect("{\"" + RichOutputMime.InspectorMime + "\":" + json + "}"); + + var depth = 0; + for (var current = node; current.Properties.Count > 0; current = current.Properties[0].ChildNode!) depth++; + Assert.Equal(InspectorBundle.MaxDepth, depth); + } + + [Fact] + public void AnInspectorTheStudioWrote_ReadsBackTheSame() + { + var people = new ObjectInspectorNode("People"); + people.Properties.Add(new ObjectInspectorPropertyRow { Name = "Name", SimpleValueText = "Code", IsString = true }); + people.Properties.Add(new ObjectInspectorPropertyRow + { + Name = "Another", + IsComplexChild = true, + ChildNode = new ObjectInspectorNode("People", isExpanded: false) { IsCircularReference = true } + }); + + var bundle = RichOutputMime.ToBundle(new RichCellOutput { Kind = CellOutputKind.ObjectInspector, InspectorNode = people }, out _); + var read = Inspect(bundle!.ToJsonString()); + + Assert.Equal("People", read.HeaderTitle); + Assert.Equal("Code", read.Properties[0].SimpleValueText); + Assert.True(read.Properties[0].IsString); + Assert.True(read.Properties[1].ChildNode!.IsCircularReference); + } + + [Fact] + public void ABundleWithNoInspector_StillFallsBackToText() + { + using var document = JsonDocument.Parse("""{ "text/plain": "People@1b2c6ec2" }"""); + + var output = MimeOutputMapper.Map(document.RootElement, default); + + Assert.Equal("People@1b2c6ec2\n", output.Text); + Assert.Null(output.Rich); + } +} diff --git a/Tests.Core/JavaEngineTests.cs b/Tests.Core/JavaEngineTests.cs new file mode 100644 index 00000000..dac516eb --- /dev/null +++ b/Tests.Core/JavaEngineTests.cs @@ -0,0 +1,297 @@ +using System.Diagnostics; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Java; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// Java cells run by the real JDK (a JShell kernel), through the engine a headless host has (no Avalonia). +public class JavaEngineTests : IDisposable +{ + private readonly TempFolder _folder = new(); + private readonly INotebookKernel _kernel; + private readonly JavaEngine _engine; + + public JavaEngineTests() + { + _engine = new JavaEngine(LanguageEngineServices.Create(_folder.Path)); + _kernel = _engine.NotebookKernels.Create(new KernelCreationContext(() => _folder.Path)); + } + + public void Dispose() + { + _kernel.Dispose(); + _folder.Dispose(); + } + + private sealed record Ran(KernelExecutionResult Result, List Text, List Rich) + { + public string Console => string.Concat(Text); + } + + private async Task RunAsync(string code, Func? input = null, CancellationToken ct = default) + { + var text = new List(); + var rich = new List(); + var result = await _kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = code, + OnConsole = text.Add, + OnRichOutput = rich.Add, + OnInputRequest = input is null ? null : (prompt, password, _) => Task.FromResult(input(prompt, password)) + }, ct); + return new Ran(result, text, rich); + } + + private async Task MustRunAsync(string code) + { + var ran = await RunAsync(code); + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + return ran; + } + + [RealJavaFact] + public async Task WhatACellDeclares_IsThereForTheNextCell() + { + await MustRunAsync("int x = 5;\nint twice(int n) { return n * 2; }\nrecord Person(String name, int age) {}"); + + var ran = await MustRunAsync("var p = new Person(\"Ada\", twice(x) + 26);\nSystem.out.println(p.name() + \" is \" + p.age());"); + + Assert.Contains("Ada is 36", ran.Console); + } + + [RealJavaFact] + public async Task AnExpressionsValue_IsShown_ADeclarationsIsNot() + { + await MustRunAsync("int kept = 7;"); + + Assert.Equal("", (await MustRunAsync("int y = 1;")).Console); + Assert.Equal("", (await MustRunAsync("kept = 9;")).Console); + Assert.Equal("10\n", (await MustRunAsync("kept + 1")).Console); + Assert.Equal("9\n", (await MustRunAsync("kept")).Console); + Assert.Equal("\"text\"\n", (await MustRunAsync("\"text\"")).Console); + } + + [RealJavaFact] + public async Task AShortListOfScalars_IsOneLine() + { + Assert.Equal("[ 1, 2, 3 ]\n", (await MustRunAsync("new int[]{1, 2, 3}")).Console); + Assert.Equal("[ a, b ]\n", (await MustRunAsync("List.of(\"a\", \"b\")")).Console); + } + + [RealJavaFact] + public async Task ARecordList_IsATable_ARecordAndABean_AnObjectExplorer() + { + await MustRunAsync("record Point(int x, int y) {}\npublic class Person { public String getName() { return \"Ada\"; } public boolean isAdmin() { return true; } }"); + + var table = Assert.Single((await MustRunAsync("List.of(new Point(1, 2), new Point(3, 4))")).Rich).TableResult!; + Assert.Equal(["x", "y"], table.Columns.Select(c => c.Header)); + Assert.Equal([true, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal(["1", "3"], table.Rows.Select(r => r.Cells[0].DisplayText)); + + var record = Assert.Single((await MustRunAsync("new Point(3, 4)")).Rich).InspectorNode!; + Assert.Equal("Point", record.HeaderTitle); + + var bean = Assert.Single((await MustRunAsync("new Person()")).Rich).InspectorNode!; + Assert.Equal(["admin", "name"], bean.Properties.Select(p => p.Name)); + } + + [RealJavaFact] + public async Task AListOfMaps_IsATable_Too() + { + var table = Assert.Single((await MustRunAsync("List.of(new java.util.LinkedHashMap<>(java.util.Map.of(\"name\", \"Ada\")))")).Rich).TableResult!; + + Assert.Equal(["name"], table.Columns.Select(c => c.Header)); + Assert.Equal("Ada", table.Rows[0].Cells[0].DisplayText); + } + + [RealJavaFact] + public async Task Output_KeepsBlankLines_ErrorOutputIsItsOwn_AndTheOrderIsThePrintedOrder() + { + var ran = await MustRunAsync("System.out.println(\"a\");\nSystem.out.println();\nSystem.err.println(\"to stderr\");\nSystem.out.println(\"héllo ✓ 日本\");"); + + Assert.Equal("a\n\nto stderr\nhéllo ✓ 日本\n", ran.Console); + } + + [RealJavaFact] + public async Task ALotOfOutput_ArrivesWhole() + { + var ran = await MustRunAsync("for (int i = 1; i <= 20000; i++) System.out.println(\"line \" + i);"); + + var lines = ran.Console.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(20000, lines.Length); + Assert.Equal("line 20000", lines[^1]); + } + + [RealJavaFact] + public async Task ACompileError_IsMarkedOnTheCellsLinesAndColumns() + { + var ran = await RunAsync("int a = 1;\n\n\n int b = a +;"); + + Assert.False(ran.Result.Success); + var error = Assert.Single(ran.Result.Diagnostics); + Assert.Equal(4, error.Line); + Assert.Equal(15, error.Column); // the ";" + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, error.Severity); + Assert.Contains("illegal start of expression", error.Message); + Assert.Equal("CompileError", ran.Result.ErrorName); + } + + [RealJavaFact] + public async Task ATypeError_IsOnItsLine_WhateverCameBeforeIt() + { + var ran = await RunAsync("int a = 1;\n\nString s = 5;\nint c = a;"); + + Assert.False(ran.Result.Success); + var error = Assert.Single(ran.Result.Diagnostics); + Assert.Equal(3, error.Line); + Assert.Contains("incompatible types", error.Message); + } + + [RealJavaFact] + public async Task ARuntimeError_NamesItsRealKind_AndTheLineOfTheCell() + { + var ran = await RunAsync("void f(int x) {\n if (x > 1) throw new IllegalStateException(\"boom\");\n}\n\nf(5);"); + + Assert.False(ran.Result.Success); + Assert.Equal("IllegalStateException", ran.Result.ErrorName); // not "EvalException" + Assert.Equal(2, ran.Result.ErrorLine); // inside f, where it was thrown + Assert.Contains("boom", ran.Result.ErrorMessage); + Assert.Contains("at f (line 2)", ran.Console); + Assert.Contains("at (line 5)", ran.Console); + + Assert.Equal("2\n", (await MustRunAsync("1 + 1")).Console); // the session goes on + } + + [RealJavaFact] + public async Task ABlankLineBeforeAnImport_DoesNotMoveTheLinesAfterIt() + { + var ran = await RunAsync("int a = 1;\n\n\nimport fry.*;\n\nString s = 5;"); + + Assert.Equal(6, Assert.Single(ran.Result.Diagnostics).Line); + } + + [RealJavaFact] + public async Task TheVariables_AreListedWithTheirTypes() + { + await MustRunAsync("int x = 5;\nList names = List.of(\"a\");"); + + var variables = (await _kernel.GetVariablesAsync(CancellationToken.None)).ToDictionary(v => v.Name); + + Assert.Equal("int", variables["x"].TypeName); + Assert.Equal("List", variables["names"].TypeName); + } + + [RealJavaFact] + public async Task AValueIsSharedOut_AsJson_AndInAsAJavaValue() + { + await MustRunAsync("record Person(String name, int age) {}\nvar people = List.of(new Person(\"Ada\", 36));\nint n = 42;"); + + Assert.Equal("42", await _kernel.GetValueJsonAsync("n", CancellationToken.None)); + Assert.Contains("Ada", await _kernel.GetValueJsonAsync("people", CancellationToken.None)); + await Assert.ThrowsAsync(() => _kernel.GetValueJsonAsync("nope", CancellationToken.None)); + + await _kernel.SetValueFromJsonAsync("rows", """[{"name":"Ada","age":36},{"name":"Linus","age":55}]""", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("count", "42", CancellationToken.None); + var ran = await MustRunAsync("System.out.println(rows.size() + \" \" + (count + 1));"); + Assert.Contains("2 43", ran.Console); + } + + [RealJavaFact] + public async Task ACellThatReadsALine_ShowsItsPromptFirst_ThenGetsTheAnswer() + { + var asked = new List(); + var ran = await RunAsync("System.out.print(\"Name? \");\nString name = new java.util.Scanner(System.in).nextLine();\nSystem.out.println(\"Hello \" + name);", (prompt, _) => { asked.Add(prompt); return "Ada"; }); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage); + Assert.Single(asked); + Assert.Equal("Name? Ada\nHello Ada\n", ran.Console); // the notebook echoes the answer after the prompt, as a terminal does + } + + [RealJavaFact] + public async Task ATableMarkdownAndAChart_AreDisplayedFromACell_InTheOrderMade() + { + var ran = await MustRunAsync( + "record P(String a, int b) {}\n" + + "Display.table(List.of(new P(\"x\", 1), new P(\"y\", 2)));\n" + + "System.out.println(\"between\");\n" + + "Display.markdown(\"# Title\");\n" + + "var chart = Display.lineChart(new double[]{1, 3, 2}, \"Chart\");"); + + Assert.Equal(["a", "b"], ran.Rich[0].TableResult!.Columns.Select(c => c.Header)); + Assert.Equal([false, true], ran.Rich[0].TableResult!.Columns.Select(c => c.IsNumeric)); + Assert.Equal("# Title", ran.Rich[1].MarkdownSource); + Assert.NotNull(ran.Rich[2].Visual); + Assert.Contains("between", ran.Console); + } + + [RealJavaFact] + public async Task StopEndsACellThatWaits_OrOnlyComputes_AndTheNotebooksValuesStay() + { + await MustRunAsync("int kept = 5;"); + + foreach (var loop in new[] { "while (true) { try { Thread.sleep(10); } catch (InterruptedException e) { } }", "long i = 0; while (true) { i++; }" }) + { + using var stop = new CancellationTokenSource(); + var clock = Stopwatch.StartNew(); + var running = RunAsync(loop, ct: stop.Token); + + await Task.Delay(1200); + await stop.CancelAsync(); + var ran = await running; + + Assert.True(ran.Result.WasCancelled, loop); + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(8), clock.Elapsed.ToString()); // the kernel stopped it itself + } + + Assert.Equal("6\n", (await MustRunAsync("kept + 1")).Console); + } + + [RealJavaFact] + public async Task ARestart_ForgetsEverything() + { + await MustRunAsync("int kept = 1;"); + + _kernel.HardReset(); + var ran = await RunAsync("kept"); + + Assert.False(ran.Result.Success); + Assert.Equal("3\n", (await MustRunAsync("1 + 2")).Console); + } + + [RealJavaNetworkFact] + public async Task AMavenPackage_RestoredForACell_IsOnTheClasspathFromTheNextCell() + { + var restored = await _engine.Packages.RunAsync(_engine.Packages.InstallCommand("com.google.code.gson:gson:2.11.0"), TestToolchain(), _ => { }); + Assert.True(restored.Success, restored.Message); + Assert.NotNull(restored.AddedSearchPath); + + await MustRunAsync("int warm = 1;"); // the kernel is running + await ((ISearchPathKernel)_kernel).AddSearchPathAsync(restored.AddedSearchPath!); + var ran = await MustRunAsync("new com.google.gson.Gson().toJson(List.of(1, 2))"); + + Assert.Contains("[1,2]", ran.Console); + } + + private static PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains.ToolchainInfo TestToolchain() => new() + { + LanguageId = "java", ExecutablePath = "java", Version = new Version(17, 0), DisplayName = "java", Source = "test" + }; + + [Fact] + public void TheEngine_IsOneAHeadlessHostHas() + { + using var folder = new TempFolder(); + var languages = HeadlessLanguages.Create(LanguageEngineServices.Create(folder.Path)); + + var java = languages.Get(LanguageIds.Java); + + Assert.NotNull(java); + Assert.True(java.Has(LanguageCapabilities.NotebookCells | LanguageCapabilities.ValueSharing)); + Assert.Same(java, languages.FindSourceFileLanguage("Main.java")); + Assert.Same(java, languages.Get("jdk")); + } +} diff --git a/Tests.Core/ProcessLauncherTests.cs b/Tests.Core/ProcessLauncherTests.cs new file mode 100644 index 00000000..ddade879 --- /dev/null +++ b/Tests.Core/ProcessLauncherTests.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; +using Xunit; + +namespace FrySharp.Core.Tests; + +public class ProcessLauncherTests +{ + [Fact] + public void AProgramsOutput_IsDelivered_EvenWhenEveryThreadPoolThreadIsBusy() + { + if (OperatingSystem.IsWindows()) return; // uses /bin/sh + + // A test run is full of blocked pool threads (tests that wait on tasks): the program's output must not depend on a pool + // thread being free to read it within the couple of seconds the launcher allows after the program ends. + ThreadPool.GetMinThreads(out var workers, out _); + using var release = new ManualResetEventSlim(); + var blockers = Enumerable.Range(0, workers * 3).Select(_ => Task.Run(() => release.Wait())).ToArray(); + try + { + var output = new ConcurrentQueue(); + var started = new ProcessLauncher().Start( + new ProcessStartSpec { FileName = "/bin/sh", Arguments = ["-c", "sleep 1; echo hello-from-a-program"], WorkingDirectory = Path.GetTempPath() }, + output.Enqueue, + output.Enqueue); + + // (waited for without a pool thread: the point of the test is that none is free) + Assert.True(SpinWait.SpinUntil(() => started.Completion.IsCompleted, TimeSpan.FromSeconds(90)), "the program did not complete"); + Assert.True(started.Completion.IsCompletedSuccessfully); + Assert.Contains("hello-from-a-program", string.Concat(output)); + } + finally + { + release.Set(); + SpinWait.SpinUntil(() => blockers.All(b => b.IsCompleted), TimeSpan.FromSeconds(30)); + } + } + + [Fact] + public async Task ACommandsOutput_IsReturned_EvenWhenEveryThreadPoolThreadIsBusy() + { + if (OperatingSystem.IsWindows()) return; // uses /bin/sh + + // HostEnvironment.RunAsync runs a compiler or a probe and gives its output a second after the program ends, so its reads must not + // wait for a free pool thread (a C++ compile whose messages came back empty, with no diagnostics, failed a full test run once). + // This guards that the output, the error output and the exit code all come back under pool pressure; the race itself depends on + // how fast the pool recovers, so it is not reproduced on demand. + ThreadPool.GetMinThreads(out var workers, out _); + using var release = new ManualResetEventSlim(); + var blockers = Enumerable.Range(0, workers * 3).Select(_ => Task.Run(() => release.Wait())).ToArray(); + try + { + var run = Task.Factory.StartNew( + () => new HostEnvironment().RunAsync("/bin/sh", ["-c", "sleep 1; echo hello-out; echo hello-err 1>&2; exit 3"], TimeSpan.FromSeconds(60)).GetAwaiter().GetResult(), + CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + Assert.True(SpinWait.SpinUntil(() => run.IsCompleted, TimeSpan.FromSeconds(90)), "the command did not complete"); + var result = await run; // (already complete: no thread is needed to resume) + Assert.Equal(3, result.ExitCode); + Assert.Contains("hello-out", result.StandardOutput); + Assert.Contains("hello-err", result.StandardError); + } + finally + { + release.Set(); + SpinWait.SpinUntil(() => blockers.All(b => b.IsCompleted), TimeSpan.FromSeconds(30)); + } + } +} diff --git a/Tests.Core/RealCppFactAttribute.cs b/Tests.Core/RealCppFactAttribute.cs new file mode 100644 index 00000000..8e63b309 --- /dev/null +++ b/Tests.Core/RealCppFactAttribute.cs @@ -0,0 +1,24 @@ +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// A test that runs a real C++ compiler (clang++ or g++, which builds each cell). Skipped on a machine without one, unless +/// FRY_REQUIRE_CPP=1, which makes a missing compiler a failure. +/// +public sealed class RealCppFactAttribute : FactAttribute +{ + public RealCppFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_REQUIRE_CPP") == "1") return; + if (!HasCompiler()) Skip = "Needs a C++ compiler (clang++ or g++) on the PATH (or set FRY_REQUIRE_CPP=1 to make this a failure)."; + } + + private static bool HasCompiler() + { + var names = OperatingSystem.IsWindows() ? new[] { "clang++.exe", "g++.exe", "cl.exe" } : new[] { "clang++", "g++" }; + var folders = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + folders.AddRange(["/usr/bin", "/usr/local/bin", "/opt/homebrew/bin", "/opt/homebrew/opt/llvm/bin"]); + return folders.Any(folder => names.Any(name => File.Exists(Path.Combine(folder, name)))); + } +} diff --git a/Tests.Core/RealFSharpFactAttribute.cs b/Tests.Core/RealFSharpFactAttribute.cs new file mode 100644 index 00000000..0df86887 --- /dev/null +++ b/Tests.Core/RealFSharpFactAttribute.cs @@ -0,0 +1,36 @@ +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// A test that runs the real F# Interactive (dotnet fsi). Skipped on a machine without dotnet on its PATH, +/// unless FRY_REQUIRE_FSHARP=1, which makes a missing one a failure. +/// +public sealed class RealFSharpFactAttribute : FactAttribute +{ + public RealFSharpFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_REQUIRE_FSHARP") == "1") return; + if (!OnPath("dotnet")) Skip = "Needs dotnet (the SDK, for dotnet fsi) on the PATH (or set FRY_REQUIRE_FSHARP=1 to make this a failure)."; + } + + private static bool OnPath(string program) + { + var names = OperatingSystem.IsWindows() ? new[] { program + ".exe" } : new[] { program }; + return (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Any(folder => names.Any(name => File.Exists(Path.Combine(folder, name)))); + } +} + +/// +/// A test that runs the real F# Interactive and restores a NuGet package, so it needs the network. Skipped unless +/// FRY_TEST_NETWORK=1 (a machine that is offline would fail it for no fault of the code). +/// +public sealed class RealFSharpNuGetFactAttribute : FactAttribute +{ + public RealFSharpNuGetFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_TEST_NETWORK") != "1") Skip = "Restores a NuGet package: needs the network (set FRY_TEST_NETWORK=1)."; + } +} diff --git a/Tests.Core/RealGoFactAttribute.cs b/Tests.Core/RealGoFactAttribute.cs new file mode 100644 index 00000000..a1b32909 --- /dev/null +++ b/Tests.Core/RealGoFactAttribute.cs @@ -0,0 +1,35 @@ +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// A test that runs the real Go toolchain (go, which builds each cell). Skipped on a machine without it, unless +/// FRY_REQUIRE_GO=1, which makes a missing toolchain a failure. +/// +public sealed class RealGoFactAttribute : FactAttribute +{ + public RealGoFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_REQUIRE_GO") == "1") return; + if (!HasGo()) Skip = "Needs the Go toolchain (go) on the PATH (or set FRY_REQUIRE_GO=1 to make this a failure)."; + } + + private static bool HasGo() + { + var name = OperatingSystem.IsWindows() ? "go.exe" : "go"; + var folders = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + folders.AddRange(["/usr/local/go/bin", "/opt/homebrew/bin", "/usr/lib/go/bin"]); + return folders.Any(folder => File.Exists(Path.Combine(folder, name))); + } +} + +/// A real-Go test that downloads a module: needs the network, so it is skipped unless FRY_TEST_NETWORK=1. +public sealed class RealGoNetworkFactAttribute : FactAttribute +{ + public RealGoNetworkFactAttribute() + { + var go = new RealGoFactAttribute(); + if (go.Skip is { } reason) { Skip = reason; return; } + if (Environment.GetEnvironmentVariable("FRY_TEST_NETWORK") != "1") Skip = "Downloads a module: needs the network (set FRY_TEST_NETWORK=1)."; + } +} diff --git a/Tests.Core/RealJavaFactAttribute.cs b/Tests.Core/RealJavaFactAttribute.cs new file mode 100644 index 00000000..779b786a --- /dev/null +++ b/Tests.Core/RealJavaFactAttribute.cs @@ -0,0 +1,35 @@ +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// A test that runs the real JDK (java and javac; the notebook kernel is JShell). Skipped on a machine without +/// them on its PATH, unless FRY_REQUIRE_JAVA=1, which makes a missing JDK a failure. +/// +public sealed class RealJavaFactAttribute : FactAttribute +{ + public RealJavaFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_REQUIRE_JAVA") == "1") return; + if (!OnPath("java") || !OnPath("javac")) Skip = "Needs a JDK (java and javac) on the PATH (or set FRY_REQUIRE_JAVA=1 to make this a failure)."; + } + + private static bool OnPath(string program) + { + var names = OperatingSystem.IsWindows() ? new[] { program + ".exe" } : new[] { program }; + return (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Any(folder => names.Any(name => File.Exists(Path.Combine(folder, name)))); + } +} + +/// A real-JDK test that downloads a Maven artifact: needs the network, so it is skipped unless FRY_TEST_NETWORK=1. +public sealed class RealJavaNetworkFactAttribute : FactAttribute +{ + public RealJavaNetworkFactAttribute() + { + var jdk = new RealJavaFactAttribute(); + if (jdk.Skip is { } reason) { Skip = reason; return; } + if (Environment.GetEnvironmentVariable("FRY_TEST_NETWORK") != "1") Skip = "Downloads a Maven artifact: needs the network (set FRY_TEST_NETWORK=1)."; + } +} diff --git a/Tests.Core/RealRustFactAttribute.cs b/Tests.Core/RealRustFactAttribute.cs new file mode 100644 index 00000000..43d77b99 --- /dev/null +++ b/Tests.Core/RealRustFactAttribute.cs @@ -0,0 +1,35 @@ +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// A test that runs the real Rust toolchain (cargo, which builds each cell with rustc). Skipped on a machine without it, unless +/// FRY_REQUIRE_RUST=1, which makes a missing toolchain a failure. +/// +public sealed class RealRustFactAttribute : FactAttribute +{ + public RealRustFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_REQUIRE_RUST") == "1") return; + if (!HasCargo()) Skip = "Needs the Rust toolchain (cargo) on the PATH or in ~/.cargo/bin (or set FRY_REQUIRE_RUST=1 to make this a failure)."; + } + + private static bool HasCargo() + { + var name = OperatingSystem.IsWindows() ? "cargo.exe" : "cargo"; + var folders = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + folders.Add(Path.Combine(Environment.GetEnvironmentVariable("CARGO_HOME") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cargo"), "bin")); + return folders.Any(folder => File.Exists(Path.Combine(folder, name))); + } +} + +/// A real-Rust test that downloads a crate: needs the network, so it is skipped unless FRY_TEST_NETWORK=1. +public sealed class RealRustNetworkFactAttribute : FactAttribute +{ + public RealRustNetworkFactAttribute() + { + var rust = new RealRustFactAttribute(); + if (rust.Skip is { } reason) { Skip = reason; return; } + if (Environment.GetEnvironmentVariable("FRY_TEST_NETWORK") != "1") Skip = "Downloads a crate: needs the network (set FRY_TEST_NETWORK=1)."; + } +} diff --git a/Tests.Core/RealSqliteFactAttribute.cs b/Tests.Core/RealSqliteFactAttribute.cs new file mode 100644 index 00000000..7bafe623 --- /dev/null +++ b/Tests.Core/RealSqliteFactAttribute.cs @@ -0,0 +1,24 @@ +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// A test that runs the real SQLite CLI. Skipped on a machine without sqlite3 on its PATH, unless +/// FRY_REQUIRE_SQL=1, which makes a missing one a failure. +/// +public sealed class RealSqliteFactAttribute : FactAttribute +{ + public RealSqliteFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_REQUIRE_SQL") == "1") return; + if (!OnPath("sqlite3")) Skip = "Needs sqlite3 on the PATH (or set FRY_REQUIRE_SQL=1 to make this a failure)."; + } + + private static bool OnPath(string program) + { + var names = OperatingSystem.IsWindows() ? new[] { program + ".exe" } : new[] { program }; + return (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Any(folder => names.Any(name => File.Exists(Path.Combine(folder, name)))); + } +} diff --git a/Tests.Core/RustEngineTests.cs b/Tests.Core/RustEngineTests.cs new file mode 100644 index 00000000..566043a0 --- /dev/null +++ b/Tests.Core/RustEngineTests.cs @@ -0,0 +1,351 @@ +using System.Text.Json; +using CSharpEditorPlugin.Tests; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Rust; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// +/// One engine for the class: its crate cache (the fry display crate, built once) is shared, each test gets its own kernel, as +/// each notebook does. Real cargo builds every cell. +/// +public sealed class RustFixture : IDisposable +{ + public RustFixture() + { + Folder = new TempFolder(); + Engine = new RustEngine(LanguageEngineServices.Create(Folder.Path)); + } + + public TempFolder Folder { get; } + + public RustEngine Engine { get; } + + public void Dispose() => Folder.Dispose(); +} + +/// Rust cells run by the real toolchain, through the engine a headless host has (no Avalonia). +public class RustEngineTests(RustFixture fixture) : IClassFixture, IDisposable +{ + private readonly List _kernels = []; + + public void Dispose() + { + foreach (var kernel in _kernels) kernel.Dispose(); + } + + private INotebookKernel NewKernel() + { + var kernel = fixture.Engine.NotebookKernels.Create(new KernelCreationContext(() => fixture.Folder.Path)); + _kernels.Add(kernel); + return kernel; + } + + private sealed record Ran(KernelExecutionResult Result, List Text, List Rich) + { + public string Console => string.Concat(Text); + } + + private static async Task RunAsync(INotebookKernel kernel, string code, Func? input = null, CancellationToken ct = default) + { + var text = new List(); + var rich = new List(); + var result = await kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = code, + OnConsole = text.Add, + OnRichOutput = rich.Add, + OnInputRequest = input is null ? null : (prompt, password, _) => Task.FromResult(input(prompt, password)) + }, ct); + return new Ran(result, text, rich); + } + + private static async Task MustRunAsync(INotebookKernel kernel, string code) + { + var ran = await RunAsync(kernel, code); + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + return ran; + } + + [RealRustFact] + public async Task WhatACellMakes_IsThereForTheNextCell() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "#[derive(Debug)]\nstruct Person { name: String, age: u32 }\nfn double(n: i32) -> i32 { n * 2 }\nlet x = 21;\nlet mut names = vec![\"Ada\".to_string()];"); + + var ran = await MustRunAsync(kernel, "names.push(\"Bob\".to_string());\nlet p = Person { name: names[1].clone(), age: 36 };\nprintln!(\"{} is {}, {}\", p.name, p.age, double(x));"); + + Assert.Equal("Bob is 36, 42\n", ran.Console); + Assert.Equal("2\n", (await MustRunAsync(kernel, "names.len()")).Console); + } + + [RealRustFact] + public async Task ShowingAVariableByItsName_DoesNotMoveItAwayFromTheNextCell() + { + var kernel = NewKernel(); + + var first = await MustRunAsync(kernel, "let words = vec![\"a\".to_string(), \"b\".to_string()];\nlet name = String::from(\"Ferris\");\nname"); + + Assert.Equal("\"Ferris\"\n", first.Console); + Assert.DoesNotContain("isn't kept", first.Console); + Assert.Equal("2 6\n", (await MustRunAsync(kernel, "println!(\"{} {}\", words.len(), name.len());")).Console); + } + + [RealRustFact] + public async Task TheLastExpression_IsText_ForAScalarAShortListAndAnEmptyList() + { + var kernel = NewKernel(); + + Assert.Equal("42\n", (await MustRunAsync(kernel, "let x = 6;\nx * 7")).Console); + Assert.Equal("\"Ferris\"\n", (await MustRunAsync(kernel, "String::from(\"Ferris\")")).Console); + Assert.Equal("Some(3)\n", (await MustRunAsync(kernel, "Some(3)")).Console); + Assert.Equal("[ 1, 2, 3 ]\n", (await MustRunAsync(kernel, "vec![1, 2, 3]")).Console); + Assert.Equal("[ ]\n", (await MustRunAsync(kernel, "Vec::::new()")).Console); + + var nothing = await MustRunAsync(kernel, "println!(\"only this\")"); + Assert.Equal("only this\n", nothing.Console); + Assert.Empty(nothing.Rich); + } + + [RealRustFact] + public async Task ADeepValue_IsListedFourLevelsDown_AndTheFifthLevelIsATitle() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "#[derive(Debug)]\nstruct E { v: i32 }\n#[derive(Debug)]\nstruct D { v: i32, next: E }\n#[derive(Debug)]\nstruct C { v: i32, next: D }\n#[derive(Debug)]\nstruct B { v: i32, next: C }\n#[derive(Debug)]\nstruct A { v: i32, next: B }"); + + var shown = Assert.Single((await MustRunAsync(kernel, "A { v: 1, next: B { v: 2, next: C { v: 3, next: D { v: 4, next: E { v: 5 } } } } }")).Rich); + + InspectorChain.AssertFiveLevels(shown.InspectorNode!, "next", ["v", "next"], cycle: false); + } + + [RealRustFact] + public async Task AListOfStructs_IsATable_AStructATupleAndAnEnumWithData_AnObjectExplorer() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "#[derive(Debug)]\nstruct Point { x: i32, y: i32 }\n#[derive(Debug)]\nstruct Person { name: String, tags: Vec, home: Point }\n#[derive(Debug)]\nenum Shape { Circle(f64), Rect { w: f64, h: f64 }, Unit }"); + + var table = Assert.Single((await MustRunAsync(kernel, "vec![Point { x: 1, y: 2 }, Point { x: 3, y: 4 }]")).Rich).TableResult!; + Assert.Equal(["x", "y"], table.Columns.Select(c => c.Header)); + Assert.Equal([true, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal(["1", "3"], table.Rows.Select(r => r.Cells[0].DisplayText)); + + var person = Assert.Single((await MustRunAsync(kernel, "Person { name: \"Ada\".into(), tags: vec![\"a\".into(), \"b\".into()], home: Point { x: 1, y: 2 } }")).Rich).InspectorNode!; + Assert.Equal("Person", person.HeaderTitle); + Assert.Equal(["name", "tags", "home"], person.Properties.Select(p => p.Name)); + Assert.Equal("Ada", person.Properties[0].SimpleValueText); + Assert.Equal(["[0]", "[1]"], person.Properties[1].ChildNode!.Properties.Select(p => p.Name)); + Assert.Equal(["x", "y"], person.Properties[2].ChildNode!.Properties.Select(p => p.Name)); + + Assert.Equal("Rect", Assert.Single((await MustRunAsync(kernel, "Shape::Rect { w: 1.0, h: 2.0 }")).Rich).InspectorNode!.HeaderTitle); + Assert.Equal("Tuple", Assert.Single((await MustRunAsync(kernel, "(1, \"two\", 3.0)")).Rich).InspectorNode!.HeaderTitle); + + // A variant with nothing in it is a name, as a number is: text. + var unit = await MustRunAsync(kernel, "Shape::Unit"); + Assert.Equal("Unit\n", unit.Console); + Assert.Empty(unit.Rich); + } + + [RealRustFact] + public async Task Markdown_IsAMarkdownOutput() + { + var ran = await MustRunAsync(NewKernel(), "fry::markdown(\"# Title\\n\\nsome **text**\");"); + + Assert.Contains("# Title", Assert.Single(ran.Rich).MarkdownSource); + } + + [RealRustFact] + public async Task AChart_IsAChartOutput() + { + var ran = await MustRunAsync(NewKernel(), "fry::line_chart(&vec![3.0, 1.0, 4.0]).title(\"Numbers\").show();"); + + Assert.Equal(CellOutputKind.Chart, Assert.Single(ran.Rich).Kind); + } + + [RealRustFact] + public async Task WhatTheProgramPrints_ComesInTheOrderItWasPrinted_StderrAmongIt() + { + var ran = await MustRunAsync(NewKernel(), "println!(\"a\");\neprintln!(\"to stderr\");\nprintln!();\nprintln!(\"héllo ✓ 日本\");"); + + Assert.Equal("a\nto stderr\n\nhéllo ✓ 日本\n", ran.Console); + } + + [RealRustFact] + public async Task ACompileError_IsOnTheCellsOwnLine_InTheDiagnosticsAndInTheText() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "fn helper() -> i32 { 1 }"); + + var ran = await RunAsync(kernel, "let ok = helper();\n\nlet wrong: i32 = \"three\";\nprintln!(\"{} {}\", ok, wrong);"); + + Assert.False(ran.Result.Success); + var error = Assert.Single(ran.Result.Diagnostics, d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error); + Assert.Equal("E0308", error.Id); + Assert.Equal(3, error.Line); + Assert.Equal(18, error.Column); + var shown = ran.Result.ConsoleOutput; + Assert.Contains("--> cell:3:18", shown); + Assert.Contains("3 | let wrong: i32", shown); + Assert.DoesNotContain("cell.rs", shown); + Assert.DoesNotContain("cell_", shown); + } + + [RealRustFact] + public async Task APanic_NamesTheCellsLine_EndsTheCellAndLeavesTheNextOneWorking() + { + var kernel = NewKernel(); + + var ran = await RunAsync(kernel, "let v: Vec = Vec::new();\nprintln!(\"before\");\nprintln!(\"{}\", v[3]);"); + + Assert.False(ran.Result.Success); + Assert.Contains("before\n", ran.Console); + Assert.Contains("index out of bounds", ran.Console); + Assert.Contains("panicked at cell:3:", ran.Console); + Assert.DoesNotContain("cell.rs", ran.Console); + Assert.Contains(ran.Result.Diagnostics, d => d.Line == 3); + Assert.Equal("still here\n", (await MustRunAsync(kernel, "println!(\"still here\");")).Console); + } + + [RealRustFact] + public async Task AnErrorFromAnEarlierCellsCode_SaysWhereItIs() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "fn fails() { let v: Vec = Vec::new(); println!(\"{}\", v[1]); }"); + + var ran = await RunAsync(kernel, "fails();"); + + Assert.False(ran.Result.Success); + Assert.Contains("panicked at", ran.Console); + Assert.DoesNotContain("cell.rs", ran.Console); + } + + [RealRustFact] + public async Task FryInput_AsksTheNotebook_AndNoAnswerIsTheEndOfInput() + { + var kernel = NewKernel(); + var prompts = new List(); + var answers = new Queue(["Ada", null]); + + var ran = await RunAsync(kernel, "let a = fry::input(\"Name? \");\nlet b = fry::input(\"Again? \");\nprintln!(\"{:?} {:?}\", a, b);", (prompt, password) => + { + prompts.Add(prompt + (password ? " (secret)" : string.Empty)); + return answers.Dequeue(); + }); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Console); + Assert.Equal(["Name? ", "Again? "], prompts); + Assert.Equal("Some(\"Ada\") None\n", ran.Console); + } + + [RealRustFact] + public async Task FryPassword_AsksForASecret() + { + var secret = false; + + var ran = await RunAsync(NewKernel(), "let p = fry::password(\"Key: \").unwrap();\nprintln!(\"{}\", p.len());", (_, password) => + { + secret = password; + return "hunter2"; + }); + + Assert.True(secret); + Assert.Equal("7\n", ran.Console); + } + + [RealRustFact] + public async Task APlainStdinRead_SeesTheEnd_InsteadOfWaitingForever() + { + var ran = await MustRunAsync(NewKernel(), "let mut line = String::new();\nlet n = std::io::stdin().read_line(&mut line)?;\nprintln!(\"read {} bytes\", n);"); + + Assert.Equal("read 0 bytes\n", ran.Console); + } + + [RealRustFact] + public async Task AValueIsSharedOut_AndAValueFromAnotherLanguageIsThereAsAVariable() + { + var kernel = NewKernel(); + + var shared = await MustRunAsync(kernel, "let scores = vec![90, 85, 77];\nfry::share!(scores);"); + Assert.DoesNotContain("__FRY_SHARE__", shared.Console); + Assert.Equal("[90,85,77]", await kernel.GetValueJsonAsync("scores", CancellationToken.None)); + + await kernel.SetValueFromJsonAsync("config", "{\"name\":\"fry\",\"sizes\":[1,2.5],\"on\":true}", CancellationToken.None); + await kernel.SetValueFromJsonAsync("nums", "[1,2,3,4]", CancellationToken.None); + var ran = await MustRunAsync(kernel, "println!(\"{} {} {}\", config[\"name\"].as_str().unwrap(), nums.iter().sum::(), scores.iter().sum::());"); + Assert.Equal("fry 10 252\n", ran.Console); + + await Assert.ThrowsAsync(() => kernel.GetValueJsonAsync("nothing_like_it", CancellationToken.None)); + } + + [RealRustFact] + public async Task AStructShared_ArrivesAsAnObject() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "#[derive(Debug)]\nstruct Point { x: i32, y: i32 }\nlet p = Point { x: 1, y: 2 };\nfry::share!(p);"); + + using var json = JsonDocument.Parse(await kernel.GetValueJsonAsync("p", CancellationToken.None)); + + Assert.Equal(1, json.RootElement.GetProperty("x").GetInt32()); + Assert.Equal(2, json.RootElement.GetProperty("y").GetInt32()); + } + + [RealRustFact] + public async Task TheKernelsVariables_AreWhatTheCellsMade_AndARestartForgetsThem() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "fn add(a: i32, b: i32) -> i32 { a + b }\nlet answer = 42;"); + + var variables = await kernel.GetVariablesAsync(CancellationToken.None); + Assert.Contains(variables, v => v.Name == "add" && v.Kind == "Function"); + Assert.Contains(variables, v => v.Name == "answer" && v.TypeName == "i32" && v.ValueDisplay == "42i32"); + + kernel.HardReset(); + + Assert.Empty(await kernel.GetVariablesAsync(CancellationToken.None)); + Assert.False((await RunAsync(kernel, "answer")).Result.Success); + } + + [RealRustFact] + public async Task Stop_EndsTheRunningProgram_AndWhatTheCellsMadeBeforeIsKept() + { + var kernel = NewKernel(); + await MustRunAsync(kernel, "let kept = 7;"); + using var cts = new CancellationTokenSource(); + var started = false; + + var running = kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = "println!(\"started\");\nloop { std::thread::sleep(std::time::Duration::from_millis(50)); }", + OnConsole = text => { if (text.Contains("started")) started = true; } + }, cts.Token); + Assert.True(SpinWait.SpinUntil(() => started, TimeSpan.FromMinutes(2)), "the program never started"); + cts.Cancel(); + var stopped = await running; + + Assert.True(stopped.WasCancelled); + Assert.Equal("7\n", (await MustRunAsync(kernel, "kept")).Console); + } + + [RealRustFact] + public async Task AWholeProgram_IsRunAsItIs() + { + var ran = await MustRunAsync(NewKernel(), "fn greet() -> &'static str { \"whole program\" }\n\nfn main() {\n println!(\"{}\", greet());\n}"); + + Assert.Equal("whole program\n", ran.Console); + } + + [RealRustNetworkFact] + public async Task ACrateNamedInAComment_IsFetched_AndUsedByThisAndLaterCells() + { + var kernel = NewKernel(); + + var first = await MustRunAsync(kernel, "// #crate: itoa = \"1\"\nlet mut buffer = itoa::Buffer::new();\nprintln!(\"{}\", buffer.format(12345));"); + var next = await MustRunAsync(kernel, "let mut buffer = itoa::Buffer::new();\nprintln!(\"{}\", buffer.format(678));"); + + Assert.Equal("12345\n", first.Console); + Assert.Equal("678\n", next.Console); + } +} diff --git a/Tests.Core/SqlCompletionsTests.cs b/Tests.Core/SqlCompletionsTests.cs new file mode 100644 index 00000000..3d225525 --- /dev/null +++ b/Tests.Core/SqlCompletionsTests.cs @@ -0,0 +1,66 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// SQL completion and hover are text in, items out (no sqlite3 needed). +public class SqlCompletionsTests +{ + private static string[] Labels(string code, string marker) => + SqlCompletions.Get(code, code.IndexOf(marker, StringComparison.Ordinal) + marker.Length).Select(i => i.DisplayText).ToArray(); + + [Fact] + public void AfterTheDot_ThePrefixIsOfTheNameAfterIt() + { + Assert.Equal([".tables"], Labels(".ta", ".ta")); + Assert.Contains(".schema", Labels(".", ".")); + Assert.Contains(".mode", Labels(".m", ".m")); + Assert.Empty(Labels(".zz", ".zz")); + } + + [Fact] + public void AKeywordIsOfferedByItsFirstLetters_AnyCase() + { + Assert.Contains("SELECT", Labels("sel", "sel")); + Assert.Contains("GROUP BY", Labels("gro", "gro")); + } + + [Fact] + public void TheTablesAndViewsTheTextCreates_AreOffered_EachOnce() + { + var code = "create table people (id integer);\ncreate table if not exists People (id integer);\ncreate view people_names as select 1;\nselect * from peo"; + + Assert.Equal(["people", "people_names"], Labels(code, "from peo").Where(l => l.StartsWith("peo", StringComparison.OrdinalIgnoreCase)).Order(StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public void NothingAtAnOffsetOutsideTheText() + { + Assert.Empty(SqlCompletions.Get("select", 99)); + Assert.Empty(SqlCompletions.Get(string.Empty, 0)); + } + + [Theory] + [InlineData("select name from people group by name", "group", "GROUP BY")] + [InlineData("select name from people group by name", "by", "GROUP BY")] + [InlineData("select name from people order by name", "order", "ORDER BY")] + [InlineData("insert into t values (1)", "insert", "INSERT INTO")] + [InlineData("create table t (x)", "table", "CREATE TABLE")] + [InlineData("select count(*) from t", "count", "COUNT")] + public void AHoverOnAKeyword_FindsTheClause_OnEitherWordOfIt(string code, string word, string entry) + { + var symbol = SqlQuickInfoProvider.ExtractSymbol(code, code.IndexOf(word, StringComparison.Ordinal) + 1); + + Assert.NotNull(symbol); + Assert.Equal(entry, symbol!.ToUpperInvariant()); + Assert.NotNull(SqlQuickInfoProvider.Lookup(symbol)); + } + + [Fact] + public void AWordThatIsNotKnown_HasNoHover() + { + var code = "select anything from somewhere"; + + Assert.Null(SqlQuickInfoProvider.Lookup(SqlQuickInfoProvider.ExtractSymbol(code, code.IndexOf("anything", StringComparison.Ordinal) + 1)!)); + } +} diff --git a/Tests.Core/SqlEngineTests.cs b/Tests.Core/SqlEngineTests.cs new file mode 100644 index 00000000..39eb5c0b --- /dev/null +++ b/Tests.Core/SqlEngineTests.cs @@ -0,0 +1,285 @@ +using System.Diagnostics; +using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; +using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; +using PdfEditorApp.Plugins.CSharpEditor.Services.Processes; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// SQL cells run by the real sqlite3, through the engine a headless host has (no Avalonia). +public class SqlEngineTests : IDisposable +{ + private readonly TempFolder _folder = new(); + private readonly INotebookKernel _kernel; + + public SqlEngineTests() + { + var engine = new SqlEngine(LanguageEngineServices.Create(_folder.Path)); + _kernel = engine.NotebookKernels.Create(new KernelCreationContext(() => _folder.Path)); + } + + public void Dispose() + { + _kernel.Dispose(); + _folder.Dispose(); + } + + /// What a cell said, in the order it said it: table:Title and text:.... + private sealed record Ran(KernelExecutionResult Result, List Said, List Tables) + { + public string Text => string.Concat(Said.Where(s => s.StartsWith("text:", StringComparison.Ordinal)).Select(s => s[5..])); + } + + private async Task RunAsync(string code, CancellationToken ct = default) + { + var said = new List(); + var tables = new List(); + var result = await _kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = code, + OnConsole = text => said.Add("text:" + text), + OnRichOutput = output => + { + Assert.Equal(CellOutputKind.Table, output.Kind); + said.Add("table:" + output.TableResult!.Title); + tables.Add(output.TableResult); + } + }, ct); + return new Ran(result, said, tables); + } + + [RealSqliteFact] + public async Task ASelect_IsATable_NotText() + { + var ran = await RunAsync(""" + create table people (id integer primary key, name text, age integer); + insert into people (name, age) values ('Ada', 36), ('Linus', 55); + select id, name, age from people order by id; + """); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage); + var table = Assert.Single(ran.Tables); + Assert.Equal(["id", "name", "age"], table.Columns.Select(c => c.Header)); + Assert.Equal([true, false, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal(["Ada", "Linus"], table.Rows.Select(r => r.Cells[1].DisplayText)); + Assert.Equal(string.Empty, ran.Text); + } + + [RealSqliteFact] + public async Task SeveralResultSets_AreNumbered_AndTheTextBetweenThemKeepsItsPlace() + { + var ran = await RunAsync(""" + select 1 as a; + .print middle + select 2 as b; + """); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage); + Assert.Equal(["table:Result 1", "text:middle\n", "table:Result 2"], ran.Said); + } + + [RealSqliteFact] + public async Task AStatementWithoutRows_ShowsNothing_AndSucceeds() + { + var ran = await RunAsync("create table empty (x); select * from empty;"); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage); + Assert.Empty(ran.Said); + } + + [RealSqliteFact] + public async Task WhatACellMade_IsThereForTheNextCell() + { + await RunAsync("create table kept (v integer); insert into kept values (100);"); + + var ran = await RunAsync("select v from kept;"); + + Assert.Equal("100", Assert.Single(Assert.Single(ran.Tables).Rows).Cells[0].DisplayText); + } + + [RealSqliteFact] + public async Task AnError_IsMarkedOnTheLineOfTheCell_AndTheCellFails() + { + var ran = await RunAsync("select 1 as ok;\n\nselect * from nosuch;\nselect 2 as later;"); + + Assert.False(ran.Result.Success); + var error = Assert.Single(ran.Result.Diagnostics); + Assert.Equal(3, error.Line); + Assert.Contains("no such table", error.Message); + Assert.Contains("no such table", ran.Text); // and it is said in the output too + Assert.Equal(2, ran.Tables.Count); // the statements around it ran + } + + [RealSqliteFact] + public async Task ADirectiveLine_KeepsItsNumber_SoAnErrorStillPointsAtTheRightLine() + { + var ran = await RunAsync("#!share --from csharp rows\nselect * from nosuch;"); + + Assert.False(ran.Result.Success); + Assert.Equal(2, Assert.Single(ran.Result.Diagnostics).Line); + } + + [RealSqliteFact] + public async Task AnError_IsOnTheCellsLine_EvenWhenTablesWereSharedInFrontOfIt() + { + await _kernel.SetValueFromJsonAsync("rows", """[{"a":1},{"a":2}]""", CancellationToken.None); + + var ran = await RunAsync("\n\nselect * from nosuch;"); + + Assert.False(ran.Result.Success); + Assert.Equal(3, Assert.Single(ran.Result.Diagnostics).Line); + } + + [RealSqliteFact] + public async Task ASharedList_BecomesATable_ASharedValueATableOfOneRow() + { + await _kernel.SetValueFromJsonAsync("rows", """[{"name":"Ada","age":36},{"name":"Linus","age":55}]""", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("answer", "42", CancellationToken.None); + await _kernel.SetValueFromJsonAsync("greeting", "\"it's\"", CancellationToken.None); + + var ran = await RunAsync("select * from rows; select value from answer; select value from greeting;"); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Text); + Assert.Equal(["Ada", "Linus"], ran.Tables[0].Rows.Select(r => r.Cells[0].DisplayText)); + Assert.Equal("42", ran.Tables[1].Rows[0].Cells[0].DisplayText); + Assert.Equal("it's", ran.Tables[2].Rows[0].Cells[0].DisplayText); + } + + [RealSqliteFact] + public async Task ANameWithAQuoteInIt_IsAnIdentifier_NotCode() + { + await _kernel.SetValueFromJsonAsync("we\"ird", "[1,2,3]", CancellationToken.None); + + var ran = await RunAsync("select count(*) as n from \"we\"\"ird\";"); + + Assert.True(ran.Result.Success, ran.Result.ErrorMessage + ran.Text); + Assert.Equal("3", ran.Tables.Single().Rows[0].Cells[0].DisplayText); + } + + [RealSqliteFact] + public async Task ATableCanBeSharedOut_AsJson_EvenWhenItIsEmpty() + { + var made = await RunAsync("create table colors (name text); insert into colors values ('red'), ('blue'); create table blank (x);"); + Assert.True(made.Result.Success, made.Result.ErrorMessage + made.Text); + + var json = await _kernel.GetValueJsonAsync("colors", CancellationToken.None); + var empty = await _kernel.GetValueJsonAsync("blank", CancellationToken.None); + + Assert.Contains("red", json); + Assert.Contains("blue", json); + Assert.Equal("[]", empty); + await Assert.ThrowsAsync(() => _kernel.GetValueJsonAsync("missing", CancellationToken.None)); + } + + [RealSqliteFact] + public async Task TheTablesAreTheVariables() + { + await RunAsync("create table one (x); create table two (y);"); + + var variables = await _kernel.GetVariablesAsync(CancellationToken.None); + + Assert.Equal(["one", "two"], variables.Select(v => v.Name).Order()); + Assert.All(variables, v => Assert.Equal("Table", v.TypeName)); + } + + [RealSqliteFact] + public async Task AReset_ForgetsTheTables() + { + await RunAsync("create table gone (x);"); + + ((SqlNotebookKernel)_kernel).HardReset(); + var variables = await _kernel.GetVariablesAsync(CancellationToken.None); + + Assert.Empty(variables); + } + + [RealSqliteFact] + public async Task StopEndsACellThatNeverEnds_AndSqliteGoesWithIt() + { + using var stop = new CancellationTokenSource(); + var clock = Stopwatch.StartNew(); + var running = RunAsync("with recursive c(x) as (select 1 union all select x + 1 from c) select count(*) from c;", stop.Token); + + await Task.Delay(500); + await stop.CancelAsync(); + var ran = await running; + + Assert.True(ran.Result.WasCancelled); + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(10), clock.Elapsed.ToString()); + // and the next cell runs: nothing is left holding the database + var next = await RunAsync("select 1 as fine;"); + Assert.True(next.Result.Success, next.Result.ErrorMessage); + } + + [Fact] + public void TheEngine_IsOneAHeadlessHostHas() + { + using var folder = new TempFolder(); + var languages = HeadlessLanguages.Create(LanguageEngineServices.Create(folder.Path)); + + var sql = languages.Get(LanguageIds.Sql); + + Assert.NotNull(sql); + Assert.True(sql.Has(LanguageCapabilities.NotebookCells | LanguageCapabilities.ValueSharing)); + Assert.Same(sql, languages.FindSourceFileLanguage("report.sql")); + Assert.Same(sql, languages.Get("sqlite")); + } +} + +/// A machine with nothing installed: what a SQL cell says when there is no sqlite3 to run it. +public class SqlWithoutSqliteTests : IDisposable +{ + private sealed class BareHost(bool windows) : IHostEnvironment + { + public bool IsWindows => windows; + public bool IsMacOS => !windows; + public bool IsLinux => false; + public string HomeDirectory => Path.GetTempPath(); + public string? GetEnvironmentVariable(string name) => null; + public bool FileExists(string path) => false; + public bool DirectoryExists(string path) => false; + public IReadOnlyList GetDirectories(string path) => []; + public Task GetLoginShellPathAsync(CancellationToken ct = default) => Task.FromResult(null); + public Task RunAsync(string fileName, IReadOnlyList arguments, TimeSpan timeout, CancellationToken ct = default) => + Task.FromResult(new CommandResult(-1, string.Empty, "not found", false)); + } + + private readonly TempFolder _folder = new(); + + public void Dispose() => _folder.Dispose(); + + private INotebookKernel KernelOn(bool windows) + { + var services = new LanguageEngineServices(_folder.Path, new BareHost(windows), new ProcessLauncher(), new ToolchainSettingsStore(Path.Combine(_folder.Path, "toolchains.json"))); + return new SqlEngine(services).NotebookKernels.Create(new KernelCreationContext(() => _folder.Path)); + } + + [Theory] + [InlineData(true, "winget install SQLite.SQLite")] + [InlineData(false, "brew install sqlite")] + public async Task ACellWithoutSqlite_SaysHowToGetIt_ForTheMachineItIsOn_AndFails(bool windows, string step) + { + using var kernel = KernelOn(windows); + var said = new List(); + + var result = await kernel.ExecuteAsync(new KernelExecutionRequest { Code = "select 1;", OnConsole = said.Add }, CancellationToken.None); + + Assert.False(result.Success); + Assert.Contains("sqlite3", result.ErrorMessage); + Assert.Contains(step, string.Concat(said)); + Assert.Empty(result.Diagnostics); + } + + [Fact] + public async Task WithoutSqlite_ThereAreNoVariables_AndNothingToShare() + { + using var kernel = KernelOn(windows: false); + + Assert.Empty(await kernel.GetVariablesAsync(CancellationToken.None)); + await Assert.ThrowsAsync(() => kernel.GetValueJsonAsync("anything", CancellationToken.None)); + } +} diff --git a/Tests.Core/SqlResultStreamTests.cs b/Tests.Core/SqlResultStreamTests.cs new file mode 100644 index 00000000..8c343bff --- /dev/null +++ b/Tests.Core/SqlResultStreamTests.cs @@ -0,0 +1,121 @@ +using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; +using Xunit; + +namespace FrySharp.Core.Tests; + +/// What sqlite3 -json prints, read into result sets and text (no sqlite3 needed: the text is what it prints). +public class SqlResultStreamTests +{ + private static SqlTableSegment OnlyTable(string output) => Assert.IsType(Assert.Single(SqlResultStream.Split(output))); + + [Fact] + public void ARowPerLine_IsOneTable_WithItsColumns_AndRows() + { + var table = OnlyTable("[{\"id\":1,\"name\":\"Ada\",\"score\":1.5,\"note\":null},\n{\"id\":2,\"name\":\"Linus\",\"score\":2.0,\"note\":\"it's\"}]\n").Table; + + Assert.Equal(["id", "name", "score", "note"], table.Columns.Select(c => c.Header)); + Assert.Equal([true, false, true, false], table.Columns.Select(c => c.IsNumeric)); // a column of numbers (and nulls) is numeric + Assert.Equal(2, table.Rows.Count); + Assert.Equal("Ada", table.Rows[0].Cells[1].DisplayText); + Assert.True(table.Rows[0].Cells[3].IsNull); + Assert.Equal("it's", table.Rows[1].Cells[3].DisplayText); + } + + [Fact] + public void AResultSetOfOneRow_IsATable() + { + var table = OnlyTable("[{\"n\":2}]\n").Table; + + Assert.Equal(["n"], table.Columns.Select(c => c.Header)); + Assert.Equal("2", Assert.Single(table.Rows).Cells[0].DisplayText); + } + + [Fact] + public void TwoColumnsWithTheSameName_AreTwoColumns() + { + var table = OnlyTable("[{\"a\":1,\"a\":2}]\n").Table; + + Assert.Equal(["a", "a"], table.Columns.Select(c => c.Header)); + Assert.Equal(["1", "2"], table.Rows[0].Cells.Select(c => c.DisplayText)); + } + + [Fact] + public void NewlinesInAValue_AreEscapedByTheTool_SoTheRowStaysOnItsLine() + { + var table = OnlyTable("[{\"note\":\"a\\nb\"}]\n").Table; + + Assert.Equal("a\nb", table.Rows[0].Cells[0].DisplayText); + } + + [Fact] + public void TextAroundResultSets_StaysText_InOrder() + { + var segments = SqlResultStream.Split("hello\n[{\"n\":1}]\nbye\n[{\"m\":2},\n{\"m\":3}]\n"); + + Assert.Collection(segments, + s => Assert.Equal("hello\n", Assert.IsType(s).Text), + s => Assert.Single(Assert.IsType(s).Table.Rows), + s => Assert.Equal("bye\n", Assert.IsType(s).Text), + s => Assert.Equal(2, Assert.IsType(s).Table.Rows.Count)); + } + + [Fact] + public void TheSegments_HaveTheirOffsetsInTheOutput() + { + const string output = "a\n[{\"n\":1}]\nb\n"; + var segments = SqlResultStream.Split(output); + + Assert.Equal([(0, 2), (2, 12), (12, 14)], segments.Select(s => (s.Start, s.End))); + Assert.Equal("[{\"n\":1}]\n", output[segments[1].Start..segments[1].End]); + } + + [Fact] + public void BackToBackResultSets_AreTwoTables() + { + var segments = SqlResultStream.Split("[{\"a\":1}]\n[{\"b\":2}]\n"); + + Assert.Equal(2, segments.OfType().Count()); + } + + [Fact] + public void ALineThatLooksLikeTheStartButIsNotJson_IsText() + { + var segments = SqlResultStream.Split("[{ this is a line a script printed\nnext\n"); + + Assert.All(segments, s => Assert.IsType(s)); + Assert.Equal("[{ this is a line a script printed\nnext\n", SqlResultStream.TextOf(segments)); + } + + [Fact] + public void AScriptThatChangesTheMode_GetsItsTextAsItIs() + { + var segments = SqlResultStream.Split("id,name\n1,Ada\n2,Linus\n"); + + Assert.All(segments, s => Assert.IsType(s)); + Assert.Equal("id,name\n1,Ada\n2,Linus\n", SqlResultStream.TextOf(segments)); + } + + [Fact] + public void WindowsLineEndings_AreRead() + { + var segments = SqlResultStream.Split("[{\"a\":1},\r\n{\"a\":2}]\r\ntext\r\n"); + + Assert.Equal(2, Assert.IsType(segments[0]).Table.Rows.Count); + Assert.Equal("text\r\n", Assert.IsType(segments[1]).Text); + } + + [Fact] + public void NothingPrinted_IsNoSegments() + { + Assert.Empty(SqlResultStream.Split(string.Empty)); + } + + [Fact] + public void ARowThatEndsLikeAResultSet_InsideAText_DoesNotEndTheTableEarly() + { + // the value ends in "}]" but the row ends in "}," : only the last row's line ends the result set + var table = OnlyTable("[{\"v\":\"x}]\"},\n{\"v\":\"y\"}]\n").Table; + + Assert.Equal(["x}]", "y"], table.Rows.Select(r => r.Cells[0].DisplayText)); + } +} diff --git a/Tests.Core/TestSupport.cs b/Tests.Core/TestSupport.cs index 02860098..128f140d 100644 --- a/Tests.Core/TestSupport.cs +++ b/Tests.Core/TestSupport.cs @@ -43,7 +43,7 @@ public static class Headless { private static readonly RoslynCompilerService Compiler = new(); - public static async Task<(CSharpRunResult Result, string Output, List Displays)> RunAsync(string code, TimeSpan? timeout = null, CancellationToken ct = default) + public static async Task<(CSharpRunResult Result, string Output, List Displays)> RunAsync(string code, TimeSpan? timeout = null, CancellationToken ct = default, Action? observe = null) { var output = new System.Text.StringBuilder(); var displays = new List(); @@ -51,7 +51,11 @@ public static class Headless limit.CancelAfter(timeout ?? TimeSpan.FromMinutes(1)); var result = await new InProcessCSharpRunner(Compiler).RunAsync( code, - text => { lock (output) output.Append(text); }, + text => + { + lock (output) output.Append(text); + observe?.Invoke(text); + }, display => { lock (displays) displays.Add(display); }, stdin: null, limit.Token); diff --git a/Tests.Sidecar/RealPythonFactAttribute.cs b/Tests.Sidecar/RealPythonFactAttribute.cs new file mode 100644 index 00000000..41bae24e --- /dev/null +++ b/Tests.Sidecar/RealPythonFactAttribute.cs @@ -0,0 +1,24 @@ +using Xunit; + +namespace FrySharp.Sidecar.Tests; + +/// +/// A test that runs real Python through the sidecar. Skipped on a machine without python3 on its PATH, unless +/// FRY_REQUIRE_PYTHON=1, which makes a missing Python a failure. +/// +public sealed class RealPythonFactAttribute : FactAttribute +{ + public RealPythonFactAttribute() + { + if (Environment.GetEnvironmentVariable("FRY_REQUIRE_PYTHON") == "1") return; + if (!OnPath("python3") && !OnPath("python")) Skip = "Needs Python on the PATH (or set FRY_REQUIRE_PYTHON=1 to make this a failure)."; + } + + private static bool OnPath(string program) + { + var names = OperatingSystem.IsWindows() ? new[] { program + ".exe", program + ".cmd" } : new[] { program }; + return (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Any(folder => names.Any(name => File.Exists(Path.Combine(folder, name)))); + } +} diff --git a/Tests.Sidecar/SidecarHarness.cs b/Tests.Sidecar/SidecarHarness.cs index 99909125..e38c9757 100644 --- a/Tests.Sidecar/SidecarHarness.cs +++ b/Tests.Sidecar/SidecarHarness.cs @@ -19,6 +19,10 @@ public sealed class SidecarHarness : IAsyncDisposable public JsonRpc ClientRpc { get; } public JsonRpc ServerRpc { get; } public List<(string Method, object? Payload)> Notifications { get; } + + /// What the client answers when a cell asks for input; the requests it got. + public Func InputAnswer { get; set; } = _ => null; + public List InputRequests { get; } = []; private SidecarHarness(TempFolder workspace, JsonRpc clientRpc, JsonRpc serverRpc, List<(string Method, object? Payload)> notifications) { @@ -58,6 +62,7 @@ public static async Task CreateAsync() languages, runs, notebooks, + new EngineInfoService(languages, workspaceService), NullLogger.Instance); serverRpc.AddLocalRpcTarget(sidecarTarget); @@ -67,10 +72,15 @@ public static async Task CreateAsync() var clientHandler = new HeaderDelimitedMessageHandler(clientStream, clientStream, new JsonMessageFormatter()); var clientRpc = new JsonRpc(clientHandler); - clientRpc.AddLocalRpcTarget(new ClientReceiver(harnessNotifications)); + SidecarHarness? created = null; + clientRpc.AddLocalRpcTarget(new ClientReceiver(harnessNotifications, request => + { + created!.InputRequests.Add(request); + return created.InputAnswer(request); + })); clientRpc.StartListening(); - var harness = new SidecarHarness(workspace, clientRpc, serverRpc, harnessNotifications); + var harness = created = new SidecarHarness(workspace, clientRpc, serverRpc, harnessNotifications); // Perform initialize handshake await clientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); @@ -85,13 +95,16 @@ public async ValueTask DisposeAsync() Workspace.Dispose(); } - private sealed class ClientReceiver(List<(string, object?)> notifications) + private sealed class ClientReceiver(List<(string, object?)> notifications, Func onInput) { + [JsonRpcMethod(ClientMethods.InputRequest, UseSingleObjectParameterDeserialization = true)] + public string? OnInputRequest(InputRequest request) => onInput(request); + [JsonRpcMethod(ClientMethods.NotebookOutput, UseSingleObjectParameterDeserialization = true)] - public void OnNotebookOutput(NotebookOutputNotification n) => notifications.Add((ClientMethods.NotebookOutput, n)); + public void OnNotebookOutput(NotebookOutputNotification n) { lock (notifications) notifications.Add((ClientMethods.NotebookOutput, n)); } [JsonRpcMethod(ClientMethods.NotebookCellState, UseSingleObjectParameterDeserialization = true)] - public void OnNotebookCellState(CellStateNotification n) => notifications.Add((ClientMethods.NotebookCellState, n)); + public void OnNotebookCellState(CellStateNotification n) { lock (notifications) notifications.Add((ClientMethods.NotebookCellState, n)); } [JsonRpcMethod(ClientMethods.RunOutput, UseSingleObjectParameterDeserialization = true)] public void OnRunOutput(RunOutputNotification n) => notifications.Add((ClientMethods.RunOutput, n)); diff --git a/Tests.Sidecar/SidecarTests.cs b/Tests.Sidecar/SidecarTests.cs index 6311df10..339a59e8 100644 --- a/Tests.Sidecar/SidecarTests.cs +++ b/Tests.Sidecar/SidecarTests.cs @@ -1,5 +1,6 @@ using FrySharp.Sidecar.Protocol; using PdfEditorApp.Plugins.CSharpEditor.Models; +using PdfEditorApp.Plugins.CSharpEditor.Services.Display; using PdfEditorApp.Plugins.CSharpEditor.Services.Storage; using Xunit; @@ -145,4 +146,1073 @@ await _harness.ClientRpc.InvokeWithParameterObjectAsync( Assert.NotNull(completions); Assert.Contains(completions.Items, c => c.Label == "Console"); } + + // The bundle a VS Code notebook gets for an object: the Object Explorer's data, and the page that draws it until a + // renderer for the data exists. + private async Task LastObjectDisplayAsync(string notebook, string language, string source) + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/notebook/execute", new ExecuteCellParams(notebook, "cell-1", source, language)); + Assert.True(result.Success); + + var display = _harness.Notifications + .Select(n => n.Payload).OfType() + .Single(n => n.Output.Kind == "display").Output; + return Assert.IsType(display.Data); + } + + private static void AssertObjectExplorerBundle(Newtonsoft.Json.Linq.JObject data, string title, string firstProperty) + { + var node = Assert.IsType(data[RichOutputMime.InspectorMime]); + Assert.EndsWith(title, (string?)node["title"]); // (a C# script class is "Submission#3+People") + Assert.Equal(firstProperty, (string?)node["properties"]![0]!["name"]); + Assert.Contains("fry-inspector-card", (string?)data["text/html"]); + Assert.NotNull(data["text/plain"]); + } + + [Fact] + public async Task Notebook_CSharp_LastObject_ArrivesAsAnObjectExplorerBundle() + { + var data = await LastObjectDisplayAsync("objects.frynb", "csharp", + "public class People { public string Name { get; set; } = \"Code\"; }\nnew People()"); + + AssertObjectExplorerBundle(data, "People", "Name"); + } + + [RealPythonFact] + public async Task Notebook_Python_LastObject_ArrivesAsAnObjectExplorerBundle() + { + var data = await LastObjectDisplayAsync("objects-py.frynb", "python", + "class People:\n def __init__(self):\n self.name = 'Code'\n\nPeople()"); + + AssertObjectExplorerBundle(data, "People", "name"); + } + + // ── A notebook the client holds (VS Code): named by an id, not by a file in the workspace ───────────────────────────── + + // The reply can arrive before some of the run's notifications: a client waits for as many as the reply says there were (lastSeq), + // as the extension does, so what a test reads afterwards is all of it and nothing is still being added. + private async Task RunHeldAsync(string notebookId, string source, string language = "csharp", string? workingDirectory = null) + { + var executionId = Guid.NewGuid().ToString("N"); + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync( + "fry/notebook/execute", new ExecuteCellParams("", Guid.NewGuid().ToString("N"), source, language, notebookId, workingDirectory, executionId)); + + Assert.True(SpinWait.SpinUntil(() => SeqNotificationsOf(executionId) >= result.LastSeq, TimeSpan.FromSeconds(30)), "the run's notifications did not all arrive"); + return result; + } + + private int SeqNotificationsOf(string executionId) + { + lock (_harness.Notifications) + { + return _harness.Notifications.Count(n => n.Payload is NotebookOutputNotification o && o.ExecutionId == executionId + || n.Payload is CellStateNotification c && c.ExecutionId == executionId); + } + } + + private string TextOutput() => string.Concat(_harness.Notifications + .Select(n => n.Payload).OfType().Where(n => n.Output.Kind == "stream").Select(n => n.Output.Text)); + + [Fact] + public async Task HeldNotebook_UntitledOrNot_KeepsItsStateBetweenCells() + { + Assert.True((await RunHeldAsync("untitled-1", "int x = 41;")).Success); + Assert.True((await RunHeldAsync("untitled-1", "Console.WriteLine(x + 1);")).Success); + + Assert.Contains("42", TextOutput()); + } + + [Fact] + public async Task HeldNotebooks_WithTheSameFileName_ButDifferentIds_DoNotShareState() + { + // two "notebook.frynb" in different folders are two notebooks: the id says which, not the name + Assert.True((await RunHeldAsync("file:///a/notebook.frynb", "int shared = 1;")).Success); + + var other = await RunHeldAsync("file:///b/notebook.frynb", "Console.WriteLine(shared);"); + + Assert.False(other.Success); + } + + [Fact] + public async Task HeldNotebook_Restart_ForgetsItsVariables_AndVariablesAreListedById() + { + await RunHeldAsync("nb-restart", "int kept = 7;"); + var before = await _harness.ClientRpc.InvokeWithParameterObjectAsync>("fry/notebook/variables", new NotebookRef(NotebookId: "nb-restart")); + Assert.Contains(before, v => v.Name == "kept"); + + await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/restart", new NotebookRef(NotebookId: "nb-restart")); + + var after = await _harness.ClientRpc.InvokeWithParameterObjectAsync>("fry/notebook/variables", new NotebookRef(NotebookId: "nb-restart")); + Assert.DoesNotContain(after, v => v.Name == "kept"); + Assert.False((await RunHeldAsync("nb-restart", "Console.WriteLine(kept);")).Success); + } + + [Fact] + public async Task HeldNotebook_Interrupt_StopsARunningCell() + { + var running = RunHeldAsync("nb-interrupt", "System.Threading.Thread.Sleep(30000);"); + await Task.Delay(1500); + + await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/interrupt", new NotebookRef(NotebookId: "nb-interrupt")); + var result = await running.WaitAsync(TimeSpan.FromSeconds(20)); + + Assert.False(result.Success); + } + + [Fact] + public async Task HeldNotebook_Close_ForgetsIt() + { + await RunHeldAsync("nb-close", "int a = 1;"); + await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/close", new NotebookRef(NotebookId: "nb-close")); + + var error = await Assert.ThrowsAnyAsync(() => + _harness.ClientRpc.InvokeWithParameterObjectAsync>("fry/notebook/variables", new NotebookRef(NotebookId: "nb-close"))); + Assert.Contains("not open", error.Message); + } + + [Fact] + public async Task ARequestThatNamesNoNotebook_IsRefused() + { + var error = await Assert.ThrowsAnyAsync(() => + _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/restart", new NotebookRef())); + Assert.Contains("notebookId or path", error.Message); + } + + [RealPythonFact] + public async Task HeldNotebook_RunsInTheFolderItIsGiven_EvenOutsideTheWorkspace() + { + using var elsewhere = new FrySharp.Core.Tests.TempFolder(); + + var result = await RunHeldAsync("nb-folder", "import os\nprint(os.getcwd())", "python", elsewhere.Path); + + Assert.True(result.Success, TextOutput()); + // (Python reports the real path: /private/var for /var on macOS, so compare the folder's own, unique name) + Assert.EndsWith(Path.GetFileName(elsewhere.Path), TextOutput().Trim()); + Assert.DoesNotContain(_harness.Workspace.Path, TextOutput()); + } + + // ── What a run tells the client: numbered, in order, with where it went wrong ────────────────────────────────────────── + + private IEnumerable Outputs() => _harness.Notifications.Select(n => n.Payload).OfType(); + + [Fact] + public async Task Execute_NumbersEveryNotification_FromOne_AndTheReplySaysHowManyThereWere() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/execute", + new ExecuteCellParams("", "c1", "Console.WriteLine(\"a\"); Display.Html(\"b\"); Console.WriteLine(\"c\");", "csharp", "nb-seq", ExecutionId: "run-1")); + + var seqs = _harness.Notifications.Select(n => n.Payload switch + { + NotebookOutputNotification o => (o.ExecutionId, o.Seq), + CellStateNotification c => (c.ExecutionId, c.Seq), + _ => (null, -1) + }).Where(x => x.Item2 >= 0).ToList(); + + Assert.All(seqs, x => Assert.Equal("run-1", x.Item1)); + Assert.Equal(Enumerable.Range(1, seqs.Count), seqs.Select(x => x.Item2)); // 1, 2, 3...: none missing, in order + Assert.Equal(seqs.Count, result.LastSeq); + var state = _harness.Notifications.Select(n => n.Payload).OfType().ToList(); + Assert.True(state.First().Running); + Assert.False(state.Last().Running); // the end is the last thing said + Assert.Equal(result.LastSeq, state.Last().Seq); + } + + [Fact] + public async Task Execute_ALotOfOutput_ArrivesWhole_BeforeTheReply() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/execute", + new ExecuteCellParams("", "c1", "for (var i = 0; i < 20000; i++) Console.WriteLine(\"line \" + i);", "csharp", "nb-flood", ExecutionId: "flood")); + + Assert.True(result.Success); + var text = string.Concat(Outputs().Where(o => o.Output.Kind == "stream").Select(o => o.Output.Text)); + var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(20000, lines.Length); + Assert.Equal("line 0", lines[0].Trim()); + Assert.Equal("line 19999", lines[^1].Trim()); + Assert.True(Outputs().Count() < 2000, "output is sent in batches, not a line at a time"); + } + + [Fact] + public async Task Execute_ACompileError_SaysWhereInTheCell() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/execute", + new ExecuteCellParams("", "c1", "int ok = 1;\nint bad = ;", "csharp", "nb-error", ExecutionId: "e1")); + + Assert.False(result.Success); + var diagnostic = Assert.Single(result.Diagnostics!); + Assert.Equal("error", diagnostic.Severity); + Assert.StartsWith("CS", diagnostic.Code); + Assert.Equal(2, diagnostic.StartLine); + } + + [RealPythonFact] + public async Task Execute_APythonError_NamesTheLineOfTheCell() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/execute", + new ExecuteCellParams("", "c1", "x = 1\ny = 0\nz = x / y", "python", "nb-py-error", ExecutionId: "p1")); + + Assert.False(result.Success); + var diagnostic = Assert.Single(result.Diagnostics!); + Assert.Equal("ZeroDivisionError", diagnostic.Code); + Assert.Equal(3, diagnostic.StartLine); + } + + [Fact] + public async Task Execute_ASuccessfulCell_HasNoDiagnostics() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/execute", + new ExecuteCellParams("", "c1", "var a = 1;", "csharp", "nb-fine", ExecutionId: "f1")); + + Assert.True(result.Success); + Assert.True(result.Diagnostics is null || result.Diagnostics.Count == 0); + } + + [Fact] + public async Task Execute_ACellThatReadsALine_AsksTheClient_WithWhichCellIsAsking() + { + _harness.InputAnswer = _ => "Ada"; + + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/execute", + new ExecuteCellParams("", "ask-cell", "var name = Console.ReadLine();\nConsole.WriteLine(\"Hello \" + name);", "csharp", "nb-input", ExecutionId: "i1")); + + Assert.True(result.Success); + var request = Assert.Single(_harness.InputRequests); + Assert.Equal("ask-cell", request.CellId); + Assert.Equal("i1", request.ExecutionId); + Assert.Equal("nb-input", request.Path); + Assert.Contains("Hello Ada", string.Concat(Outputs().Where(o => o.Output.Kind == "stream").Select(o => o.Output.Text))); + } + + [Fact] + public async Task Execute_AChartTheProgramRedraws_IsSentAgainAsAnUpdateOfTheSameDisplay() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/notebook/execute", + new ExecuteCellParams("", "chart", "var h = Display.LineChart(new[] { 1.0, 2.0 }, \"Before\");\nh.Update(s => s.Title = \"After\");", "csharp", "nb-update", ExecutionId: "u1")); + + Assert.True(result.Success); + var displays = Outputs().Where(o => o.Output.Kind == "display").Select(o => o.Output).ToList(); + Assert.True(displays.Count >= 2, $"{displays.Count} display notification(s)"); + Assert.False(displays[0].Update); + Assert.NotNull(displays[0].DisplayId); + var update = displays.Last(); + Assert.True(update.Update); + Assert.Equal(displays[0].DisplayId, update.DisplayId); + Assert.Contains("After", update.Data!.ToString()); + } + + // ── Language features in a notebook cell: what the earlier cells define is in scope ────────────────────────────────────── + + private async Task CompleteAsync(string source, string marker, string[]? context = null, string language = "csharp") + { + var offset = source.IndexOf(marker, StringComparison.Ordinal) + marker.Length; + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/completeCell", + new CellPositionParams(language, source, offset, context)); + return result.Items.Select(i => i.Label).ToArray(); + } + + [Fact] + public async Task CellCompletion_OffersTheMembersOfAVariableAnEarlierCellDefined() + { + var labels = await CompleteAsync("greeting.", "greeting.", context: ["var greeting = \"hi\";"]); + + Assert.Contains("ToUpper", labels); + Assert.Contains("Length", labels); + } + + [Fact] + public async Task CellCompletion_KnowsATypeAnEarlierCellDeclared_EvenThoughStatementsFollowIt() + { + var labels = await CompleteAsync("var p = new Person();\np.", "p.", context: ["class Person { public string Name { get; set; } public int Age { get; set; } }"]); + + Assert.Contains("Name", labels); + Assert.Contains("Age", labels); + } + + [Fact] + public async Task CellCompletion_ReadsTheUsingsOfEarlierCells() + { + var labels = await CompleteAsync("var x = new XElement(\"a\");\nx.", "x.", context: ["using System.Xml.Linq;", "int unrelated = 1;"]); + + Assert.Contains("Add", labels); + } + + [Fact] + public async Task CellCompletion_IgnoresTheDirectivesOfTheNotebook() + { + var labels = await CompleteAsync("n.", "n.", context: ["#!csharp\n#!share --from python things\nint n = 5;"]); + + Assert.Contains("ToString", labels); + } + + [Fact] + public async Task CellCompletion_WithNoEarlierCells_StillWorks() + { + var labels = await CompleteAsync("Console.Wr", "Console.Wr"); + + Assert.Contains("WriteLine", labels); + } + + // ── SQL cells: the rows of a query are a table the client draws, not text ─────────────────────────────────────────────── + + private IEnumerable TableBundles() => Outputs() + .Where(o => o.Output.Kind == "display").Select(o => o.Output.Data).OfType() + .Where(data => data[PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table] is not null); + + [Fact] + public async Task Initialize_ListsSql_AsALanguageThatRunsCells() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + var sql = Assert.Single(result.Languages, l => l.Id == "sql"); + Assert.True(sql.CanRunCells); + Assert.Contains("sqlite", sql.Aliases); + Assert.Contains(".sql", sql.Extensions); + // the languages the server completes in a cell are said to the client (it asks only for those) + Assert.Equal(["csharp", "sql"], result.Languages.Where(l => l.HasCompletion).Select(l => l.Id).Order()); + } + + [FrySharp.Core.Tests.RealSqliteFact] + public async Task Notebook_Sql_Rows_ArriveAsATableBundle_WithItsHtmlAndTextFallbacks() + { + var result = await RunHeldAsync("nb-sql", "create table people (id integer primary key, name text, age integer);\n" + + "insert into people (name, age) values ('Ada', 36), ('Linus', 55);\nselect id, name, age from people order by id;", "sql"); + + Assert.True(result.Success, TextOutput()); + var data = Assert.Single(TableBundles()); + var table = Assert.IsType(data[PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table]); + Assert.Equal(["id", "name", "age"], table["columns"]!.Select(c => (string?)c)); + Assert.Equal("Ada", (string?)table["rows"]![0]![1]); + Assert.Equal(2, table["rows"]!.Count()); + Assert.NotNull(data["text/html"]); + Assert.NotNull(data["text/plain"]); + Assert.Equal(string.Empty, TextOutput()); // no text table next to it + } + + [FrySharp.Core.Tests.RealSqliteFact] + public async Task Notebook_Sql_AnError_NamesTheLineOfTheCell_AndWhatItSays() + { + var result = await RunHeldAsync("nb-sql-error", "select 1 as fine;\nselect * from nosuch;", "sql"); + + Assert.False(result.Success); + var diagnostic = Assert.Single(result.Diagnostics!); + Assert.Equal(2, diagnostic.StartLine); + Assert.Contains("no such table", diagnostic.Message); + } + + [FrySharp.Core.Tests.RealSqliteFact] + public async Task Notebook_Sql_KeepsItsTables_AndTheyAreTheVariables() + { + Assert.True((await RunHeldAsync("nb-sql-vars", "create table kept (v integer); insert into kept values (7);", "sql")).Success); + + var variables = await _harness.ClientRpc.InvokeWithParameterObjectAsync>( + "fry/notebook/variables", new NotebookRef(NotebookId: "nb-sql-vars")); + + var kept = Assert.Single(variables, v => v.Name == "kept"); + Assert.Equal("SQL", kept.Kernel); + } + + [FrySharp.Core.Tests.RealSqliteFact] + public async Task Notebook_Sql_CanBeGivenAListFromCSharp_AndQueryIt() + { + Assert.True((await RunHeldAsync("nb-sql-share", "var rows = new[] { new { Name = \"Ada\", Age = 36 }, new { Name = \"Linus\", Age = 55 } };")).Success); + + var result = await RunHeldAsync("nb-sql-share", "#!share --from csharp rows\nselect Name from rows where Age > 40;", "sql"); + + Assert.True(result.Success, TextOutput()); + var table = Assert.IsType(Assert.Single(TableBundles())[PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table]); + Assert.Equal("Linus", (string?)table["rows"]![0]![0]); + } + + // ── F# cells: one session for the notebook, the value of the last expression shown, errors on the cell's lines ─────────── + + private IEnumerable DisplayBundles() => Outputs() + .Where(o => o.Output.Kind == "display").Select(o => o.Output.Data).OfType(); + + [Fact] + public async Task Initialize_ListsFSharp_AsALanguageThatRunsCells() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + var fsharp = Assert.Single(result.Languages, l => l.Id == "fsharp"); + Assert.True(fsharp.CanRunCells); + Assert.Contains("fs", fsharp.Aliases); + Assert.Contains(".fsx", fsharp.Extensions); + } + + [FrySharp.Core.Tests.RealFSharpFact] + public async Task Notebook_FSharp_KeepsWhatACellDefines_AndShowsTheLastValue() + { + Assert.True((await RunHeldAsync("nb-fs", "let x = 5\nlet twice n = n * 2", "fsharp")).Success, TextOutput()); + + var result = await RunHeldAsync("nb-fs", "twice x + 1", "fsharp"); + + Assert.True(result.Success, TextOutput()); + Assert.Contains("11", TextOutput()); + } + + [FrySharp.Core.Tests.RealFSharpFact] + public async Task Notebook_FSharp_RecordsArriveAsATable_ARecordAsAnObjectExplorer_WithTheirFallbacks() + { + Assert.True((await RunHeldAsync("nb-fs-bundles", "type Person = { Name: string; Age: int }\nlet people = [ { Name = \"Ada\"; Age = 36 }; { Name = \"Linus\"; Age = 55 } ]", "fsharp")).Success); + + Assert.True((await RunHeldAsync("nb-fs-bundles", "people", "fsharp")).Success, TextOutput()); + Assert.True((await RunHeldAsync("nb-fs-bundles", "people.Head", "fsharp")).Success, TextOutput()); + + var bundles = DisplayBundles().ToList(); + Assert.Equal(2, bundles.Count); + var table = Assert.IsType(bundles[0][PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table]); + Assert.Equal(["Name", "Age"], table["columns"]!.Select(c => (string?)c)); + Assert.Equal("Linus", (string?)table["rows"]![1]![0]); + Assert.NotNull(bundles[0]["text/html"]); + AssertObjectExplorerBundle(bundles[1], "Person", "Name"); + } + + [FrySharp.Core.Tests.RealFSharpFact] + public async Task Notebook_FSharp_ACompileError_IsOnTheCellsLine_WithItsColumns() + { + var result = await RunHeldAsync("nb-fs-error", "let a = 1\n\nlet b = a + nope\nb", "fsharp"); + + Assert.False(result.Success); + var error = Assert.Single(result.Diagnostics!); + Assert.Equal("FS0039", error.Code); + Assert.Equal(3, error.StartLine); + Assert.Equal(13, error.StartColumn); + Assert.Contains("nope", error.Message); + } + + [FrySharp.Core.Tests.RealFSharpFact] + public async Task Notebook_FSharp_ARuntimeError_NamesTheLineOfTheCell() + { + var result = await RunHeldAsync("nb-fs-throw", "let f x =\n if x > 1 then failwith \"boom\" else x\nf 5", "fsharp"); + + Assert.False(result.Success); + var error = Assert.Single(result.Diagnostics!); + Assert.Equal("Exception", error.Code); + Assert.Equal(3, error.StartLine); + } + + [FrySharp.Core.Tests.RealFSharpFact] + public async Task Notebook_FSharp_TakesAListFromCSharp_AndGivesOneBack() + { + Assert.True((await RunHeldAsync("nb-fs-share", "var rows = new[] { new { Name = \"Ada\", Age = 36 }, new { Name = \"Linus\", Age = 55 } };")).Success); + + var inFs = await RunHeldAsync("nb-fs-share", "#!share --from csharp rows\nrows |> Array.map (fun r -> r.[\"Name\"] :?> string) |> String.concat \", \"", "fsharp"); + Assert.True(inFs.Success, TextOutput()); + Assert.Contains("Ada, Linus", TextOutput()); + + Assert.True((await RunHeldAsync("nb-fs-share", "let squares = [ for i in 1 .. 4 -> i * i ]", "fsharp")).Success); + var back = await RunHeldAsync("nb-fs-share", "#!share --from fsharp squares\nConsole.WriteLine(string.Join(\",\", squares));"); + Assert.True(back.Success, TextOutput()); + Assert.Contains("1,4,9,16", TextOutput()); + } + + [FrySharp.Core.Tests.RealFSharpNuGetFact] + public async Task Notebook_FSharp_APackageLine_IsNotSwallowedByTheNotebook_FsiLoadsIt() + { + var result = await RunHeldAsync("nb-fs-nuget", "#r \"nuget: Newtonsoft.Json, 13.0.3\"\nopen Newtonsoft.Json\nJsonConvert.SerializeObject([ 1; 2 ])", "fsharp"); + + Assert.True(result.Success, TextOutput()); + Assert.Contains("[1,2]", TextOutput()); + } + + [FrySharp.Core.Tests.RealFSharpFact] + public async Task Notebook_FSharp_TheNotebooksValues_AreTheVariables() + { + Assert.True((await RunHeldAsync("nb-fs-vars", "let kept = 7\nlet names = [ \"a\" ]", "fsharp")).Success); + + var variables = await _harness.ClientRpc.InvokeWithParameterObjectAsync>( + "fry/notebook/variables", new NotebookRef(NotebookId: "nb-fs-vars")); + + Assert.Equal(["kept", "names"], variables.Select(v => v.Name).Order()); + Assert.Equal("int", variables.Single(v => v.Name == "kept").Type); + } + + // ── Java cells: a JShell session for the notebook, errors on the cell's lines and columns ───────────────────────────────── + + [Fact] + public async Task Initialize_ListsJava_AsALanguageThatRunsCells() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + var java = Assert.Single(result.Languages, l => l.Id == "java"); + Assert.True(java.CanRunCells); + Assert.Contains("jdk", java.Aliases); + Assert.Contains(".java", java.Extensions); + } + + [FrySharp.Core.Tests.RealJavaFact] + public async Task Notebook_Java_KeepsWhatACellDeclares_AndShowsTheLastValue() + { + Assert.True((await RunHeldAsync("nb-java", "int x = 5;\nint twice(int n) { return n * 2; }", "java")).Success, TextOutput()); + + var result = await RunHeldAsync("nb-java", "twice(x) + 1", "java"); + + Assert.True(result.Success, TextOutput()); + Assert.Contains("11", TextOutput()); + } + + [FrySharp.Core.Tests.RealJavaFact] + public async Task Notebook_Java_RecordsArriveAsATable_ARecordAsAnObjectExplorer() + { + Assert.True((await RunHeldAsync("nb-java-bundles", "record Person(String name, int age) {}\nvar people = List.of(new Person(\"Ada\", 36), new Person(\"Linus\", 55));", "java")).Success); + + Assert.True((await RunHeldAsync("nb-java-bundles", "people", "java")).Success, TextOutput()); + Assert.True((await RunHeldAsync("nb-java-bundles", "people.get(0)", "java")).Success, TextOutput()); + + var bundles = DisplayBundles().ToList(); + Assert.Equal(2, bundles.Count); + var table = Assert.IsType(bundles[0][PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table]); + Assert.Equal(["name", "age"], table["columns"]!.Select(c => (string?)c)); + Assert.Equal("Linus", (string?)table["rows"]![1]![0]); + AssertObjectExplorerBundle(bundles[1], "Person", "name"); + } + + // What VS Code draws for an object that points at itself: the same five levels the desktop shows (the members of the top object and of + // three levels below it, then a title), each reference back flagged and still listing its members, so it opens. + [FrySharp.Core.Tests.RealJavaFact] + public async Task Notebook_Java_AnObjectThatPointsAtItself_ArrivesFiveLevelsDeep_EachOneOpenable() + { + var data = await LastObjectDisplayAsync("cycle.frynb", "java", + "public class People { public String name = \"Prashant\"; public People another; }\nPeople person = new People();\nperson.another = person;\nperson"); + + var chain = new List { Assert.IsType(data[RichOutputMime.InspectorMime]) }; + while (chain[^1]["properties"]!.FirstOrDefault(p => (string?)p["name"] == "another")?["child"] is Newtonsoft.Json.Linq.JObject next) chain.Add(next); + + Assert.Equal(5, chain.Count); + Assert.All(chain.Take(4), node => Assert.Equal(2, node["properties"]!.Count())); + Assert.Equal([false, true, true, true, false], chain.Select(node => (bool?)node["circular"] == true)); + Assert.Empty(chain[4]["properties"]!); + } + + [FrySharp.Core.Tests.RealJavaFact] + public async Task Notebook_Java_ACompileError_IsOnTheCellsLine_WithItsColumns() + { + var result = await RunHeldAsync("nb-java-error", "int a = 1;\n\nString s = 5;\nint c = a;", "java"); + + Assert.False(result.Success); + var error = Assert.Single(result.Diagnostics!); + Assert.Equal(3, error.StartLine); + Assert.Equal(12, error.StartColumn); + Assert.Contains("incompatible types", error.Message); + } + + [FrySharp.Core.Tests.RealJavaFact] + public async Task Notebook_Java_ARuntimeError_NamesItsKindAndTheLineOfTheCell() + { + var result = await RunHeldAsync("nb-java-throw", "void f(int x) {\n if (x > 1) throw new IllegalStateException(\"boom\");\n}\n\nf(5);", "java"); + + Assert.False(result.Success); + var error = Assert.Single(result.Diagnostics!); + Assert.Equal("IllegalStateException", error.Code); + Assert.Equal(2, error.StartLine); + } + + [FrySharp.Core.Tests.RealJavaFact] + public async Task Notebook_Java_TakesAListFromCSharp_AndGivesOneBack() + { + Assert.True((await RunHeldAsync("nb-java-share", "var rows = new[] { new { Name = \"Ada\", Age = 36 }, new { Name = \"Linus\", Age = 55 } };")).Success); + + var inJava = await RunHeldAsync("nb-java-share", "#!share --from csharp rows\nSystem.out.println(rows.size());", "java"); + Assert.True(inJava.Success, TextOutput()); + Assert.Contains("2", TextOutput()); + + Assert.True((await RunHeldAsync("nb-java-share", "int[] squares = {1, 4, 9};", "java")).Success); + var back = await RunHeldAsync("nb-java-share", "#!share --from java squares\nConsole.WriteLine(string.Join(\",\", squares));"); + Assert.True(back.Success, TextOutput()); + Assert.Contains("1,4,9", TextOutput()); + } + + [FrySharp.Core.Tests.RealJavaFact] + public async Task Notebook_Java_AScannerAndABufferedReader_AskTheClient_AndNoAnswerIsTheEndOfInput() + { + var answers = new Queue(["Ada", "36", null]); + _harness.InputAnswer = _ => answers.Dequeue(); + + var result = await RunHeldAsync("nb-java-input", + "var in = new java.util.Scanner(System.in);\n" + + "System.out.print(\"Name? \");\n" + + "String name = in.nextLine();\n" + + "var reader = new java.io.BufferedReader(new java.io.InputStreamReader(System.in));\n" + + "String age = reader.readLine();\n" + + "String rest = reader.readLine();\n" + + "System.out.println(name + \" is \" + age + \", then \" + (rest == null ? \"end of input\" : rest));", "java"); + + Assert.True(result.Success, TextOutput()); + Assert.Equal(3, _harness.InputRequests.Count); + Assert.All(_harness.InputRequests, request => Assert.Equal("nb-java-input", request.Path)); + Assert.Contains("Ada is 36, then end of input", TextOutput()); + } + + [FrySharp.Core.Tests.RealJavaNetworkFact] + public async Task Notebook_Java_ADepsLine_RestoresThePackage_AndItsClassesAreUsableAtOnce() + { + var result = await RunHeldAsync("nb-java-deps", "//DEPS com.google.code.gson:gson:2.11.0\nnew com.google.gson.Gson().toJson(List.of(1, 2))", "java"); + + Assert.True(result.Success, TextOutput()); + Assert.Contains("[1,2]", TextOutput()); + } + + // ── Rust cells: each cell built by Cargo, what a cell makes kept for the next, errors on the cell's own lines ───────────── + + [Fact] + public async Task Initialize_ListsRust_AsALanguageThatRunsCells() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + var rust = Assert.Single(result.Languages, l => l.Id == "rust"); + Assert.True(rust.CanRunCells); + Assert.Contains("rs", rust.Aliases); + Assert.Contains(".rs", rust.Extensions); + } + + [FrySharp.Core.Tests.RealRustFact] + public async Task Notebook_Rust_KeepsWhatACellMakes_AndShowsTheLastValue() + { + Assert.True((await RunHeldAsync("nb-rust", "fn twice(n: i32) -> i32 { n * 2 }\nlet x = 5;", "rust")).Success, TextOutput()); + + var result = await RunHeldAsync("nb-rust", "twice(x) + 1", "rust"); + + Assert.True(result.Success, TextOutput()); + Assert.Contains("11", TextOutput()); + } + + [FrySharp.Core.Tests.RealRustFact] + public async Task Notebook_Rust_AListOfStructsArrivesAsATable_AStructAsAnObjectExplorer() + { + Assert.True((await RunHeldAsync("nb-rust-bundles", "#[derive(Debug)]\nstruct Person { name: String, age: u32 }", "rust")).Success, TextOutput()); + + Assert.True((await RunHeldAsync("nb-rust-bundles", "vec![Person { name: \"Ada\".into(), age: 36 }, Person { name: \"Linus\".into(), age: 55 }]", "rust")).Success, TextOutput()); + Assert.True((await RunHeldAsync("nb-rust-bundles", "Person { name: \"Grace\".into(), age: 85 }", "rust")).Success, TextOutput()); + + var bundles = DisplayBundles().ToList(); + Assert.Equal(2, bundles.Count); + var table = Assert.IsType(bundles[0][PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table]); + Assert.Equal(["name", "age"], table["columns"]!.Select(c => (string?)c)); + Assert.Equal("Linus", (string?)table["rows"]![1]![0]); + AssertObjectExplorerBundle(bundles[1], "Person", "name"); + } + + [FrySharp.Core.Tests.RealRustFact] + public async Task Notebook_Rust_ACompileError_IsOnTheCellsLine_WithItsColumn() + { + var result = await RunHeldAsync("nb-rust-error", "let a = 1;\n\nlet s: String = 5;\nlet c = a;", "rust"); + + Assert.False(result.Success); + var error = Assert.Single(result.Diagnostics!, d => d.Severity == "error"); + Assert.Equal(3, error.StartLine); + Assert.Equal(17, error.StartColumn); + Assert.Contains("mismatched types", error.Message); + Assert.Contains("--> cell:3:17", TextOutput()); + } + + [FrySharp.Core.Tests.RealRustFact] + public async Task Notebook_Rust_APanic_NamesTheLineOfTheCell() + { + var result = await RunHeldAsync("nb-rust-panic", "let v: Vec = Vec::new();\n\nprintln!(\"{}\", v[3]);", "rust"); + + Assert.False(result.Success); + Assert.Equal(3, Assert.Single(result.Diagnostics!).StartLine); + Assert.Contains("panicked at cell:3:", TextOutput()); + } + + [FrySharp.Core.Tests.RealRustFact] + public async Task Notebook_Rust_FryInput_AsksTheClient() + { + _harness.InputAnswer = _ => "Ada"; + + var result = await RunHeldAsync("nb-rust-input", "let name = fry::input(\"Name? \").unwrap_or_default();\nprintln!(\"Hello, {}\", name);", "rust"); + + Assert.True(result.Success, TextOutput()); + Assert.Equal("Name? ", Assert.Single(_harness.InputRequests).Prompt); + Assert.Contains("Hello, Ada", TextOutput()); + } + + [FrySharp.Core.Tests.RealRustFact] + public async Task Notebook_Rust_TakesAListFromCSharp_AndGivesOneBack() + { + Assert.True((await RunHeldAsync("nb-rust-share", "var rows = new[] { 1, 2, 3 };")).Success); + + var inRust = await RunHeldAsync("nb-rust-share", "#!share --from csharp rows\nprintln!(\"{}\", rows.iter().sum::());", "rust"); + Assert.True(inRust.Success, TextOutput()); + Assert.Contains("6", TextOutput()); + + Assert.True((await RunHeldAsync("nb-rust-share", "let squares = vec![1, 4, 9];\nfry::share!(squares);", "rust")).Success, TextOutput()); + var back = await RunHeldAsync("nb-rust-share", "#!share --from rust squares\nConsole.WriteLine(string.Join(\",\", squares));"); + Assert.True(back.Success, TextOutput()); + Assert.Contains("1,4,9", TextOutput()); + } + + [FrySharp.Core.Tests.RealRustFact] + public async Task Notebook_Rust_TheNotebooksValues_AreTheVariables() + { + Assert.True((await RunHeldAsync("nb-rust-vars", "let kept = 7;\nlet names = vec![\"a\"];", "rust")).Success, TextOutput()); + + var variables = await _harness.ClientRpc.InvokeWithParameterObjectAsync>( + "fry/notebook/variables", new NotebookRef(NotebookId: "nb-rust-vars")); + + Assert.Equal(["kept", "names"], variables.Select(v => v.Name).Order()); + } + + // ── Go cells: each cell built by the Go toolchain, what a cell makes kept for the next, errors on the cell's own lines ───── + + [Fact] + public async Task Initialize_ListsGo_AsALanguageThatRunsCells() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + var go = Assert.Single(result.Languages, l => l.Id == "go"); + Assert.True(go.CanRunCells); + Assert.Contains("golang", go.Aliases); + Assert.Contains(".go", go.Extensions); + } + + [FrySharp.Core.Tests.RealGoFact] + public async Task Notebook_Go_KeepsWhatACellMakes_AndShowsTheLastValue() + { + Assert.True((await RunHeldAsync("nb-go", "func twice(n int) int { return n * 2 }\nx := 5", "go")).Success, TextOutput()); + + var result = await RunHeldAsync("nb-go", "twice(x) + 1", "go"); + + Assert.True(result.Success, TextOutput()); + Assert.Contains("11", TextOutput()); + } + + [FrySharp.Core.Tests.RealGoFact] + public async Task Notebook_Go_AListOfStructsArrivesAsATable_AStructAsAnObjectExplorer() + { + Assert.True((await RunHeldAsync("nb-go-bundles", "type Person struct {\n\tName string\n\tAge int\n}", "go")).Success, TextOutput()); + + Assert.True((await RunHeldAsync("nb-go-bundles", "[]Person{{\"Ada\", 36}, {\"Linus\", 55}}", "go")).Success, TextOutput()); + Assert.True((await RunHeldAsync("nb-go-bundles", "Person{\"Grace\", 85}", "go")).Success, TextOutput()); + + var bundles = DisplayBundles().ToList(); + Assert.Equal(2, bundles.Count); + var table = Assert.IsType(bundles[0][PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table]); + Assert.Equal(["Name", "Age"], table["columns"]!.Select(c => (string?)c)); + Assert.Equal("Linus", (string?)table["rows"]![1]![0]); + AssertObjectExplorerBundle(bundles[1], "Person", "Name"); + } + + [FrySharp.Core.Tests.RealGoFact] + public async Task Notebook_Go_ACompileError_IsOnTheCellsLine_WithItsColumn() + { + var result = await RunHeldAsync("nb-go-error", "a := 1\n\nvar s string = 5\nfmt.Println(a, s)", "go"); + + Assert.False(result.Success); + var error = Assert.Single(result.Diagnostics!, d => d.Severity == "error"); + Assert.Equal(3, error.StartLine); + Assert.Equal(16, error.StartColumn); + Assert.Contains("cannot use 5", error.Message); + Assert.Contains("cell:3:16", TextOutput()); + } + + [FrySharp.Core.Tests.RealGoFact] + public async Task Notebook_Go_APanic_NamesTheLineOfTheCell() + { + var result = await RunHeldAsync("nb-go-panic", "v := []int{}\n\nfmt.Println(v[3])", "go"); + + Assert.False(result.Success); + Assert.Equal(3, Assert.Single(result.Diagnostics!).StartLine); + Assert.Contains("cell:3", TextOutput()); + } + + [FrySharp.Core.Tests.RealGoFact] + public async Task Notebook_Go_FryInput_AsksTheClient() + { + _harness.InputAnswer = _ => "Ada"; + + var result = await RunHeldAsync("nb-go-input", "name, _ := fry.Input(\"Name? \")\nfmt.Println(\"Hello,\", name)", "go"); + + Assert.True(result.Success, TextOutput()); + Assert.Equal("Name? ", Assert.Single(_harness.InputRequests).Prompt); + Assert.Contains("Hello, Ada", TextOutput()); + } + + [FrySharp.Core.Tests.RealGoFact] + public async Task Notebook_Go_TakesAListFromCSharp_AndGivesOneBack() + { + Assert.True((await RunHeldAsync("nb-go-share", "var rows = new[] { 1, 2, 3 };")).Success); + + var inGo = await RunHeldAsync("nb-go-share", "#!share --from csharp rows\ntotal := 0\nfor _, n := range rows {\n\ttotal += n\n}\nfmt.Println(len(rows), \"rows, sum\", total)", "go"); + Assert.True(inGo.Success, TextOutput()); + Assert.Contains("3 rows, sum 6", TextOutput()); + + Assert.True((await RunHeldAsync("nb-go-share", "squares := []int{1, 4, 9}", "go")).Success, TextOutput()); + var back = await RunHeldAsync("nb-go-share", "#!share --from go squares\nConsole.WriteLine(string.Join(\",\", squares));"); + Assert.True(back.Success, TextOutput()); + Assert.Contains("1,4,9", TextOutput()); + } + + [FrySharp.Core.Tests.RealGoFact] + public async Task Notebook_Go_TheNotebooksValues_AreTheVariables() + { + Assert.True((await RunHeldAsync("nb-go-vars", "kept := 7\nnames := []string{\"a\"}", "go")).Success, TextOutput()); + + var variables = await _harness.ClientRpc.InvokeWithParameterObjectAsync>( + "fry/notebook/variables", new NotebookRef(NotebookId: "nb-go-vars")); + + Assert.Equal(["kept", "names"], variables.Select(v => v.Name).Order()); + Assert.Equal("int", variables.Single(v => v.Name == "kept").Type); + } + + // ── C++ cells: each cell built by the compiler, what a cell makes kept for the next, errors on the cell's own lines ───── + + [Fact] + public async Task Initialize_ListsCpp_AsALanguageThatRunsCells() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + var cpp = Assert.Single(result.Languages, l => l.Id == "cpp"); + Assert.True(cpp.CanRunCells); + Assert.Contains("c++", cpp.Aliases); + Assert.Contains(".cpp", cpp.Extensions); + } + + [FrySharp.Core.Tests.RealCppFact] + public async Task Notebook_Cpp_KeepsWhatACellMakes_AndShowsTheLastValue() + { + Assert.True((await RunHeldAsync("nb-cpp", "int twice(int n) { return n * 2; }\nint x = 5;", "cpp")).Success, TextOutput()); + + var result = await RunHeldAsync("nb-cpp", "twice(x) + 1", "cpp"); + + Assert.True(result.Success, TextOutput()); + Assert.Contains("11", TextOutput()); + } + + [FrySharp.Core.Tests.RealCppFact] + public async Task Notebook_Cpp_AListOfStructsArrivesAsATable_AStructAsAnObjectExplorer() + { + Assert.True((await RunHeldAsync("nb-cpp-bundles", "struct Person {\n std::string name;\n int age;\n};", "cpp")).Success, TextOutput()); + + Assert.True((await RunHeldAsync("nb-cpp-bundles", "std::vector{{\"Ada\", 36}, {\"Linus\", 55}}", "cpp")).Success, TextOutput()); + Assert.True((await RunHeldAsync("nb-cpp-bundles", "Person{\"Grace\", 85}", "cpp")).Success, TextOutput()); + + var bundles = DisplayBundles().ToList(); + Assert.Equal(2, bundles.Count); + var table = Assert.IsType(bundles[0][PdfEditorApp.Plugins.CSharpEditor.Visuals.Json.VisualMimeTypes.Table]); + Assert.Equal(["name", "age"], table["columns"]!.Select(c => (string?)c)); + Assert.Equal("Linus", (string?)table["rows"]![1]![0]); + AssertObjectExplorerBundle(bundles[1], "Person", "name"); + } + + [FrySharp.Core.Tests.RealCppFact] + public async Task Notebook_Cpp_ACompileError_IsOnTheCellsLine_WithItsColumn() + { + var result = await RunHeldAsync("nb-cpp-error", "int a = 1;\n\nint s = \"text\";\nstd::cout << a << s;", "cpp"); + + Assert.False(result.Success); + var error = Assert.Single(result.Diagnostics!, d => d.Severity == "error"); + Assert.Equal(3, error.StartLine); + Assert.Equal(5, error.StartColumn); // the name the compiler points at: int s = "text"; + Assert.Contains("cell:3:5", TextOutput()); + } + + [FrySharp.Core.Tests.RealCppFact] + public async Task Notebook_Cpp_AnUncaughtException_NamesItsCause() + { + var result = await RunHeldAsync("nb-cpp-throw", "throw std::runtime_error(\"boom\");", "cpp"); + + Assert.False(result.Success); + Assert.Contains("boom", TextOutput()); + } + + [FrySharp.Core.Tests.RealCppFact] + public async Task Notebook_Cpp_FryInput_AsksTheClient() + { + _harness.InputAnswer = _ => "Ada"; + + var result = await RunHeldAsync("nb-cpp-input", "auto name = fry::input(\"Name? \");\nstd::cout << \"Hello, \" << name.value_or(\"?\") << \"\\n\";", "cpp"); + + Assert.True(result.Success, TextOutput()); + Assert.Equal("Name? ", Assert.Single(_harness.InputRequests).Prompt); + Assert.Contains("Hello, Ada", TextOutput()); + } + + [FrySharp.Core.Tests.RealCppFact] + public async Task Notebook_Cpp_TakesAListFromCSharp_AndGivesOneBack() + { + Assert.True((await RunHeldAsync("nb-cpp-share", "var rows = new[] { 1, 2, 3 };")).Success); + + var inCpp = await RunHeldAsync("nb-cpp-share", "#!share --from csharp rows\nlong long total = 0;\nfor (auto n : rows) total += n;\nstd::cout << rows.size() << \" rows, sum \" << total << \"\\n\";", "cpp"); + Assert.True(inCpp.Success, TextOutput()); + Assert.Contains("3 rows, sum 6", TextOutput()); + + Assert.True((await RunHeldAsync("nb-cpp-share", "std::vector squares{1, 4, 9};", "cpp")).Success, TextOutput()); + var back = await RunHeldAsync("nb-cpp-share", "#!share --from cpp squares\nConsole.WriteLine(string.Join(\",\", squares));"); + Assert.True(back.Success, TextOutput()); + Assert.Contains("1,4,9", TextOutput()); + } + + [FrySharp.Core.Tests.RealCppFact] + public async Task Notebook_Cpp_TheNotebooksValues_AreTheVariables() + { + Assert.True((await RunHeldAsync("nb-cpp-vars", "int kept = 7;\nstd::vector names{\"a\"};", "cpp")).Success, TextOutput()); + + var variables = await _harness.ClientRpc.InvokeWithParameterObjectAsync>( + "fry/notebook/variables", new NotebookRef(NotebookId: "nb-cpp-vars")); + + Assert.Equal(["kept", "names"], variables.Select(v => v.Name).Order()); + Assert.Equal("int", variables.Single(v => v.Name == "kept").Type); + } + + [Fact] + public async Task Execute_AFailedCell_SaysWhyInALine_AndAStoppedOrSuccessfulOneSaysNothing() + { + var compile = await RunHeldAsync("nb-error-line", "int x = \"text\";\nstring y = 5;"); + Assert.False(compile.Success); + Assert.NotNull(compile.Error); + Assert.StartsWith("CS", compile.Error!.Name); + Assert.Contains("(and 1 more)", compile.Error.Message); + + var thrown = await RunHeldAsync("nb-error-line", "throw new InvalidOperationException(\"boom\");"); + Assert.False(thrown.Success); + Assert.Contains("boom", thrown.Error!.Message); + + Assert.Null((await RunHeldAsync("nb-error-line", "1 + 1")).Error); + } + + [Fact] + public async Task EngineInfo_SaysWhatItRunsOnAndWhichToolEachLanguageFound() + { + await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/initialize", new InitializeParams("test-client")); + + var info = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/engine/info", new EmptyParams()); + + Assert.Equal(ProtocolVersion.Current, info.ProtocolVersion); + Assert.StartsWith(".NET", info.Runtime); + Assert.False(string.IsNullOrEmpty(info.OperatingSystem)); + Assert.Equal(_harness.Workspace.Path, info.WorkspaceRoot); + // C# is in the engine, so it is always found; every other language says what it found, or what is missing and how to get it + var csharp = Assert.Single(info.Toolchains, t => t.LanguageId == "csharp"); + Assert.True(csharp.Found); + Assert.Contains(info.Toolchains, t => t.LanguageId == "python"); + Assert.Contains(info.Toolchains, t => t.LanguageId == "java"); + Assert.All(info.Toolchains, t => + { + if (t.Found) Assert.False(string.IsNullOrEmpty(t.Name)); + else Assert.False(string.IsNullOrWhiteSpace(t.Missing), $"{t.DisplayName} is missing and does not say how to get it"); + }); + Assert.Equal(info.Toolchains.Select(t => t.DisplayName).OrderBy(n => n, StringComparer.OrdinalIgnoreCase), info.Toolchains.Select(t => t.DisplayName)); + } + + [Fact] + public async Task CellDiagnostics_ASqlDirectiveThatIsNotOne_IsReported_AsInAnyLanguage() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/diagnoseCell", + new CellDiagnosticsParams("sql", "#!sqll\nselect 1;", null)); + + Assert.Equal("FRY0001", Assert.Single(result.Diagnostics).Code); + } + + [Fact] + public async Task CellCompletion_IsForCSharp_OtherLanguagesGetNone() + { + Assert.Empty(await CompleteAsync("import os\nos.", "os.", language: "python")); + } + + [Fact] + public async Task CellDiagnostics_AreInTheCellsOwnLines_NotInTheJoinedText() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/diagnoseCell", + new CellDiagnosticsParams("csharp", "int fine = 1;\nint bad = ;", ["using System.Text;", "int a = 1;\nint b = 2;\nint c = 3;"])); + + var error = Assert.Single(result.Diagnostics); + Assert.Equal(2, error.StartLine); + Assert.StartsWith("CS", error.Code); + } + + [Fact] + public async Task CellDiagnostics_AVariableAnEarlierCellDefines_IsNotUndefined_AndRedefiningItIsAllowed() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/diagnoseCell", + new CellDiagnosticsParams("csharp", "var total = count + 1;\nstring name = \"again\";\nConsole.WriteLine(total + name);", ["int count = 41;", "string name = \"first\";"])); + + Assert.Empty(result.Diagnostics); + } + + [Fact] + public async Task CellDiagnostics_AnUndefinedName_IsReported_WhenNoCellDefinesIt() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/diagnoseCell", + new CellDiagnosticsParams("csharp", "Console.WriteLine(missing);", ["int other = 1;"])); + + Assert.Contains(result.Diagnostics, d => d.Code == "CS0103" && d.StartLine == 1); + } + + [Fact] + public async Task CellDiagnostics_ADirectiveTypo_IsReported_InAnyLanguage() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/diagnoseCell", + new CellDiagnosticsParams("python", "#!pyton\nprint(1)")); + + var error = Assert.Single(result.Diagnostics); + Assert.Equal(1, error.StartLine); + Assert.Contains("isn't a directive", error.Message); + } + + [Fact] + public async Task CellDiagnostics_ACellWithNoProblems_HasNone() + { + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/diagnoseCell", + new CellDiagnosticsParams("python", "#!python\nprint(1)")); + + Assert.Empty(result.Diagnostics); + } + + [Fact] + public async Task CellHover_SaysWhatAMemberOfAnEarlierCellsVariableIs() + { + var source = "s.Length"; + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/hoverCell", + new CellPositionParams("csharp", source, source.IndexOf("Length", StringComparison.Ordinal) + 2, ["var s = \"abc\";"])); + + Assert.NotNull(result); + Assert.Contains("int", string.Concat(result!.Markdown)); + } + + [Fact] + public async Task CellCompletion_Sql_OffersKeywordsFunctionsAndTheTablesTheCellsBeforeCreated() + { + var keywords = await CompleteAsync("SEL", "SEL", language: "sql"); + var tables = await CompleteAsync("select * from peo", "peo", context: ["create table people (id integer);", "create view people_names as select 1;"], language: "sql"); + var functions = await CompleteAsync("select CO", "CO", language: "sql"); + + Assert.Contains("SELECT", keywords); + Assert.Equal(["people", "people_names"], tables.Order()); + Assert.Contains("COUNT", functions); + Assert.Contains("COALESCE", functions); + } + + [Fact] + public async Task CellCompletion_Sql_OffersTheDotCommandsOfSqlite() + { + var labels = await CompleteAsync(".ta", ".ta", language: "sql"); + + Assert.Equal([".tables"], labels); + } + + [Fact] + public async Task CellHover_Sql_SaysWhatAKeywordIs() + { + var source = "select name from people group by name"; + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/hoverCell", + new CellPositionParams("sql", source, source.IndexOf("group", StringComparison.Ordinal) + 2, null)); + + Assert.NotNull(result); + var text = string.Concat(result!.Markdown); + Assert.Contains("GROUP BY column1, column2", text); + Assert.Contains("Groups summary rows", text); + Assert.StartsWith("```sql", result.Markdown[0]); + } + + [Fact] + public async Task CellHover_Sql_SaysNothingAboutAWordThatIsNotKnown() + { + var source = "select anything from somewhere"; + var result = await _harness.ClientRpc.InvokeWithParameterObjectAsync("fry/language/hoverCell", + new CellPositionParams("sql", source, source.IndexOf("anything", StringComparison.Ordinal) + 2, null)); + + Assert.Null(result); + } } diff --git a/Tests/CodeStudio/CSharpCodeStudioExplorerTests.cs b/Tests/CodeStudio/CSharpCodeStudioExplorerTests.cs index 4ce198fe..3f39842a 100644 --- a/Tests/CodeStudio/CSharpCodeStudioExplorerTests.cs +++ b/Tests/CodeStudio/CSharpCodeStudioExplorerTests.cs @@ -33,15 +33,18 @@ public void Dispose() catch { } } - private CSharpCodeStudioViewModel CreateStudio(ScriptDocumentItem script) + // A studio lists its folder in the background when it is made: wait for the listing, so the Explorer has its files. + private async Task CreateStudioAsync(ScriptDocumentItem script) { - return new CSharpCodeStudioViewModel( + var studio = new CSharpCodeStudioViewModel( script, _testStorage, new RoslynCompilerService(), new ScriptExecutionEngine(), backToHubAction: () => { }, backToHomeAction: () => { }); + await studio.RefreshExplorerAsync(); + return studio; } [Fact] @@ -50,7 +53,7 @@ public async Task PopulateExplorerTree_WithLibraryScripts_ListsThemAtRoot() var script = await _testStorage.CreateNewScriptAsync("Main Script"); await _testStorage.CreateNewScriptAsync("Sibling Script"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); Assert.Contains(studio.ExplorerRootItems, x => !x.IsDirectory && x.Name == "Main Script.frycs"); Assert.Contains(studio.ExplorerRootItems, x => !x.IsDirectory && x.Name == "Sibling Script.frycs"); @@ -71,7 +74,7 @@ public async Task TheExplorer_ShowsEverythingInTheFolder_ExceptWhatVsCodeHides_A await _testStorage.OpenExternalProjectAsync(root); var outsider = await _testStorage.CreateNewScriptAsync("Outsider", folderPath: Path.Combine(_testBaseDir, "elsewhere")); - var studio = CreateStudio(outsider); + var studio = await CreateStudioAsync(outsider); await studio.RefreshExplorerAsync(); var top = studio.ExplorerRootItems.Select(i => i.Name).ToList(); @@ -106,7 +109,7 @@ public async Task PopulateExplorerTree_ForScriptOutsideActiveRoot_DoesNotListIt( try { var script = await _testStorage.CreateNewScriptAsync("External Script", folderPath: externalDir); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); Assert.DoesNotContain(studio.ExplorerRootItems, x => x.Name == "External Script.frycs"); Assert.Equal(script.Id, studio.Script.Id); // still open, just not in this folder's list @@ -128,7 +131,7 @@ public async Task PopulateExplorerTree_WithUnrelatedExternalScript_DoesNotShowIt var openScript = await _testStorage.CreateNewScriptAsync("Open One", folderPath: relevantDir); await _testStorage.CreateNewScriptAsync("Untouched", folderPath: unrelatedDir); - var studio = CreateStudio(openScript); + var studio = await CreateStudioAsync(openScript); // Neither external folder is the active workspace root, so neither document is listed. Assert.DoesNotContain(studio.ExplorerRootItems, x => x.Name is "Open One.frycs" or "Untouched.frycs"); @@ -154,7 +157,7 @@ public async Task SwitchToScriptAsync_ToDifferentScript_UpdatesScriptAndCodeAndF second.Code = "// second script code"; await _testStorage.SaveScriptAsync(second); - var studio = CreateStudio(first); + var studio = await CreateStudioAsync(first); studio.Code = "// edited in memory, unsaved"; var reloadFired = false; @@ -175,7 +178,7 @@ public async Task SwitchToScriptAsync_ToDifferentScript_UpdatesScriptAndCodeAndF public async Task SwitchToScriptAsync_ToAlreadyOpenScript_DoesNothingAndDoesNotFireReload() { var script = await _testStorage.CreateNewScriptAsync("Solo Script"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); var reloadFired = false; studio.RequestReloadEditorText += () => reloadFired = true; @@ -190,7 +193,7 @@ public async Task SwitchToScriptAsync_ToAlreadyOpenScript_DoesNothingAndDoesNotF public async Task NewScriptCommand_WithNoSelection_CreatesAtLibraryRootAndSwitchesToIt() { var initial = await _testStorage.CreateNewScriptAsync("Initial Script"); - var studio = CreateStudio(initial); + var studio = await CreateStudioAsync(initial); await studio.NewScript(); @@ -214,7 +217,7 @@ public async Task DeleteExplorerItemAsync_OnFolderInsideOpenedExternalRoot_Delet await _testStorage.OpenExternalProjectAsync(externalDir); var subFolder = await _testStorage.CreateFolderAsync(null, "SubFolder"); var script = await _testStorage.CreateNewScriptAsync("Inside", folderPath: subFolder); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); var folderNode = studio.ExplorerRootItems.Single(x => x.IsDirectory); Assert.True(folderNode.IsManageableDirectory); @@ -238,7 +241,7 @@ public async Task DeleteExplorerItemAsync_LastScriptInFolder_FolderStaysVisibleA var openScript = await _testStorage.CreateNewScriptAsync("Kept Open"); var folder = await _testStorage.CreateFolderAsync(null, "SoloFolder"); await _testStorage.CreateNewScriptAsync("Only One", folderPath: folder); - var studio = CreateStudio(openScript); + var studio = await CreateStudioAsync(openScript); var summaries = await _testStorage.LoadWorkspaceSummariesAsync(); var docId = summaries.Single(s => s.Title == "Only One").Id; @@ -262,7 +265,7 @@ public async Task DuplicateExplorerItemAsync_ForDocumentInOpenedExternalRoot_Kee { await _testStorage.OpenExternalProjectAsync(externalDir); var script = await _testStorage.CreateNewScriptAsync("Original"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); var originalItem = studio.ExplorerRootItems.Single(x => !x.IsDirectory); await studio.DuplicateExplorerItemAsync(originalItem); @@ -282,7 +285,7 @@ public async Task DuplicateExplorerItemAsync_ForDocumentInOpenedExternalRoot_Kee public async Task OnItemRenamedAsync_ForCurrentlyOpenScript_UpdatesScriptTitleAndPersists() { var script = await _testStorage.CreateNewScriptAsync("Old Name"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); var item = studio.ExplorerRootItems.Single(x => x.DocumentId == script.Id); item.Name = "New Name.frycs"; @@ -302,7 +305,7 @@ public async Task UpdateActiveScriptAsync_OnSingleThreadedUiLikeContext_DoesNotD { var first = await _testStorage.CreateNewScriptAsync("First", folderPath: externalDir); var second = await _testStorage.CreateNewScriptAsync("Second", folderPath: externalDir); - var studio = CreateStudio(first); + var studio = await CreateStudioAsync(first); var pump = new SingleThreadSynchronizationContext(); var completed = new TaskCompletionSource(); @@ -356,7 +359,7 @@ public async Task OpenExternalProjectAsync_ValidScriptFile_UpdatesActiveScriptAn await File.WriteAllTextAsync(externalFile, "System.Console.WriteLine(\"Imported!\");"); var initialScript = await _testStorage.CreateNewScriptAsync("Initial"); - var studio = CreateStudio(initialScript); + var studio = await CreateStudioAsync(initialScript); await studio.OpenExternalProjectAsync(externalFile); @@ -415,7 +418,7 @@ public async Task NewServerCommand_CreatesServerOnDisk_AndInvokesOpenAction() public async Task NewNotebookUnderItemAsync_CreatesNotebookInTargetFolder() { var script = await _testStorage.CreateNewScriptAsync("TestScript"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); var subFolder = Path.Combine(_testStorage.ActiveWorkspaceRootPath, "analytics"); Directory.CreateDirectory(subFolder); await studio.RefreshExplorerAsync(); @@ -433,7 +436,7 @@ public async Task NewNotebookUnderItemAsync_CreatesNotebookInTargetFolder() public async Task NewServerUnderItemAsync_CreatesServerInTargetFolder() { var script = await _testStorage.CreateNewScriptAsync("TestScript"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); var subFolder = Path.Combine(_testStorage.ActiveWorkspaceRootPath, "services"); Directory.CreateDirectory(subFolder); await studio.RefreshExplorerAsync(); diff --git a/Tests/CodeStudio/CSharpEditorShortcutRegistrationTests.cs b/Tests/CodeStudio/CSharpEditorShortcutRegistrationTests.cs index 90f87dbd..6d21aec3 100644 --- a/Tests/CodeStudio/CSharpEditorShortcutRegistrationTests.cs +++ b/Tests/CodeStudio/CSharpEditorShortcutRegistrationTests.cs @@ -4,6 +4,19 @@ namespace CSharpEditorPlugin.Tests; +/// +/// Tests that apply the real plugin and let go of it. Letting go of the plugin ends every program it started +/// (ProcessRegistry.KillAll()), and that registry is the test process's own: so while one of these runs, no other test +/// may be running a program, or its dotnet fsi, python or java is killed under it (exit code 137). A +/// collection that doesn't run in parallel runs by itself, after the others. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class PluginLifecycleCollection +{ + public const string Name = "PluginLifecycle"; +} + +[Collection(PluginLifecycleCollection.Name)] public class CSharpEditorShortcutRegistrationTests { [Fact] diff --git a/Tests/CodeStudio/CodeStudioSourceFileTests.cs b/Tests/CodeStudio/CodeStudioSourceFileTests.cs index b04b1add..4b62e173 100644 --- a/Tests/CodeStudio/CodeStudioSourceFileTests.cs +++ b/Tests/CodeStudio/CodeStudioSourceFileTests.cs @@ -35,11 +35,16 @@ public void Dispose() } } - private CSharpCodeStudioViewModel Studio(ScriptDocumentItem script) => - new(script, _storage, new RoslynCompilerService(), new ScriptExecutionEngine(), + // A studio lists its folder in the background when it is made: wait for the listing, so the Explorer has its files. + private async Task StudioAsync(ScriptDocumentItem script) + { + var studio = new CSharpCodeStudioViewModel(script, _storage, new RoslynCompilerService(), new ScriptExecutionEngine(), backToHubAction: () => { }, blindProgress: new LocalBlindProgressService(Path.Combine(_baseDir, "progress")), languages: _languages); + await studio.RefreshExplorerAsync(); + return studio; + } private string WriteSource(string relativePath, string text) { @@ -77,7 +82,7 @@ private static IEnumerable All(IEnumerable i.Name == "main.py"); @@ -90,7 +95,7 @@ public async Task TheExplorer_ShowsSourceFiles_WithTheirLanguagesIcon() public async Task OpeningASourceFile_GivesItATabWithItsLanguage() { WriteSource("main.py", "print('hi')\n"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var item = All(studio.ExplorerRootItems).Single(i => i.Name == "main.py"); await studio.SwitchToScriptAsync(item); @@ -106,7 +111,7 @@ public async Task OpeningASourceFile_GivesItATabWithItsLanguage() [Fact] public async Task NewFileOptions_OfferEverySourceFileLanguage() { - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); Assert.Equal(8, studio.NewFileOptions.Count); var python = Assert.Single(studio.NewFileOptions, o => o.LanguageId == LanguageIds.Python); @@ -140,7 +145,7 @@ public async Task NewFileOptions_OfferEverySourceFileLanguage() public async Task ANewSourceFile_IsCreatedInTheSelectedFolder_OpenedAndReadyToRename() { Directory.CreateDirectory(Path.Combine(_storage.LibraryRootPath, "tools")); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var folder = All(studio.ExplorerRootItems).Single(i => i.IsDirectory && i.Name == "tools"); await studio.NewFileOptions.First(o => o.LanguageId == LanguageIds.Python).Command.ExecuteAsync(folder); @@ -156,7 +161,7 @@ public async Task ANewSourceFile_IsCreatedInTheSelectedFolder_OpenedAndReadyToRe public async Task RenamingInTheExplorer_MovesTheFile_AndTheOpenTabFollows() { var path = WriteSource("draft.py", "print('draft')\n"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var item = All(studio.ExplorerRootItems).Single(i => i.Name == "draft.py"); await studio.SwitchToScriptAsync(item); @@ -177,7 +182,7 @@ public async Task RenamingInTheExplorer_MovesTheFile_AndTheOpenTabFollows() public async Task DeletingASourceFile_AsksFirst() { var path = WriteSource("keep.py", "print('keep')\n"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var item = All(studio.ExplorerRootItems).Single(i => i.Name == "keep.py"); item.RequestDelete(); @@ -198,7 +203,7 @@ public async Task DeletingASourceFile_AsksFirst() public async Task Duplicating_CopiesTheFile_WithTheEditorsText() { WriteSource("tool.py", "print('v1')\n"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); await studio.SwitchToScriptAsync(All(studio.ExplorerRootItems).Single(i => i.Name == "tool.py")); studio.Code = "print('v2')\n"; @@ -213,7 +218,7 @@ public async Task Duplicating_CopiesTheFile_WithTheEditorsText() public async Task SwitchingAway_DoesntSaveOverAnotherEditorsChange() { var path = WriteSource("shared.py", "print('original')\n"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); await studio.SwitchToScriptAsync(All(studio.ExplorerRootItems).Single(i => i.Name == "shared.py")); studio.Code = "print('studio')\n"; File.WriteAllText(path, "print('other editor')\n"); @@ -228,7 +233,7 @@ public async Task SwitchingAway_DoesntSaveOverAnotherEditorsChange() public async Task CtrlS_SavesTheEditorsText_OverAnotherEditorsChange() { var path = WriteSource("shared.py", "print('original')\n"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); await studio.SwitchToScriptAsync(All(studio.ExplorerRootItems).Single(i => i.Name == "shared.py")); studio.Code = "print('studio')\n"; File.WriteAllText(path, "print('other editor')\n"); @@ -262,7 +267,7 @@ public async Task TheNotebookExplorer_OpensSourceFilesInTheCodeStudio() public async Task OpeningAnImageFile_ActivatesVisualImageViewer_WithAccurateMetadata() { CopyOrWriteImage("assets/app-logo.png"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var item = Assert.Single(All(studio.ExplorerRootItems), i => i.Name == "app-logo.png"); await studio.SwitchToScriptAsync(item); @@ -295,7 +300,7 @@ public async Task OpeningAnImageFile_ActivatesVisualImageViewer_WithAccurateMeta public async Task OpeningAMarkdownFile_TogglesBetweenPreviewAndEditModes() { var mdPath = WriteSource("docs/guide.md", "# Guide\n\nWelcome to **C# Studio**.\n"); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var item = Assert.Single(All(studio.ExplorerRootItems), i => i.Name == "guide.md"); await studio.SwitchToScriptAsync(item); @@ -333,7 +338,7 @@ public async Task ABigCsvFile_IsTurnedIntoATableInTheBackground_AndOnlyTheNewest var csv = new System.Text.StringBuilder("id,name,score\n"); for (int i = 0; i < 20_000; i++) csv.Append(i).Append(",name ").Append(i).Append(',').Append(i % 100).Append('\n'); WriteSource("data/big.csv", csv.ToString()); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var item = Assert.Single(All(studio.ExplorerRootItems), i => i.Name == "big.csv"); await studio.SwitchToScriptAsync(item); @@ -365,7 +370,7 @@ public async Task OpeningACsvFile_TogglesBetweenTablePreviewAndRawTextModes() 3,"Austin, TX",89.0,true """; WriteSource("data/cities.csv", csvContent); - var studio = Studio(await _storage.CreateNewScriptAsync("Notes")); + var studio = await StudioAsync(await _storage.CreateNewScriptAsync("Notes")); var item = Assert.Single(All(studio.ExplorerRootItems), i => i.Name == "cities.csv"); await studio.SwitchToScriptAsync(item); diff --git a/Tests/CodeStudio/ExplorerRowListTests.cs b/Tests/CodeStudio/ExplorerRowListTests.cs index 950d52d2..a48094b8 100644 --- a/Tests/CodeStudio/ExplorerRowListTests.cs +++ b/Tests/CodeStudio/ExplorerRowListTests.cs @@ -245,13 +245,19 @@ public void Dispose() } } - private CSharpCodeStudioViewModel CreateStudio(PdfEditorApp.Plugins.CSharpEditor.Models.ScriptDocumentItem script) => new( - script, - _storage, - new RoslynCompilerService(), - new ScriptExecutionEngine(), - backToHubAction: () => { }, - backToHomeAction: () => { }); + // A studio lists its folder in the background when it is made: wait for the listing, so the Explorer has its files. + private async Task CreateStudioAsync(PdfEditorApp.Plugins.CSharpEditor.Models.ScriptDocumentItem script) + { + var studio = new CSharpCodeStudioViewModel( + script, + _storage, + new RoslynCompilerService(), + new ScriptExecutionEngine(), + backToHubAction: () => { }, + backToHomeAction: () => { }); + await studio.RefreshExplorerAsync(); + return studio; + } [Fact] public async Task TheStudiosRows_ListFoldersCollapsed_AndOpeningOneRevealsItsFiles() @@ -261,7 +267,7 @@ public async Task TheStudiosRows_ListFoldersCollapsed_AndOpeningOneRevealsItsFil await _storage.CreateNewScriptAsync("Inside Two", folderPath: folder); var loose = await _storage.CreateNewScriptAsync("Loose"); - var studio = CreateStudio(loose); + var studio = await CreateStudioAsync(loose); Assert.Equal(new[] { "Algorithms", "Loose.frycs" }, studio.ExplorerRows.Rows.Select(r => r.Name)); @@ -276,7 +282,7 @@ public async Task OpeningAScriptInsideACollapsedFolder_ExpandsItSoItsRowIsThere( var folder = await _storage.CreateFolderAsync(null, "Deep"); var inside = await _storage.CreateNewScriptAsync("Buried", folderPath: folder); - var studio = CreateStudio(inside); + var studio = await CreateStudioAsync(inside); Assert.Contains(studio.ExplorerRows.Rows, r => r.DocumentId == inside.Id && r.IsSelected); } @@ -285,7 +291,7 @@ public async Task OpeningAScriptInsideACollapsedFolder_ExpandsItSoItsRowIsThere( public async Task RefreshingTheExplorer_KeepsTheRowsInStepWithTheTree() { var first = await _storage.CreateNewScriptAsync("One"); - var studio = CreateStudio(first); + var studio = await CreateStudioAsync(first); await _storage.CreateNewScriptAsync("Two"); await studio.RefreshExplorerAsync(); diff --git a/Tests/CodeStudio/StudioLoadingOverlayTests.cs b/Tests/CodeStudio/StudioLoadingOverlayTests.cs index 47cc4272..f1d343be 100644 --- a/Tests/CodeStudio/StudioLoadingOverlayTests.cs +++ b/Tests/CodeStudio/StudioLoadingOverlayTests.cs @@ -155,6 +155,9 @@ public async Task CodeStudio_TabSwitch_ReportsNoActivity_ButOpeningAFileDoes() var activities = new RecordingActivities(); var studio = new CSharpCodeStudioViewModel(first, storage, new RoslynCompilerService(), new ScriptExecutionEngine(), backToHubAction: () => { }, backToHomeAction: () => { }, activities: activities); + // A studio lists its folder in the background when it is made, and says so: that is not what is being measured. + await studio.RefreshExplorerAsync(); + activities.Started.Clear(); await studio.SwitchToScriptAsync(new ExplorerItemViewModel { Name = "Script 2.frycs", DocumentId = second.Id, FileExtension = ".frycs" }); var open = Assert.Single(activities.Started); diff --git a/Tests/Languages/Common/CodeStudioExternalLanguageTests.cs b/Tests/Languages/Common/CodeStudioExternalLanguageTests.cs index 7d8ef140..fa895e6b 100644 --- a/Tests/Languages/Common/CodeStudioExternalLanguageTests.cs +++ b/Tests/Languages/Common/CodeStudioExternalLanguageTests.cs @@ -53,6 +53,7 @@ private async Task StudioWith(string fileName, string backToHubAction: () => { }, blindProgress: new LocalBlindProgressService(Path.Combine(_baseDir, "progress")), languages: _languages); + await studio.RefreshExplorerAsync(); // the studio lists its folder in the background: wait for it var item = Flatten(studio.ExplorerRootItems).Single(i => i.Name == fileName); await studio.SwitchToScriptAsync(item); return studio; diff --git a/Tests/Languages/FSharp/FSharpPackageManagerTests.cs b/Tests/Languages/FSharp/FSharpPackageManagerTests.cs index eaa9bfe4..83c9f13e 100644 --- a/Tests/Languages/FSharp/FSharpPackageManagerTests.cs +++ b/Tests/Languages/FSharp/FSharpPackageManagerTests.cs @@ -1,4 +1,5 @@ using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; +using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; using Xunit; namespace CSharpEditorPlugin.Tests; @@ -8,26 +9,17 @@ public class FSharpPackageManagerTests private readonly FSharpPackageManager _manager = new(); [Theory] - [InlineData("#r \"nuget: Newtonsoft.Json\"", "Newtonsoft.Json")] - [InlineData("#r \"nuget: FSharp.Data, 6.4.0\"", "FSharp.Data")] - [InlineData("#r \"nuget: Plotly.NET\"", "Plotly.NET")] - [InlineData("%nuget install MathNet.Numerics", "MathNet.Numerics")] - [InlineData("!nuget add Serilog", "Serilog")] - public void ParsesDirectivesSuccessfully(string line, string expectedPkg) + [InlineData("#r \"nuget: Newtonsoft.Json\"")] + [InlineData("#r \"nuget: FSharp.Data, 6.4.0\"")] + [InlineData("%nuget install MathNet.Numerics")] + [InlineData("printfn \"hello\"")] + public void NoLineIsAPackageCommandToRunBeforehand_FsiLoadsItsOwnReferences(string line) { + // a notebook that claimed "#r nuget:" ran an installer that did nothing for F#, and removed the line fsi needed var parsed = _manager.TryParseDirective(line, out var command); - Assert.True(parsed); - Assert.NotNull(command); - Assert.Contains(expectedPkg, command.Arguments); - } - - [Fact] - public void NonPackageDirective_ReturnsFalse() - { - var parsed = _manager.TryParseDirective("printfn \"hello\"", out var command); Assert.False(parsed); - Assert.Null(command); + Assert.Empty(command.Arguments); } [Fact] @@ -37,4 +29,16 @@ public void InstallCommand_GeneratesFSharpNuGetDirective() Assert.Equal("#r \"nuget: FSharpPlus\"", command.Text); Assert.Equal(new[] { "install", "FSharpPlus" }, command.Arguments); } + + [Fact] + public async Task InstallingAPackage_IsALineForTheFile() + { + var said = new List(); + var toolchain = new ToolchainInfo { LanguageId = "fsharp", ExecutablePath = "dotnet", Version = new Version(10, 0), DisplayName = "dotnet fsi", Source = "test" }; + + var result = await _manager.RunAsync(_manager.InstallCommand("FSharp.Data, 6.4.0"), toolchain, said.Add); + + Assert.True(result.Success); + Assert.Equal("#r \"nuget: FSharp.Data, 6.4.0\"", result.DirectiveToInsert); + } } diff --git a/Tests/Languages/Go/GoBuildAndRunScriptRunnerTests.cs b/Tests/Languages/Go/GoBuildAndRunScriptRunnerTests.cs index 8e98aa1a..2e6b7521 100644 --- a/Tests/Languages/Go/GoBuildAndRunScriptRunnerTests.cs +++ b/Tests/Languages/Go/GoBuildAndRunScriptRunnerTests.cs @@ -12,7 +12,7 @@ public class GoBuildAndRunScriptRunnerTests public async Task GeneratesTwoPhaseExecutionPlan() { var host = new FakeHostEnvironment(FakeOs.MacOS); - var runner = new GoBuildAndRunScriptRunner(host); + var runner = new GoBuildAndRunScriptRunner(host, Path.Combine(Path.GetTempPath(), "FryPDF_GoRunnerTests")); var toolchain = new ToolchainInfo { @@ -51,7 +51,7 @@ public async Task GeneratesTwoPhaseExecutionPlan() public async Task OnWindows_GeneratesExeExtension() { var host = new FakeHostEnvironment(FakeOs.Windows); - var runner = new GoBuildAndRunScriptRunner(host); + var runner = new GoBuildAndRunScriptRunner(host, Path.Combine(Path.GetTempPath(), "FryPDF_GoRunnerTests")); var toolchain = new ToolchainInfo { diff --git a/Tests/Languages/Go/GoPackageManagerTests.cs b/Tests/Languages/Go/GoPackageManagerTests.cs index be42ccd5..b0761cf5 100644 --- a/Tests/Languages/Go/GoPackageManagerTests.cs +++ b/Tests/Languages/Go/GoPackageManagerTests.cs @@ -5,7 +5,7 @@ namespace CSharpEditorPlugin.Tests; public class GoPackageManagerTests { - private readonly GoPackageManager _manager = new(); + private readonly GoPackageManager _manager = new(Path.Combine(Path.GetTempPath(), "FryPDF_GoPackageManagerTests")); [Theory] [InlineData("%go get github.com/google/uuid", "github.com/google/uuid")] diff --git a/Tests/Languages/Rust/RustPackagesPanelTests.cs b/Tests/Languages/Rust/RustPackagesPanelTests.cs index ec5b5b28..e74c002c 100644 --- a/Tests/Languages/Rust/RustPackagesPanelTests.cs +++ b/Tests/Languages/Rust/RustPackagesPanelTests.cs @@ -42,6 +42,7 @@ private async Task StudioWith(string fileName, string backToHubAction: () => { }, blindProgress: new LocalBlindProgressService(Path.Combine(_baseDir, "progress")), languages: _languages); + await studio.RefreshExplorerAsync(); // the studio lists its folder in the background: wait for it await studio.SwitchToScriptAsync(Flatten(studio.ExplorerRootItems).Single(i => i.Name == fileName)); return studio; } diff --git a/Tests/Notebooks/CSharpNotebookStudioTabTests.cs b/Tests/Notebooks/CSharpNotebookStudioTabTests.cs index 625ae6cb..99ba4c7d 100644 --- a/Tests/Notebooks/CSharpNotebookStudioTabTests.cs +++ b/Tests/Notebooks/CSharpNotebookStudioTabTests.cs @@ -52,6 +52,14 @@ private CSharpNotebookStudioViewModel CreateStudio(NotebookDocumentItem? noteboo backToHubAction: () => { }, backToHomeAction: () => { }); } + // A studio lists its folder in the background when it is made: wait for the listing, so the Explorer has its files. + private async Task CreateListedStudioAsync(NotebookDocumentItem? notebook = null) + { + var studio = CreateStudio(notebook); + await studio.RefreshExplorerAsync(); + return studio; + } + private ExplorerItemViewModel EnsureSecondNotebookItem(CSharpNotebookStudioViewModel studio, string name = "Second_Notebook.frynb") { var existing = studio.ExplorerRootItems.FirstOrDefault(x => x.Name == name); @@ -87,7 +95,7 @@ public void Studio_InitialState_HasInitialTab_AndIsActive() [Fact] public async Task OpenDocument_MultipleFiles_DisplaysMultipleTabs() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); var secondFile = EnsureSecondNotebookItem(studio); @@ -104,7 +112,7 @@ public async Task OpenDocument_MultipleFiles_DisplaysMultipleTabs() public async Task OpenDocument_SameFileTwice_DoesNotDuplicateTabs() { // Saved in the workspace (the library, in these tests), so it is one of the Explorer's files. - var studio = CreateStudio(await _testStorage.CreateNewNotebookAsync("Document Automation Notebook")); + var studio = await CreateListedStudioAsync(await _testStorage.CreateNewNotebookAsync("Document Automation Notebook")); var docFile = studio.ExplorerRootItems.FirstOrDefault(x => x.Name == "Document Automation Notebook.frynb"); Assert.NotNull(docFile); @@ -119,7 +127,7 @@ public async Task OpenDocument_SameFileTwice_DoesNotDuplicateTabs() public async Task SelectTab_SwitchesActiveTab_AndUpdatesExplorerSelection() { // The first notebook is a file of the workspace (the Explorer lists nothing else). - var studio = CreateStudio(await _testStorage.CreateNewNotebookAsync("Document Automation Notebook")); + var studio = await CreateListedStudioAsync(await _testStorage.CreateNewNotebookAsync("Document Automation Notebook")); var secondFile = EnsureSecondNotebookItem(studio); await studio.OpenDocumentAsync(secondFile); @@ -140,7 +148,7 @@ public async Task SelectTab_SwitchesActiveTab_AndUpdatesExplorerSelection() [Fact] public async Task CloseTab_SwitchesToAdjacentTab() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); var secondFile = EnsureSecondNotebookItem(studio); await studio.OpenDocumentAsync(secondFile); @@ -238,7 +246,7 @@ public void SelectCell_WhenAlreadySelected_PreservesSelectionWithoutStateChurn() [Fact] public async Task NewNotebookTab_CreatesATab_ThatTheExplorerListsOnlyOnceSaved() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); var initialCount = studio.Tabs.Count; await studio.NewNotebookTab(); @@ -256,7 +264,7 @@ public async Task NewNotebookTab_CreatesATab_ThatTheExplorerListsOnlyOnceSaved() [Fact] public async Task DeleteExplorerItem_ClosesOpenTab() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); var secondFile = EnsureSecondNotebookItem(studio); await studio.OpenDocumentAsync(secondFile); @@ -272,7 +280,7 @@ public async Task DeleteExplorerItem_ClosesOpenTab() [Fact] public async Task NewFolder_PersistsAcrossRefresh() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); await studio.NewFolder(); @@ -290,7 +298,7 @@ public async Task RenamingANotebook_RenamesItsFile_AsVsCodeDoes() { // The Explorer shows file names: a rename used to change only the title inside, so the old name stayed. var doc = await _testStorage.CreateNewNotebookAsync("Draft"); - var studio = CreateStudio(doc); + var studio = await CreateListedStudioAsync(doc); var item = studio.ExplorerRootItems.Single(x => x.DocumentId == doc.Id); var folder = Path.GetDirectoryName(_testStorage.GetWorkspaceRelativePath(doc.Id) is { } rel ? Path.Combine(_testStorage.ActiveWorkspaceRootPath, rel) @@ -311,7 +319,7 @@ public async Task RenamingANotebook_RenamesItsFile_AsVsCodeDoes() [Fact] public async Task RenameFolder_UpdatesDescendantPaths_AndPersistsAcrossRefresh() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); await studio.NewFolder(); var folder = studio.ExplorerRootItems.First(x => x.IsDirectory); @@ -336,7 +344,7 @@ public async Task RenameFolder_UpdatesDescendantPaths_AndPersistsAcrossRefresh() [Fact] public async Task DeleteFolder_CascadesToChildrenAndClosesTabs() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); await studio.NewFolder(); var folder = studio.ExplorerRootItems.First(x => x.IsDirectory); @@ -789,9 +797,9 @@ public void NuGetSemanticVersion_ParsingAndComparison_PrefersStableOverPrereleas } [Fact] - public void UpdateActiveNotebook_OpensATab_ButListsOnlyTheFoldersFiles() + public async Task UpdateActiveNotebook_OpensATab_ButListsOnlyTheFoldersFiles() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); var customNb = new NotebookDocumentItem { @@ -812,7 +820,7 @@ public void UpdateActiveNotebook_OpensATab_ButListsOnlyTheFoldersFiles() [Fact] public async Task OpenDocument_WhenSavedInStorage_LoadsSavedCells() { - var studio = CreateStudio(); + var studio = await CreateListedStudioAsync(); var uniqueTitle = $"StorageTest_{Guid.NewGuid():N}"; var newNb = new NotebookDocumentItem @@ -848,7 +856,7 @@ public async Task OpenDocument_WhenSavedInStorage_LoadsSavedCells() [Fact] public async Task DuplicateExplorerItem_CreatesClonedDocumentInStorageAndExplorer() { - var studio = CreateStudio(await _testStorage.CreateNewNotebookAsync("Document Automation Notebook")); + var studio = await CreateListedStudioAsync(await _testStorage.CreateNewNotebookAsync("Document Automation Notebook")); var docFile = studio.ExplorerRootItems.First(x => x.Name == "Document Automation Notebook.frynb"); @@ -982,7 +990,7 @@ public async Task PopulateExplorerTree_ForDocumentOutsideActiveRoot_DoesNotListI { var doc = await _testStorage.CreateNewNotebookAsync("External Doc", folderPath: externalDir); - var studio = CreateStudio(doc); + var studio = await CreateListedStudioAsync(doc); Assert.DoesNotContain(studio.ExplorerRootItems, x => x.Name == "External Doc.frynb"); } @@ -1008,7 +1016,7 @@ public async Task CopiesThatShareATitle_AreAllListed_ByTheirFileNames_AndEachOpe File.WriteAllText(Path.Combine(externalDir, "Interactive C# Notebook (2).frynb"), System.Text.Json.JsonSerializer.Serialize(copy)); await _testStorage.OpenExternalProjectAsync(externalDir); - var studio = CreateStudio(first); + var studio = await CreateListedStudioAsync(first); await studio.RefreshExplorer(); var names = studio.ExplorerRootItems.Where(x => !x.IsDirectory).Select(x => x.Name).ToList(); @@ -1036,7 +1044,7 @@ public async Task DeleteExplorerItemAsync_OnFolderInsideOpenedExternalRoot_Delet await _testStorage.OpenExternalProjectAsync(externalDir); var subFolder = await _testStorage.CreateFolderAsync(null, "SubFolder"); var doc = await _testStorage.CreateNewNotebookAsync("Inside", folderPath: subFolder); - var studio = CreateStudio(doc); + var studio = await CreateListedStudioAsync(doc); var folderNode = studio.ExplorerRootItems.Single(x => x.IsDirectory); @@ -1060,7 +1068,7 @@ public async Task DuplicateExplorerItemAsync_ForDocumentInOpenedExternalRoot_Kee { await _testStorage.OpenExternalProjectAsync(externalDir); var doc = await _testStorage.CreateNewNotebookAsync("Original"); - var studio = CreateStudio(doc); + var studio = await CreateListedStudioAsync(doc); var originalItem = studio.ExplorerRootItems.Single(x => !x.IsDirectory); await studio.DuplicateExplorerItemAsync(originalItem); @@ -1086,7 +1094,7 @@ public async Task PopulateExplorerTree_WithUnrelatedExternalProject_DoesNotShowT var helloDoc = await _testStorage.CreateNewNotebookAsync("h", folderPath: helloDir); await _testStorage.CreateNewNotebookAsync("New I", folderPath: iGuessDir); - var studio = CreateStudio(helloDoc); + var studio = await CreateListedStudioAsync(helloDoc); // Neither folder is the active workspace root, so neither notebook is listed (not even the open one). Assert.DoesNotContain(studio.ExplorerRootItems, x => x.Name is "h.frynb" or "New I.frynb"); @@ -1106,7 +1114,7 @@ public async Task DeleteExplorerItemAsync_LastDocumentInFolder_FolderStaysVisibl { var folder = await _testStorage.CreateFolderAsync(null, "SoloFolder"); var doc = await _testStorage.CreateNewNotebookAsync("Only Doc", folderPath: folder); - var studio = CreateStudio(doc); + var studio = await CreateListedStudioAsync(doc); var folderNode = studio.ExplorerRootItems.Single(x => x.IsDirectory && x.Name == "SoloFolder"); var docItem = folderNode.Children.Single(); @@ -1126,7 +1134,7 @@ public async Task DeleteExplorerItemAsync_OneOfMultipleDocumentsInOpenedFolder_K var first = await _testStorage.CreateNewNotebookAsync("First"); await _testStorage.CreateNewNotebookAsync("Second"); - var studio = CreateStudio(first); + var studio = await CreateListedStudioAsync(first); Assert.Equal(2, studio.ExplorerRootItems.Count(x => !x.IsDirectory)); var firstItem = studio.ExplorerRootItems.Single(c => c.Name == "First.frynb"); diff --git a/Tests/Notebooks/CppNotebookKernelTests.cs b/Tests/Notebooks/CppNotebookKernelTests.cs index d13ef38e..acd211b7 100644 --- a/Tests/Notebooks/CppNotebookKernelTests.cs +++ b/Tests/Notebooks/CppNotebookKernelTests.cs @@ -30,7 +30,7 @@ public void Properties_AreExpected() var settings = new ToolchainSettingsStore(Path.Combine(_baseDir, "toolchains.json")); var toolchain = new CppToolchainProvider(host, processes, settings, Path.Combine(_baseDir, "cpp")); var context = new KernelCreationContext(() => host.HomeDirectory); - using var kernel = new CppNotebookKernel(toolchain, processes, host, context); + using var kernel = new CppNotebookKernel(toolchain, processes, host, Path.Combine(_baseDir, "cpp"), context); Assert.Equal(LanguageIds.Cpp, kernel.LanguageId); Assert.True(kernel.CanForceStop); @@ -45,7 +45,7 @@ public async Task SetValueFromJson_StoresAndSharesVariable() var settings = new ToolchainSettingsStore(Path.Combine(_baseDir, "toolchains.json")); var toolchain = new CppToolchainProvider(host, processes, settings, Path.Combine(_baseDir, "cpp")); var context = new KernelCreationContext(() => host.HomeDirectory); - using var kernel = new CppNotebookKernel(toolchain, processes, host, context); + using var kernel = new CppNotebookKernel(toolchain, processes, host, Path.Combine(_baseDir, "cpp"), context); await kernel.SetValueFromJsonAsync("count", "42", default); await kernel.SetValueFromJsonAsync("pi", "3.14159", default); @@ -71,7 +71,7 @@ public async Task HardReset_ClearsSessionState() var settings = new ToolchainSettingsStore(Path.Combine(_baseDir, "toolchains.json")); var toolchain = new CppToolchainProvider(host, processes, settings, Path.Combine(_baseDir, "cpp")); var context = new KernelCreationContext(() => host.HomeDirectory); - using var kernel = new CppNotebookKernel(toolchain, processes, host, context); + using var kernel = new CppNotebookKernel(toolchain, processes, host, Path.Combine(_baseDir, "cpp"), context); await kernel.SetValueFromJsonAsync("x", "100", default); Assert.True(kernel.IsSessionActive); @@ -90,7 +90,7 @@ public async Task UnknownVariable_ThrowsKernelValueException() var settings = new ToolchainSettingsStore(Path.Combine(_baseDir, "toolchains.json")); var toolchain = new CppToolchainProvider(host, processes, settings, Path.Combine(_baseDir, "cpp")); var context = new KernelCreationContext(() => host.HomeDirectory); - using var kernel = new CppNotebookKernel(toolchain, processes, host, context); + using var kernel = new CppNotebookKernel(toolchain, processes, host, Path.Combine(_baseDir, "cpp"), context); await Assert.ThrowsAsync(() => kernel.GetValueJsonAsync("non_existent", default)); } @@ -103,7 +103,7 @@ public async Task ExecuteAsync_WhenCompilerMissing_ReturnsErrorWithGuidance() var settings = new ToolchainSettingsStore(Path.Combine(_baseDir, "toolchains.json")); var toolchain = new CppToolchainProvider(host, processes, settings, Path.Combine(_baseDir, "cpp")); var context = new KernelCreationContext(() => host.HomeDirectory); - using var kernel = new CppNotebookKernel(toolchain, processes, host, context); + using var kernel = new CppNotebookKernel(toolchain, processes, host, Path.Combine(_baseDir, "cpp"), context); var result = await kernel.ExecuteAsync(new KernelExecutionRequest { diff --git a/Tests/Notebooks/FSharpNotebookKernelTests.cs b/Tests/Notebooks/FSharpNotebookKernelTests.cs deleted file mode 100644 index 2ef001c8..00000000 --- a/Tests/Notebooks/FSharpNotebookKernelTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using CSharpEditorPlugin.Tests.TestSupport; -using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; -using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.FSharp; -using PdfEditorApp.Plugins.CSharpEditor.Services.Toolchains; -using Xunit; - -namespace CSharpEditorPlugin.Tests; - -public class FSharpNotebookKernelTests : IDisposable -{ - private readonly string _tempDir; - private readonly FakeHostEnvironment _host; - private readonly FSharpToolchainProvider _toolchain; - - public FSharpNotebookKernelTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "FryPDF_FSNotebookTest_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - - _host = new FakeHostEnvironment(FakeOs.MacOS); - _host.AddFSharp("/usr/local/share/dotnet/dotnet", "10.0.100"); - _toolchain = new FSharpToolchainProvider(_host, new FakeProcessLauncher(), new ToolchainSettingsStore(Path.Combine(_tempDir, "tc.json"))); - } - - public void Dispose() - { - try - { - if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); - } - catch (IOException) - { - } - } - - [Fact] - public async Task SharedVariables_SetAndGetRoundTrip() - { - var context = new KernelCreationContext(() => _tempDir, () => null); - using var kernel = new FSharpNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, context); - - await kernel.SetValueFromJsonAsync("myInt", "42", CancellationToken.None); - await kernel.SetValueFromJsonAsync("myStr", "\"hello fsharp\"", CancellationToken.None); - await kernel.SetValueFromJsonAsync("myBool", "true", CancellationToken.None); - - var intVal = await kernel.GetValueJsonAsync("myInt", CancellationToken.None); - var strVal = await kernel.GetValueJsonAsync("myStr", CancellationToken.None); - var boolVal = await kernel.GetValueJsonAsync("myBool", CancellationToken.None); - - Assert.Equal("42", intVal); - Assert.Equal("\"hello fsharp\"", strVal); - Assert.Equal("true", boolVal); - - var vars = await kernel.GetVariablesAsync(CancellationToken.None); - Assert.Equal(3, vars.Count); - Assert.Contains(vars, v => v.Name == "myInt" && v.TypeName == "int64"); - Assert.Contains(vars, v => v.Name == "myStr" && v.TypeName == "string"); - Assert.Contains(vars, v => v.Name == "myBool" && v.TypeName == "bool"); - } - - [Fact] - public async Task MissingVariable_ThrowsKernelValueException() - { - var context = new KernelCreationContext(() => _tempDir, () => null); - using var kernel = new FSharpNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, context); - - await Assert.ThrowsAsync(async () => - { - await kernel.GetValueJsonAsync("nonExistent", CancellationToken.None); - }); - } - - [Fact] - public async Task HardReset_ClearsVariablesAndState() - { - var context = new KernelCreationContext(() => _tempDir, () => null); - using var kernel = new FSharpNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, context); - - await kernel.SetValueFromJsonAsync("count", "10", CancellationToken.None); - Assert.True(kernel.IsSessionActive); - - kernel.HardReset(); - Assert.False(kernel.IsSessionActive); - } -} diff --git a/Tests/Notebooks/GoNotebookKernelTests.cs b/Tests/Notebooks/GoNotebookKernelTests.cs index 2f3eddf9..d71bb6e6 100644 --- a/Tests/Notebooks/GoNotebookKernelTests.cs +++ b/Tests/Notebooks/GoNotebookKernelTests.cs @@ -37,7 +37,7 @@ public void Dispose() public async Task SharedVariables_SetAndGetRoundTrip() { var context = new KernelCreationContext(() => _tempDir, () => null); - using var kernel = new GoNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, context); + using var kernel = new GoNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, _tempDir, new GoNotebookDependencies(_tempDir), context); await kernel.SetValueFromJsonAsync("myInt", "42", CancellationToken.None); await kernel.SetValueFromJsonAsync("myStr", "\"hello go\"", CancellationToken.None); @@ -53,7 +53,7 @@ public async Task SharedVariables_SetAndGetRoundTrip() var vars = await kernel.GetVariablesAsync(CancellationToken.None); Assert.Equal(3, vars.Count); - Assert.Contains(vars, v => v.Name == "myInt" && v.TypeName == "int64"); + Assert.Contains(vars, v => v.Name == "myInt" && v.TypeName == "int"); Assert.Contains(vars, v => v.Name == "myStr" && v.TypeName == "string"); Assert.Contains(vars, v => v.Name == "myBool" && v.TypeName == "bool"); } @@ -62,7 +62,7 @@ public async Task SharedVariables_SetAndGetRoundTrip() public async Task MissingVariable_ThrowsKernelValueException() { var context = new KernelCreationContext(() => _tempDir, () => null); - using var kernel = new GoNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, context); + using var kernel = new GoNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, _tempDir, new GoNotebookDependencies(_tempDir), context); await Assert.ThrowsAsync(async () => { @@ -74,7 +74,7 @@ await Assert.ThrowsAsync(async () => public async Task HardReset_ClearsVariablesAndState() { var context = new KernelCreationContext(() => _tempDir, () => null); - using var kernel = new GoNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, context); + using var kernel = new GoNotebookKernel(_toolchain, new FakeProcessLauncher(), _host, _tempDir, new GoNotebookDependencies(_tempDir), context); await kernel.SetValueFromJsonAsync("count", "10", CancellationToken.None); Assert.True(kernel.IsSessionActive); @@ -109,7 +109,7 @@ public async Task StoppingACellWhileGoBuilds_KillsTheBuild_AndReportsACancellati await Task.Delay(Timeout.Infinite, process.KilledToken); } }; - using var kernel = new GoNotebookKernel(_toolchain, launcher, _host, new KernelCreationContext(() => _tempDir, () => null)); + using var kernel = new GoNotebookKernel(_toolchain, launcher, _host, _tempDir, new GoNotebookDependencies(_tempDir), new KernelCreationContext(() => _tempDir, () => null)); using var cts = new CancellationTokenSource(); var running = kernel.ExecuteAsync(new KernelExecutionRequest { Code = "fmt.Println(1)" }, cts.Token); @@ -139,7 +139,7 @@ public async Task StoppingACellWhileTheProgramRuns_KillsTheProgram_AndReportsACa await Task.Delay(Timeout.Infinite, process.KilledToken); } }; - using var kernel = new GoNotebookKernel(_toolchain, launcher, _host, new KernelCreationContext(() => _tempDir, () => null)); + using var kernel = new GoNotebookKernel(_toolchain, launcher, _host, _tempDir, new GoNotebookDependencies(_tempDir), new KernelCreationContext(() => _tempDir, () => null)); using var cts = new CancellationTokenSource(); var running = kernel.ExecuteAsync(new KernelExecutionRequest { Code = "for {}" }, cts.Token); @@ -165,7 +165,7 @@ public async Task ACellThatRunsForALongTime_IsNotEndedByALimitOfTheKernelsOwn() process.Exit(0); } }; - using var kernel = new GoNotebookKernel(_toolchain, launcher, _host, new KernelCreationContext(() => _tempDir, () => null)); + using var kernel = new GoNotebookKernel(_toolchain, launcher, _host, _tempDir, new GoNotebookDependencies(_tempDir), new KernelCreationContext(() => _tempDir, () => null)); var running = kernel.ExecuteAsync(new KernelExecutionRequest { Code = "fmt.Println(1)" }, CancellationToken.None); await Task.Delay(200); diff --git a/Tests/RealCpp/CppProgramRunTests.cs b/Tests/RealCpp/CppProgramRunTests.cs index d72c21c7..2c9e6075 100644 --- a/Tests/RealCpp/CppProgramRunTests.cs +++ b/Tests/RealCpp/CppProgramRunTests.cs @@ -42,7 +42,7 @@ private string Write(string name, string code) var cpp = TestCpp.Require(); var services = TestCpp.Services(Path.Combine(_dir, ".studio")); var language = (CppLanguage)services.Registry.Get(LanguageIds.Cpp)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, cpp)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, cpp)); var output = new ConcurrentQueue(); var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); return (session, output); @@ -165,7 +165,7 @@ public async Task ANotebookCell_ExecutesWithRealCompiler_AndPrintsOutput() var language = (CppLanguage)services.Registry.Get(LanguageIds.Cpp)!; var context = new PdfEditorApp.Plugins.CSharpEditor.Services.Kernels.KernelCreationContext(() => _dir); - using var kernel = language.NotebookKernels!.Create(context); + using var kernel = language.Engine.NotebookKernels.Create(context); var console = new System.Text.StringBuilder(); var request = new PdfEditorApp.Plugins.CSharpEditor.Services.Kernels.KernelExecutionRequest diff --git a/Tests/RealCpp/CppVisualsConformanceTests.cs b/Tests/RealCpp/CppVisualsConformanceTests.cs index 6ed8bb33..429cbbff 100644 --- a/Tests/RealCpp/CppVisualsConformanceTests.cs +++ b/Tests/RealCpp/CppVisualsConformanceTests.cs @@ -143,7 +143,7 @@ private INotebookKernel Kernel() TestCpp.Require(); var services = TestCpp.Services(Path.Combine(_dir, ".studio")); var language = (CppLanguage)services.Registry.Get(LanguageIds.Cpp)!; - var kernel = language.NotebookKernels!.Create(new KernelCreationContext(() => _dir)); + var kernel = language.Engine.NotebookKernels.Create(new KernelCreationContext(() => _dir)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/RealFSharp/FSharpProgramRunTests.cs b/Tests/RealFSharp/FSharpProgramRunTests.cs index c1c75e54..392368d1 100644 --- a/Tests/RealFSharp/FSharpProgramRunTests.cs +++ b/Tests/RealFSharp/FSharpProgramRunTests.cs @@ -43,9 +43,9 @@ private string Write(string name, string code) var fs = TestFSharp.Require(); var services = TestFSharp.Services(Path.Combine(_dir, ".studio")); var language = (FSharpLanguage)services.Registry.Get(LanguageIds.FSharp)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, fs)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, fs)); var output = new ConcurrentQueue(); - var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); + var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.Engine.RunDiagnostics, output.Enqueue); return (session, output); } @@ -101,17 +101,17 @@ open System } [FSharpFact] - public async Task NotebookKernel_ExecutesCellAndAccumulatesDefinitions() + public async Task NotebookKernel_KeepsWhatACellDefines_ForTheNextCell() { var fs = TestFSharp.Require(); var services = TestFSharp.Services(Path.Combine(_dir, ".studio")); var language = (FSharpLanguage)services.Registry.Get(LanguageIds.FSharp)!; var context = new KernelCreationContext(() => _dir, () => null); - using var kernel = (FSharpNotebookKernel)language.NotebookKernels.Create(context); + using var kernel = language.Engine.NotebookKernels.Create(context); var consoleOut = new List(); - var req1 = new KernelExecutionRequest + var first = await kernel.ExecuteAsync(new KernelExecutionRequest { Code = """ let add a b = a + b @@ -119,11 +119,19 @@ public async Task NotebookKernel_ExecutesCellAndAccumulatesDefinitions() printfn "RESULT_1=%d" res1 """, OnConsole = s => consoleOut.Add(s) - }; + }, CancellationToken.None); + Assert.True(first.Success, first.ErrorMessage ?? first.ConsoleOutput); - var res = await kernel.ExecuteAsync(req1, CancellationToken.None); + // a second cell sees what the first one defined: it is one session, as in `dotnet fsi` + var second = await kernel.ExecuteAsync(new KernelExecutionRequest + { + Code = "printfn \"RESULT_2=%d\" (add res1 12)", + OnConsole = s => consoleOut.Add(s) + }, CancellationToken.None); - Assert.True(res.Success, res.ErrorMessage ?? res.ConsoleOutput); - Assert.Contains("RESULT_1=30", string.Join("", consoleOut)); + Assert.True(second.Success, second.ErrorMessage ?? second.ConsoleOutput); + var joined = string.Join("", consoleOut); + Assert.Contains("RESULT_1=30", joined); + Assert.Contains("RESULT_2=42", joined); } } diff --git a/Tests/RealGo/GoProgramRunTests.cs b/Tests/RealGo/GoProgramRunTests.cs index ce0378c5..cbf7bc5b 100644 --- a/Tests/RealGo/GoProgramRunTests.cs +++ b/Tests/RealGo/GoProgramRunTests.cs @@ -42,7 +42,7 @@ private string Write(string name, string code) var go = TestGo.Require(); var services = TestGo.Services(Path.Combine(_dir, ".studio")); var language = (GoLanguage)services.Registry.Get(LanguageIds.Go)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, go)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, go)); var output = new ConcurrentQueue(); var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); return (session, output); diff --git a/Tests/RealGo/GoVisualsConformanceTests.cs b/Tests/RealGo/GoVisualsConformanceTests.cs index 08722a01..ddb889dc 100644 --- a/Tests/RealGo/GoVisualsConformanceTests.cs +++ b/Tests/RealGo/GoVisualsConformanceTests.cs @@ -111,7 +111,7 @@ func main() { var go = TestGo.Require(); var services = TestGo.Services(Path.Combine(_dir, ".studio")); var language = (GoLanguage)services.Registry.Get(LanguageIds.Go)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, go)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, go)); var console = new List(); var outputs = new List(); var processor = new ExternalOutputProcessor(console.Add, outputs.Add, visuals: session?.Visuals); @@ -129,7 +129,7 @@ private INotebookKernel Kernel() var services = new StudioLanguageServices(Path.Combine(_dir, "services")); var go = (GoLanguage)services.Registry.Get(LanguageIds.Go)!; go.GoToolchain.Select(TestGo.Require().ExecutablePath); - var kernel = go.NotebookKernels.Create(new KernelCreationContext(() => _dir)); + var kernel = go.Engine.NotebookKernels.Create(new KernelCreationContext(() => _dir)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/RealJava/JavaKernelTests.cs b/Tests/RealJava/JavaKernelTests.cs index 0368f23d..2d6fe5f9 100644 --- a/Tests/RealJava/JavaKernelTests.cs +++ b/Tests/RealJava/JavaKernelTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using CSharpEditorPlugin.Tests.TestSupport; +using PdfEditorApp.Plugins.CSharpEditor.Models; using PdfEditorApp.Plugins.CSharpEditor.Services.Display; using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; @@ -41,14 +42,14 @@ private ProtocolKernel Kernel() var services = new StudioLanguageServices(Path.Combine(_baseDir, "services")); var java = (JavaLanguage)services.Registry.Get(LanguageIds.Java)!; java.JavaToolchain.Select(TestJava.Require().ExecutablePath); - var kernel = (ProtocolKernel)java.NotebookKernels.Create(new KernelCreationContext(() => _notebookFolder)); + var kernel = (ProtocolKernel)java.Engine.NotebookKernels.Create(new KernelCreationContext(() => _notebookFolder)); _kernels.Add(kernel); return kernel; } private sealed record Run(KernelExecutionResult Result, string Console, List Rich, List Chunks); - private static async Task Execute(ProtocolKernel kernel, string code, CancellationToken ct = default) + private static async Task Execute(ProtocolKernel kernel, string code, CancellationToken ct = default, Func>? ask = null) { var chunks = new ConcurrentQueue(); var rich = new ConcurrentQueue(); @@ -57,7 +58,8 @@ private static async Task Execute(ProtocolKernel kernel, string code, Cance Code = code.Replace("\r\n", "\n"), SourceId = "cell", OnConsole = chunks.Enqueue, - OnRichOutput = rich.Enqueue + OnRichOutput = rich.Enqueue, + OnInputRequest = ask }, ct).WaitAsync(Patience); return new Run(result, string.Concat(chunks), rich.ToList(), chunks.ToList()); } @@ -75,6 +77,121 @@ public async Task State_CarriesOverBetweenCells_AndTheLastExpressionIsShown() Assert.StartsWith("Java", kernel.DisplayName); } + [JavaFact] + public async Task LastExpression_AnObject_OpensInTheObjectExplorer_LikeACSharpValue() + { + var kernel = Kernel(); + + var run = await Execute(kernel, """ + public class People { + public String name = ""; + public String className = ""; + public int[] numbers = {1, 34, 45, 235, 25}; + public People another; + } + + People person = new People(); + person.name = "Prashant"; + person.another = person; + person + """); + + Assert.True(run.Result.Success, run.Console); + Assert.DoesNotContain("People@", run.Console); + var node = Assert.IsType(Assert.Single(run.Rich, r => r.Kind == CellOutputKind.ObjectInspector).InspectorNode); + Assert.Equal("People", node.HeaderTitle); + Assert.Equal(["name", "className", "numbers", "another"], node.Properties.Select(p => p.Name)); + Assert.Equal("Prashant", node.Properties[0].SimpleValueText); + Assert.True(node.Properties[0].IsString); + Assert.Equal("[ 1, 34, 45, 235, 25 ]", node.Properties[2].SimpleValueText); + Assert.True(node.Properties[3].ChildNode!.IsCircularReference); + InspectorChain.AssertFiveLevels(node, "another", ["name", "className", "numbers", "another"]); + } + + [JavaFact] + public async Task LastExpression_RecordsAndGetters_AreInspected_ACollectionOfRecordsIsATable() + { + var kernel = Kernel(); + + var record = await Execute(kernel, "record Point(int x, int y) {}\nnew Point(3, 4)"); + var points = await Execute(kernel, "List.of(new Point(1, 2), new Point(3, 4))"); + var bean = await Execute(kernel, "public class Person { private String name = \"Ada\"; public String getName() { return name; } public boolean isAdmin() { return true; } }\nnew Person()"); + + var one = Assert.Single(record.Rich).InspectorNode!; + Assert.Equal("Point", one.HeaderTitle); + Assert.Equal(["x", "y"], one.Properties.Select(p => p.Name)); + + // a collection of records is the studio's table (a column for each component), as in Python and F# + var table = Assert.Single(points.Rich).TableResult!; + Assert.Equal(["x", "y"], table.Columns.Select(c => c.Header)); + Assert.Equal([true, true], table.Columns.Select(c => c.IsNumeric)); + Assert.Equal(["1", "3"], table.Rows.Select(r => r.Cells[0].DisplayText)); + Assert.Contains("Point", table.Title); + + // a collection of collections has no columns: it opens as a tree + var nested = Assert.Single((await Execute(kernel, "List.of(List.of(new Point(1, 2)), List.of(new Point(3, 4)))")).Rich).InspectorNode!; + Assert.Equal("List", nested.HeaderTitle); // not the JDK's hidden List12 + Assert.Equal(["[0]", "[1]"], nested.Properties.Select(p => p.Name)); + + var person = Assert.Single(bean.Rich).InspectorNode!; + Assert.Equal(["admin", "name"], person.Properties.Select(p => p.Name)); + Assert.Equal("Ada", person.Properties.Single(p => p.Name == "name").SimpleValueText); + } + + [JavaFact] + public async Task LastExpression_AScalar_OrAShortListOfScalars_StaysText() + { + var kernel = Kernel(); + + var text = await Execute(kernel, "\"hello\""); + var numbers = await Execute(kernel, "new int[]{1, 2, 3}"); + var names = await Execute(kernel, "List.of(\"a\", \"b\")"); + + Assert.Empty(text.Rich); + Assert.Equal("\"hello\"\n", text.Console); + Assert.Empty(numbers.Rich); + Assert.Equal("[ 1, 2, 3 ]\n", numbers.Console); + Assert.Empty(names.Rich); + Assert.Equal("[ a, b ]\n", names.Console); + } + + [JavaFact] + public async Task Input_ScannerAndBufferedReader_AskTheNotebookForALine() + { + var kernel = Kernel(); + var answers = new Queue(["Ada", "Grace"]); + var asked = 0; + Task Ask(string prompt, bool password, CancellationToken ct) + { + asked++; + return Task.FromResult(answers.Dequeue()); + } + + var run = await Execute(kernel, """ + var scanner = new java.util.Scanner(System.in); + System.out.print("Name? "); + String name = scanner.nextLine(); + var reader = new java.io.BufferedReader(new java.io.InputStreamReader(System.in)); + String other = reader.readLine(); + System.out.println("Hello " + name + " and " + other); + """, ask: Ask); + + Assert.True(run.Result.Success, run.Console + " Err: " + run.Result.ErrorMessage); + Assert.Equal(2, asked); + Assert.Contains("Hello Ada and Grace", run.Console); + } + + [JavaFact] + public async Task Input_WithNoOneToAsk_IsTheEndOfInput_NotAHang() + { + var kernel = Kernel(); + + var run = await Execute(kernel, "System.in.read()"); + + Assert.True(run.Result.Success, run.Console); + Assert.EndsWith("-1\n", run.Console); // (the studio echoes the empty answer first, as for every language) + } + [JavaFact] public async Task OutputStreams_SystemOutPrints() { diff --git a/Tests/RealJava/JavaProgramRunTests.cs b/Tests/RealJava/JavaProgramRunTests.cs index 49a6c545..dde067eb 100644 --- a/Tests/RealJava/JavaProgramRunTests.cs +++ b/Tests/RealJava/JavaProgramRunTests.cs @@ -42,7 +42,7 @@ private string Write(string name, string code) var java = TestJava.Require(); var services = TestJava.Services(Path.Combine(_dir, ".studio")); var language = (JavaLanguage)services.Registry.Get(LanguageIds.Java)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, java)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, java)); var output = new ConcurrentQueue(); var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); return (session, output); diff --git a/Tests/RealJava/JavaVisualsConformanceTests.cs b/Tests/RealJava/JavaVisualsConformanceTests.cs index 1a11ac47..d90e8b55 100644 --- a/Tests/RealJava/JavaVisualsConformanceTests.cs +++ b/Tests/RealJava/JavaVisualsConformanceTests.cs @@ -108,7 +108,7 @@ public static void main(String[] args) { var java = TestJava.Require(); var services = TestJava.Services(Path.Combine(_dir, ".studio")); var language = (JavaLanguage)services.Registry.Get(LanguageIds.Java)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, java)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, java)); var console = new List(); var outputs = new List(); var processor = new ExternalOutputProcessor(console.Add, outputs.Add, visuals: session?.Visuals); @@ -126,7 +126,7 @@ private ProtocolKernel Kernel() var services = new StudioLanguageServices(Path.Combine(_dir, "services")); var java = (JavaLanguage)services.Registry.Get(LanguageIds.Java)!; java.JavaToolchain.Select(TestJava.Require().ExecutablePath); - var kernel = (ProtocolKernel)java.NotebookKernels.Create(new KernelCreationContext(() => _dir)); + var kernel = (ProtocolKernel)java.Engine.NotebookKernels.Create(new KernelCreationContext(() => _dir)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/RealJavaScript/JavaScriptKernelTests.cs b/Tests/RealJavaScript/JavaScriptKernelTests.cs index e4d3977a..6a381d24 100644 --- a/Tests/RealJavaScript/JavaScriptKernelTests.cs +++ b/Tests/RealJavaScript/JavaScriptKernelTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using CSharpEditorPlugin.Tests.TestSupport; +using PdfEditorApp.Plugins.CSharpEditor.Models; using PdfEditorApp.Plugins.CSharpEditor.Services.Display; using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; @@ -62,6 +63,61 @@ private static async Task Execute(ProtocolKernel kernel, string code, Cance return new Run(result, string.Concat(chunks), rich.ToList(), chunks.ToList()); } + [JavaScriptFact] + public async Task LastExpression_AnObject_OpensInTheObjectExplorer_LikeACSharpValue() + { + var kernel = Kernel(); + + var run = await Execute(kernel, """ + class People { + constructor() { + this.name = 'Prashant'; + this.numbers = [1, 34, 45, 235, 25]; + this.another = this; + } + get shout() { return this.name.toUpperCase(); } + } + const person = new People(); + person + """); + + Assert.True(run.Result.Success, run.Console); + Assert.DoesNotContain("People {", run.Console); + var node = Assert.IsType(Assert.Single(run.Rich, r => r.Kind == CellOutputKind.ObjectInspector).InspectorNode); + Assert.Equal("People", node.HeaderTitle); + Assert.Equal(["name", "numbers", "another", "shout"], node.Properties.Select(p => p.Name)); + Assert.Equal("Prashant", node.Properties[0].SimpleValueText); + Assert.True(node.Properties[0].IsString); + Assert.Equal("[ 1, 34, 45, 235, 25 ]", node.Properties[1].SimpleValueText); + Assert.True(node.Properties[2].ChildNode!.IsCircularReference); + Assert.Equal("PRASHANT", node.Properties[3].SimpleValueText); + InspectorChain.AssertFiveLevels(node, "another", ["name", "numbers", "another", "shout"]); + } + + [JavaScriptFact] + public async Task LastExpression_ObjectsMapsAndArraysOfObjects_AreInspected_ScalarsStayText() + { + var kernel = Kernel(); + + var plain = await Execute(kernel, "({ a: 1, b: { c: [1, 2] } })"); + var map = await Execute(kernel, "new Map([['k', { v: 1 }], ['z', 2]])"); // a Map of the notebook's own vm context + var scalar = await Execute(kernel, "40 + 2"); + var numbers = await Execute(kernel, "[1, 2, 3]"); + + var top = Assert.Single(plain.Rich).InspectorNode!; + Assert.Equal(["a", "b"], top.Properties.Select(p => p.Name)); + Assert.Equal("c", top.Properties[1].ChildNode!.Properties.Single().Name); + + var entries = Assert.Single(map.Rich).InspectorNode!; + Assert.Equal("Map", entries.HeaderTitle); + Assert.Equal(["k", "z"], entries.Properties.Select(p => p.Name)); + + Assert.Empty(scalar.Rich); + Assert.Equal("42\n", scalar.Console); + Assert.Empty(numbers.Rich); + Assert.Equal("[ 1, 2, 3 ]\n", numbers.Console); + } + [JavaScriptFact] public async Task State_CarriesOverBetweenCells_AndTheLastExpressionIsShown() { diff --git a/Tests/RealPython/CodeStudioPythonRunTests.cs b/Tests/RealPython/CodeStudioPythonRunTests.cs index 0666285a..d6cf1070 100644 --- a/Tests/RealPython/CodeStudioPythonRunTests.cs +++ b/Tests/RealPython/CodeStudioPythonRunTests.cs @@ -40,6 +40,7 @@ private async Task StudioWith(string fileName, string backToHubAction: () => { }, blindProgress: new LocalBlindProgressService(Path.Combine(_baseDir, "progress")), languages: services); + await studio.RefreshExplorerAsync(); // the explorer lists the folder in the background: wait for the file just written await studio.SwitchToScriptAsync(studio.ExplorerRootItems.Single(i => i.Name == fileName)); return studio; } diff --git a/Tests/RealPython/PythonKernelTests.cs b/Tests/RealPython/PythonKernelTests.cs index e447848e..3968f17b 100644 --- a/Tests/RealPython/PythonKernelTests.cs +++ b/Tests/RealPython/PythonKernelTests.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using CSharpEditorPlugin.Tests.TestSupport; +using PdfEditorApp.Plugins.CSharpEditor.Models; using PdfEditorApp.Plugins.CSharpEditor.Services.Display; using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; @@ -77,6 +78,63 @@ public async Task State_CarriesOverBetweenCells_AndTheLastExpressionIsShown() Assert.StartsWith("Python 3.", kernel.DisplayName); } + [PythonFact] + public async Task LastExpression_AnObject_OpensInTheObjectExplorer_LikeACSharpValue() + { + var kernel = Kernel(); + + var run = await Execute(kernel, """ + class People: + def __init__(self): + self.name = "Prashant" + self.numbers = [1, 34, 45, 235, 25] + self.another = self + + @property + def shout(self): + return self.name.upper() + + person = People() + person + """); + + Assert.True(run.Result.Success, run.Console); + Assert.DoesNotContain("<__main__", run.Console); + var node = Assert.IsType(Assert.Single(run.Rich, r => r.Kind == CellOutputKind.ObjectInspector).InspectorNode); + Assert.Equal("People", node.HeaderTitle); + Assert.Equal(["name", "numbers", "another", "shout"], node.Properties.Select(p => p.Name)); + Assert.Equal("Prashant", node.Properties[0].SimpleValueText); + Assert.True(node.Properties[0].IsString); + Assert.Equal("[ 1, 34, 45, 235, 25 ]", node.Properties[1].SimpleValueText); + Assert.True(node.Properties[2].ChildNode!.IsCircularReference); + Assert.Equal("PRASHANT", node.Properties[3].SimpleValueText); + InspectorChain.AssertFiveLevels(node, "another", ["name", "numbers", "another", "shout"]); + } + + [PythonFact] + public async Task LastExpression_DictsDataclassesAndListsOfObjects_AreInspected_ScalarsStayText() + { + var kernel = Kernel(); + + var dict = await Execute(kernel, "{'a': 1, 'b': {'c': [1, 2]}}"); + var many = await Execute(kernel, "from dataclasses import dataclass\n@dataclass\nclass P:\n x: int\n y: int\n[P(1, 2), P(3, 4)]"); + var scalar = await Execute(kernel, "40 + 2"); + var numbers = await Execute(kernel, "[1, 2, 3]"); + + var top = Assert.Single(dict.Rich).InspectorNode!; + Assert.Equal(["a", "b"], top.Properties.Select(p => p.Name)); + Assert.Equal("c", top.Properties[1].ChildNode!.Properties.Single().Name); + + var list = Assert.Single(many.Rich).InspectorNode!; + Assert.Equal(["[0]", "[1]"], list.Properties.Select(p => p.Name)); + Assert.Equal(["x", "y"], list.Properties[1].ChildNode!.Properties.Select(p => p.Name)); + + Assert.Empty(scalar.Rich); + Assert.Equal("42\n", scalar.Console); + Assert.Empty(numbers.Rich); + Assert.Equal("[1, 2, 3]\n", numbers.Console); + } + [PythonFact] public async Task OutputStreams_WhileTheCellRuns() { diff --git a/Tests/RealRust/RustProgramRunTests.cs b/Tests/RealRust/RustProgramRunTests.cs index 8e5a3f64..e789713c 100644 --- a/Tests/RealRust/RustProgramRunTests.cs +++ b/Tests/RealRust/RustProgramRunTests.cs @@ -69,7 +69,7 @@ private string Write(string name, string code) { var rust = TestRust.Require(); var language = (RustLanguage)_studio.Services.Registry.Get(LanguageIds.Rust)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, rust)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, rust)); var output = new ConcurrentQueue(); var session = new ScriptRunExecutor(_studio.Services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); return (session, output); diff --git a/Tests/RealRust/RustVisualsConformanceTests.cs b/Tests/RealRust/RustVisualsConformanceTests.cs index 5696331e..4129ff4e 100644 --- a/Tests/RealRust/RustVisualsConformanceTests.cs +++ b/Tests/RealRust/RustVisualsConformanceTests.cs @@ -118,7 +118,7 @@ fn main() { { var rust = TestRust.Require(); var language = (RustLanguage)_studio.Services.Registry.Get(LanguageIds.Rust)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, rust)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, rust)); var console = new List(); var outputs = new List(); var processor = new ExternalOutputProcessor(console.Add, outputs.Add, visuals: session?.Visuals); @@ -135,7 +135,7 @@ private INotebookKernel Kernel() { TestRust.Require(); var language = (RustLanguage)_studio.Services.Registry.Get(LanguageIds.Rust)!; - var kernel = language.NotebookKernels.Create(new KernelCreationContext(() => _dir)); + var kernel = language.Engine.NotebookKernels.Create(new KernelCreationContext(() => _dir)); _kernels.Add(kernel); return kernel; } diff --git a/Tests/RealSql/SqlProgramRunTests.cs b/Tests/RealSql/SqlProgramRunTests.cs index 05360f6c..273ea186 100644 --- a/Tests/RealSql/SqlProgramRunTests.cs +++ b/Tests/RealSql/SqlProgramRunTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using CSharpEditorPlugin.Tests.TestSupport; +using PdfEditorApp.Plugins.CSharpEditor.Models; using PdfEditorApp.Plugins.CSharpEditor.Services.Kernels; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages; using PdfEditorApp.Plugins.CSharpEditor.Services.Languages.Sql; @@ -43,9 +44,9 @@ private string Write(string name, string code) var sqlite = TestSql.Require(); var services = TestSql.Services(Path.Combine(_dir, ".studio")); var language = (SqlLanguage)services.Registry.Get(LanguageIds.Sql)!; - var plan = await language.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, sqlite)); + var plan = await language.Engine.ScriptRunner.PlanAsync(new ScriptRunContext(path, Path.GetDirectoryName(path)!, sqlite)); var output = new ConcurrentQueue(); - var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.RunDiagnostics, output.Enqueue); + var session = new ScriptRunExecutor(services.Processes).Start(plan, path, language.Engine.RunDiagnostics, output.Enqueue); return (session, output); } @@ -146,9 +147,10 @@ public async Task NotebookKernel_ExecutesCellAndQueryResults() var language = (SqlLanguage)services.Registry.Get(LanguageIds.Sql)!; var context = new KernelCreationContext(() => _dir, () => null); - using var kernel = (SqlNotebookKernel)language.NotebookKernels.Create(context); + using var kernel = (SqlNotebookKernel)language.Engine.NotebookKernels.Create(context); - var consoleOut = new List(); + // A query's rows arrive as a table, not as text. + var tables = new List(); var req1 = new KernelExecutionRequest { Code = """ @@ -161,15 +163,17 @@ qty INTEGER DEFAULT 0 INSERT INTO products (name, qty) VALUES ('Bananas', 30); SELECT name, qty FROM products ORDER BY name; """, - OnConsole = s => consoleOut.Add(s) + OnConsole = _ => { }, + OnRichOutput = output => tables.Add(output.TableResult!) }; var res = await kernel.ExecuteAsync(req1, CancellationToken.None); Assert.True(res.Success, res.ErrorMessage ?? res.ConsoleOutput); - var joined = string.Join("", consoleOut); - Assert.Contains("Apples", joined); - Assert.Contains("Bananas", joined); + var table = Assert.Single(tables); + Assert.Equal(new[] { "name", "qty" }, table.Columns.Select(c => c.Header)); + Assert.Equal(new[] { "Apples", "Bananas" }, table.Rows.Select(r => r.Cells[0].DisplayText)); + Assert.Equal(new[] { "50", "30" }, table.Rows.Select(r => r.Cells[1].DisplayText)); } [SqlFact] @@ -180,7 +184,7 @@ public async Task NotebookKernel_AccumulatesTablesAcrossCells() var language = (SqlLanguage)services.Registry.Get(LanguageIds.Sql)!; var context = new KernelCreationContext(() => _dir, () => null); - using var kernel = (SqlNotebookKernel)language.NotebookKernels.Create(context); + using var kernel = (SqlNotebookKernel)language.Engine.NotebookKernels.Create(context); // Cell 1: create + insert var req1 = new KernelExecutionRequest @@ -195,16 +199,17 @@ public async Task NotebookKernel_AccumulatesTablesAcrossCells() Assert.True(res1.Success, res1.ErrorMessage); // Cell 2: query from the same DB session - var consoleOut = new List(); + var tables = new List(); var req2 = new KernelExecutionRequest { Code = "SELECT val FROM accumtest;", - OnConsole = s => consoleOut.Add(s) + OnConsole = _ => { }, + OnRichOutput = output => tables.Add(output.TableResult!) }; var res2 = await kernel.ExecuteAsync(req2, CancellationToken.None); Assert.True(res2.Success, res2.ErrorMessage); - Assert.Contains("100", string.Join("", consoleOut)); + Assert.Equal("100", Assert.Single(Assert.Single(tables).Rows).Cells[0].DisplayText); } [SqlFact] @@ -215,7 +220,7 @@ public async Task NotebookKernel_SharesVariableOutAsJson() var language = (SqlLanguage)services.Registry.Get(LanguageIds.Sql)!; var context = new KernelCreationContext(() => _dir, () => null); - using var kernel = (SqlNotebookKernel)language.NotebookKernels.Create(context); + using var kernel = (SqlNotebookKernel)language.Engine.NotebookKernels.Create(context); // Create a table to share await kernel.ExecuteAsync(new KernelExecutionRequest diff --git a/Tests/Services/Execution/ExternalChangeTests.cs b/Tests/Services/Execution/ExternalChangeTests.cs index ef6d94b9..4a27001a 100644 --- a/Tests/Services/Execution/ExternalChangeTests.cs +++ b/Tests/Services/Execution/ExternalChangeTests.cs @@ -30,13 +30,19 @@ public void Dispose() } } - private CSharpCodeStudioViewModel CreateStudio(PdfEditorApp.Plugins.CSharpEditor.Models.ScriptDocumentItem script) => new( - script, - _storage, - new RoslynCompilerService(), - new ScriptExecutionEngine(), - backToHubAction: () => { }, - backToHomeAction: () => { }); + // A studio lists its folder in the background when it is made: wait for the listing, so the Explorer has its files. + private async Task CreateStudioAsync(PdfEditorApp.Plugins.CSharpEditor.Models.ScriptDocumentItem script) + { + var studio = new CSharpCodeStudioViewModel( + script, + _storage, + new RoslynCompilerService(), + new ScriptExecutionEngine(), + backToHubAction: () => { }, + backToHomeAction: () => { }); + await studio.RefreshExplorerAsync(); + return studio; + } [Theory] [InlineData("/work/.git/config", true)] @@ -97,7 +103,7 @@ public async Task RefreshingTheExplorer_KeepsTheFoldersTheUserHadOpenOpen() var folder = await _storage.CreateFolderAsync(null, "Kept"); await _storage.CreateNewScriptAsync("Inside", folderPath: folder); var loose = await _storage.CreateNewScriptAsync("Loose"); - var studio = CreateStudio(loose); + var studio = await CreateStudioAsync(loose); studio.ExplorerRootItems.Single(i => i.IsDirectory).IsExpanded = true; await studio.RefreshExplorerAsync(); diff --git a/Tests/Services/Workspace/WorkspaceStalenessTests.cs b/Tests/Services/Workspace/WorkspaceStalenessTests.cs index 17e465bc..11f5872e 100644 --- a/Tests/Services/Workspace/WorkspaceStalenessTests.cs +++ b/Tests/Services/Workspace/WorkspaceStalenessTests.cs @@ -33,13 +33,20 @@ public void Dispose() } } - private CSharpCodeStudioViewModel CreateStudio(PdfEditorApp.Plugins.CSharpEditor.Models.ScriptDocumentItem script) => new( - script, - _storage, - new RoslynCompilerService(), - new ScriptExecutionEngine(), - backToHubAction: () => { }, - backToHomeAction: () => { }); + // A studio lists its folder in the background when it is made: wait for that scan to end, so a count taken after it is + // not one the scan is still moving. + private async Task CreateStudioAsync(PdfEditorApp.Plugins.CSharpEditor.Models.ScriptDocumentItem script) + { + var studio = new CSharpCodeStudioViewModel( + script, + _storage, + new RoslynCompilerService(), + new ScriptExecutionEngine(), + backToHubAction: () => { }, + backToHomeAction: () => { }); + await studio.RefreshExplorerAsync(); + return studio; + } [Fact] public async Task StructureVersion_MovesWhenItemsAreCreatedRenamedOrDeleted_ButNotOnASave() @@ -86,7 +93,7 @@ public async Task SwitchingTabs_DoesNotRescanTheWorkspace() { var first = await _storage.CreateNewScriptAsync("Alpha"); var second = await _storage.CreateNewScriptAsync("Beta"); - var studio = CreateStudio(first); + var studio = await CreateStudioAsync(first); var scans = _storage.WorkspaceScanCount; await studio.UpdateActiveScriptAsync(second); @@ -103,7 +110,7 @@ public async Task SwitchingTabs_AfterAnotherPageCreatedAScript_ShowsTheNewScript { var first = await _storage.CreateNewScriptAsync("Alpha"); var second = await _storage.CreateNewScriptAsync("Beta"); - var studio = CreateStudio(first); + var studio = await CreateStudioAsync(first); await studio.UpdateActiveScriptAsync(second); await _storage.CreateNewScriptAsync("Gamma"); @@ -116,7 +123,7 @@ public async Task SwitchingTabs_AfterAnotherPageCreatedAScript_ShowsTheNewScript public async Task ShowingTheExplorerAgain_WithNothingChanged_DoesNotRescan() { var script = await _storage.CreateNewScriptAsync("Alpha"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); var scans = _storage.WorkspaceScanCount; await studio.RefreshExplorerIfStaleAsync(); @@ -129,7 +136,7 @@ public async Task ShowingTheExplorerAgain_WithNothingChanged_DoesNotRescan() public async Task ShowingTheExplorerAgain_AfterASaveOnly_DoesNotRescan() { var script = await _storage.CreateNewScriptAsync("Alpha"); - var studio = CreateStudio(script); + var studio = await CreateStudioAsync(script); script.Code = "// edited"; await _storage.SaveScriptAsync(script); diff --git a/Tests/TestSupport/CppFactAttribute.cs b/Tests/TestSupport/CppFactAttribute.cs index cf7aef6d..7e397ddd 100644 --- a/Tests/TestSupport/CppFactAttribute.cs +++ b/Tests/TestSupport/CppFactAttribute.cs @@ -49,6 +49,8 @@ public static class TestCpp var scratch = Path.Combine(Path.GetTempPath(), "FryPDF_TestCpp_" + Guid.NewGuid().ToString("N")); try { + // (the smoke test below writes its source here: a folder that isn't there made every machine "have no toolchain") + Directory.CreateDirectory(scratch); var services = new StudioLanguageServices(scratch); var cpp = (CppLanguage)services.Registry.Get(LanguageIds.Cpp)!; var explicitPath = Environment.GetEnvironmentVariable("FRY_TEST_CPP"); diff --git a/Tests/TestSupport/RustFactAttribute.cs b/Tests/TestSupport/RustFactAttribute.cs index 484ceb62..eb2fcedb 100644 --- a/Tests/TestSupport/RustFactAttribute.cs +++ b/Tests/TestSupport/RustFactAttribute.cs @@ -49,6 +49,8 @@ public static class TestRust var scratch = Path.Combine(Path.GetTempPath(), "FryPDF_TestRust_" + Guid.NewGuid().ToString("N")); try { + // (the smoke test below writes its source here: a folder that isn't there made every machine "have no toolchain") + Directory.CreateDirectory(scratch); var services = new StudioLanguageServices(scratch); var rust = (RustLanguage)services.Registry.Get(LanguageIds.Rust)!; var explicitPath = Environment.GetEnvironmentVariable("FRY_TEST_RUST"); diff --git a/Tests/Visuals/Data/InspectorChain.cs b/Tests/Visuals/Data/InspectorChain.cs new file mode 100644 index 00000000..88a36d65 --- /dev/null +++ b/Tests/Visuals/Data/InspectorChain.cs @@ -0,0 +1,45 @@ +using PdfEditorApp.Plugins.CSharpEditor.Models; +using Xunit; + +namespace CSharpEditorPlugin.Tests; + +/// +/// What the Object Explorer shows for an object that points back to itself, the same in every language: the desktop's C# +/// inspector lists the members of the top object and of three levels below it, and the fifth level is a title with nothing +/// under it. A reference back to an object above is flagged but listed like any other (the desktop shows it unrolled). +/// +internal static class InspectorChain +{ + /// The nodes met by following one property down from : the top node, what it points to, and so on. + public static List Follow(ObjectInspectorNode top, string property) + { + var nodes = new List { top }; + var current = top; + while (current.Properties.FirstOrDefault(p => p.Name == property)?.ChildNode is { } next) + { + nodes.Add(next); + current = next; + } + + return nodes; + } + + /// + /// Five levels, each one but the last listing its members. With the property points back at the top + /// object, so every level below it is flagged as a reference back (and still lists its members). + /// + public static void AssertFiveLevels(ObjectInspectorNode top, string property, IReadOnlyList members, bool cycle = true) + { + var chain = Follow(top, property); + Assert.Equal(5, chain.Count); + + for (var level = 0; level < 4; level++) + { + Assert.Equal(members, chain[level].Properties.Select(p => p.Name)); + Assert.Equal(cycle && level > 0, chain[level].IsCircularReference); + } + + Assert.Empty(chain[4].Properties); + if (cycle) Assert.Equal(top.HeaderTitle, chain[4].HeaderTitle); + } +} diff --git a/Tests/Visuals/Data/ObjectInspectorTests.cs b/Tests/Visuals/Data/ObjectInspectorTests.cs index daa26e85..63d73d36 100644 --- a/Tests/Visuals/Data/ObjectInspectorTests.cs +++ b/Tests/Visuals/Data/ObjectInspectorTests.cs @@ -95,6 +95,9 @@ public void Build_ShouldHandle_CircularReferences_WithoutInfiniteLoop() Assert.True(anotherProp.IsComplexChild); Assert.NotNull(anotherProp.ChildNode); Assert.Equal("TestPeople", anotherProp.ChildNode.HeaderTitle); + + // The desktop shows the cycle unrolled: levels one to four list their members, the fifth is a title. Every other language matches this. + InspectorChain.AssertFiveLevels(node, "Another", ["Name", "Class", "Numbers", "Another"]); } [Fact] diff --git a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Execution.cs b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Execution.cs index ae8c23a1..31418766 100644 --- a/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Execution.cs +++ b/ViewModels/CodeStudio/CSharpCodeStudioViewModel.Execution.cs @@ -693,8 +693,10 @@ void ShowOutput(RichCellOutput rich) { Diagnostics.Add(new Common.DiagnosticItemViewModel(d, (l, c) => RequestNavigateToCaret?.Invoke(l, c))); } - ErrorCount = Diagnostics.Count(d => d.Severity == DiagnosticSeverity.Error); - WarningCount = Diagnostics.Count(d => d.Severity == DiagnosticSeverity.Warning); + // Counted from the result, not from the shown list: a live check that finishes now changes that list + // while it is being enumerated. + ErrorCount = kernelResult.Diagnostics.Count(d => d.Severity == DiagnosticSeverity.Error); + WarningCount = kernelResult.Diagnostics.Count(d => d.Severity == DiagnosticSeverity.Warning); SelectedBottomTabIndex = 2; } else diff --git a/docs/adding-a-language.md b/docs/adding-a-language.md index ed96f3b4..935cf455 100644 --- a/docs/adding-a-language.md +++ b/docs/adding-a-language.md @@ -66,10 +66,12 @@ Write a kernel program that speaks the [Fry kernel protocol](kernel-protocol.md) - **Embedding:** add the program's files to `CSharpEditorPlugin.csproj` as `EmbeddedResource`s with a `LogicalName` prefix, as `PythonKernel.%(Filename)%(Extension)` does. Your `IKernelLauncher` extracts them with `EmbeddedKernelFiles.Extract` (once per content hash). - **Launching:** the launcher resolves the toolchain for `context.WorkingDirectory()` (the notebook's folder) and returns the command. It throws `KernelUnavailableException` with the guidance when the toolchain is missing. - **What the notebook does with it:** each notebook tab's `NotebookKernelRouter` creates one kernel per language the first time a cell of it runs, and ends it when the tab closes. A `ProtocolKernel` restarts its program after a crash, with a note that the variables are gone. Cells route to it by their language or a `#!` first line. Output, rich output (MIME bundles), `input()`, Stop and Variables need nothing more. -- **In-process kernels:** a kernel can live in the studio instead, as C#'s does. Implement `INotebookKernel` directly. The compiled languages (Go, C++, F#, SQL, Rust) do it by building each cell into a small program and running it, replaying the items earlier cells defined; Rust's (`RustNotebookKernel`) is the model: +- **In-process kernels:** a kernel can live in the studio instead, as C#'s does. Implement `INotebookKernel` directly. The compiled languages (Go, C++, Rust) do it by building each cell into a small program and running it, replaying the items earlier cells defined; Rust's (`RustNotebookKernel`), Go's (`GoNotebookKernel`) and C++'s (`CppNotebookKernel`) are the models: all take the cell apart without a parser (`RustCellSplitter`, `GoCellSplitter`, `CppCellSplitter`), hand variables on through the language's own runtime (`fry::__persist!`, `fry.Persist`, `fry::persist`), show the last expression through it (`fry::show`, `fry.ShowAll`, `fry::show`), and map the compiler's lines back to the cell's (`RustCellProgram`, `GoCellProgram`, `CppCellProgram`). A language with no reflection (Rust, C++) reads a type's members from the cell's own source and registers them for the runtime to walk (C++: a generated `fry::fields`). A language whose compiler can be hosted does better, and F# does: its kernel (`FSharp/Kernel/fry_kernel.fsx`) is a [protocol kernel](kernel-protocol.md) that runs the compiler's own evaluation session, so there is nothing to replay and state, values, diagnostics and `#!share` come from the session. SQL runs each cell with the `sqlite3` command against a database that lasts as long as the kernel, and reads `-json` output into tables. Notes on the replaying kernels: - A cell's process is waited for with `IManagedProcess.WaitForExitOrKillAsync(ct)`, which kills it where the wait is cancelled. Don't register `Kill` on the token and then `WaitAsync(ct)`: the cancelled wait's continuation can dispose the registration before it runs, and the process keeps going. - No time limit of the kernel's own: how long a cell may take is the studio's `ExecutionTimeoutSeconds` setting, which arrives as the cancellation token. - - Close the program's standard input (`CloseInput()`), so a cell that reads input sees the end of it instead of waiting for good. + - Close the program's standard input (`CloseInput()`), so a cell that reads input sees the end of it instead of waiting for good, unless the language's runtime crate has a way to ask: Rust's `fry::input("Prompt: ")` prints `__FRY_INPUT__ {"prompt":"...","password":false}`, `ExternalOutputProcessor` hands it to `onInputRequest`, and the kernel answers by writing the line to the program (or closing its input for no answer). The kernel keeps stdin open only for a program that names `fry::input` or `fry::password`, and sets `FRY_INPUT_REQUESTS=1` so the crate asks the notebook instead of printing the prompt as a terminal program would. + - Merge the program's error output into its output (`ProcessStartSpec.MergeStandardError`; on Unix a shell `exec`s the program with `2>&1`), or what it printed to stderr arrives after output printed after it: two pipes are read by two threads. + - A compiler or panic message names the generated file and its lines: rewrite them to the cell's (`RustConsoleText`: `--> cell:3:18`, the quoted `3 | ...` lines, `panicked at cell:3:2`), keeping the raw text for the diagnostics parser. - Report a build failure once, after the last attempt (a kernel may try a cell two ways); put compiler messages on the cell's own lines, not the generated program's. - Text the program prints as a `text/plain` display (`__FRY_DISPLAY__`) is console text; `ExternalOutputProcessor` also reads `__FRY_SHARE__` lines, a value a cell offers to other kernels. diff --git a/docs/kernel-protocol.md b/docs/kernel-protocol.md index 5c330ac2..34529a18 100644 --- a/docs/kernel-protocol.md +++ b/docs/kernel-protocol.md @@ -1,6 +1,6 @@ # The Fry kernel protocol -A notebook language that runs as its own program (Python today) talks to the studio with this protocol. The studio side is `Services/Kernels/ProtocolKernel.cs`. The Python side is `Services/Languages/Python/Kernel/fry_kernel.py`, which is a working reference for any new kernel. To give a language notebook cells, write a program that speaks this protocol and an `IKernelLauncher` that starts it. [adding-a-language.md](adding-a-language.md) has the rest of the checklist. +A notebook language that runs as its own program (Python, JavaScript, F# and Java today) talks to the studio with this protocol. The studio side is `FrySharp.Core/Services/Kernels/ProtocolKernel.cs`. The Python side is `FrySharp.Core/Services/Languages/Python/Kernel/fry_kernel.py`, which is a working reference for any new kernel; `…/Java/Kernel/FryKernel.java` (a JShell program run as `java FryKernel.java`) and `…/FSharp/Kernel/fry_kernel.fsx` are others, for a compiled language whose compiler can be hosted: it runs the F# compiler's own evaluation session (`FsiEvaluationSession`, which ships in the .NET SDK) inside `dotnet fsi`, so the cells of a notebook are one session. To give a language notebook cells, write a program that speaks this protocol and an `IKernelLauncher` that starts it. [adding-a-language.md](adding-a-language.md) has the rest of the checklist. It is deliberately small next to Jupyter's: no ZeroMQ and no connection files, just two pipes. A Jupyter kernel could still be supported later through an adapter that implements `INotebookKernel`. @@ -23,7 +23,7 @@ It is deliberately small next to Jupyter's: no ZeroMQ and no connection files, j | `variables` | `id` | List the variables, for the Variables panel. | | `get_value` | `id`, `name` | A variable's value as JSON, for `#!share`. | | `set_value` | `id`, `name`, `json` | Set (or create) a variable from JSON, for `#!share`. | -| `add_search_path` | `id`, `path` | Add a folder to the import path while running. This lets a package just installed into a new environment work without a restart. Optional: implement it only if the language has one. | +| `add_search_path` | `id`, `path` | Add a folder to the import path (a jar to the classpath) while running. This lets a package just installed work without a restart. The studio sends it for every path it was asked to add, when the kernel starts and again after a restart, so a package line in the first cell of a notebook works. Optional: implement it only if the language has one. | | `event` | `id`, `display_id`, `event` | The user clicked, selected or stepped in a visual the program listens to (`subscribe`). Run the callbacks registered for it, with their output sent under this `id`: it goes to the cell that showed the visual. `event` is `{"event": "click", "target", "modifiers"}`, `{"event": "select", "targets"}` or `{"event": "step", "index"}` ([visual-protocol.md](visual-protocol.md#updates-and-events)). | | `shutdown` | — | Exit now. Optional, because closing stdin means the same. | @@ -39,7 +39,7 @@ The studio sends one request at a time for each notebook, and each `execute` fin | `update_display` | `id`, `data`, `metadata`, `transient` | Redraw the output named `transient.display_id` where it is, with this bundle. An id never shown is shown as a new output. | | `subscribe` / `unsubscribe` | `id`, `display_id`, `events` | Start (or stop) hearing about `click`, `select` and `step` events on that output; every kind when `events` is left out. | | `input_request` | `id`, `prompt`, `password` | The cell asks for a line of input. Wait for `input_reply`. The studio echoes the prompt and the answer into the cell's output. | -| `error` | `id`, `ename`, `evalue`, `traceback`, `line`, `missingModule`, `missingName` | The cell failed. `traceback` is the text to show. `line` is the failing line in the cell, if it's known. `missingModule` (e.g. `"cv2"`) makes the cell offer to install the package. `missingName` makes it offer to run the cells above. | +| `error` | `id`, `ename`, `evalue`, `traceback`, `line`, `diagnostics`, `missingModule`, `missingName` | The cell failed. `traceback` is the text to show. `line` is the failing line in the cell, if it's known. `diagnostics`, when the language has a compiler that says where, is a list of `{line, column, endLine, endColumn, severity, code, message}` (1-based, on the cell's own lines and columns) that the notebook underlines and lists in Problems. `missingModule` (e.g. `"cv2"`) makes the cell offer to install the package. `missingName` makes it offer to run the cells above. | | `reply` | `id`, `status`, … | Ends a request. `status` is `ok`, `error` (with `message` for requests other than `execute`) or `interrupted`. An `execute` reply carries `executionCount`, a `variables` reply `variables` (`[{name, type, value, kind}]`), and a `get_value` reply `json`. | Send everything that belongs to an execution before its `reply`: flush buffered output, then any pending `display`. The reply is what ends the cell in the studio. @@ -50,6 +50,7 @@ Send everything that belongs to an execution before its `reply`: flush buffered 1. `application/vnd.fry.table+json`: the studio's own table (column sorting, number alignment). The shape is `{"title", "columns": [...], "numeric": [bool...], "rows": [[...]], "totalRows", "totalColumns"}`. Send at most about 1,000 rows. The studio notes "showing the first N of M rows" when `totalRows` is larger. The studio's charts, 3D plots and visualizers come the same way, as `application/vnd.fry.chart.v1+json`, `application/vnd.fry.plot3d.v1+json` and `application/vnd.fry.visualizer.v1+json`: see [visual-protocol.md](visual-protocol.md). + `application/vnd.fry.inspector+json` shows an object as the studio's Object Explorer, as a C# value is: `{"title": "People", "properties": [{"name": "name", "value": "Code", "isString": true}, {"name": "another", "child": {"title": "People", "circular": true, "properties": [{"name": "name", "value": "Code", "isString": true}]}}]}`. A row is a scalar (`value`, with `isNull` / `isString` for its colour) or a nested object (`child`); `circular` marks an object already on the way down. It is listed like any other object (the desktop's C# inspector does the same), so a self-referencing value unrolls until the depth limit: the members of the top object and of three levels below it, then a fifth level that is a title with no `properties`. `circular` only flags the node (the chip is `↻`, and it starts closed). A kernel sends it for the last expression of a cell when that is an object (not a scalar, and not a short collection of scalars, which is one text line `[ 1, 2, 3 ]`), and keeps `text/plain` for hosts that cannot draw it. The studio reads at most 6 levels. 2. `image/png` or `image/jpeg`, base64. `metadata["image/png"] = {"width", "height"}` gives its size in pixels. 3. `image/svg+xml`, drawn by the HTML view. 4. `text/html`. @@ -68,7 +69,7 @@ An error `reply` for `get_value` or `set_value` should explain in its `message` ## Programs that aren't kernels -A program run from Code Studio, and a Go, Rust, C++ or F# notebook cell, owns its stdin and stdout: its user types into one and reads the other. It sends the same `display`, `update_display`, `subscribe` and `unsubscribe` messages as lines on stdout, each after the marker `__FRY_DISPLAY__ ` (the marker may be left out for a line that starts `{"type":` with one of those types). Its events come another way, over a loopback socket: +A program run from Code Studio, and a Go, Rust or C++ notebook cell, owns its stdin and stdout: its user types into one and reads the other. It sends the same `display`, `update_display`, `subscribe` and `unsubscribe` messages as lines on stdout, each after the marker `__FRY_DISPLAY__ ` (the marker may be left out for a line that starts `{"type":` with one of those types). Its events come another way, over a loopback socket: - The studio sets two environment variables for the program (not for its build steps): `FRY_EVENTS`, the address (`127.0.0.1:port`), and `FRY_EVENTS_TOKEN`, a token of its own. - When its code first listens to a visual, the program connects to that address and sends `{"type": "hello", "token": "…"}` as its first line. A connection without a token the studio gave out is closed. diff --git a/docs/session-protocol.md b/docs/session-protocol.md index 5960cfd1..bc095981 100644 --- a/docs/session-protocol.md +++ b/docs/session-protocol.md @@ -54,6 +54,7 @@ Paths are relative to the workspace root and use `/`. `""` is the root. | Method | Params | Result | |---|---|---| | `fry/initialize` | `{ clientId, clientName?, protocolVersion }` | `{ protocolVersion, serverVersion, workspaceRoot, workspaceName, languages[], resumed, maxOpenFileBytes }`. `languages[]` says what each language can do: `canRun`, `canRunCells`, `hasCompletion`, its extensions and new-file template. A different `protocolVersion` answers `NotSupported`. | +| `fry/engine/info` | `{}` | `{ serverVersion, protocolVersion, runtime, operatingSystem, architecture, workspaceRoot, toolchains: [{ languageId, displayName, found, name?, version?, path?, source?, missing? }] }`: what the engine runs on and the tool each language found (C# is the engine itself), or what is missing and how to get it. Probes the machine, so the first answer can take seconds. "FrySharp: Show Engine Info" shows it. | ### Workspace @@ -88,6 +89,18 @@ The server holds the text of every document the tab has open, so language featur Diagnostics come by themselves as `fry/language/diagnostics`, about 400 ms after the last change. Statements are checked as the C# script they run as: a trailing expression is the result, not an error. +### Language features in notebook cells + +A client that holds its cells itself (a notebook in VS Code) asks about a cell's text directly: no document is open on the server. The language is the cell's, after any `#!language` line. + +| Method | Params | Result | +|---|---|---| +| `fry/language/completeCell` | `{ language, source, offset, context? }` | As `fry/language/completion`. | +| `fry/language/hoverCell` | `{ language, source, offset, context? }` | As `fry/language/hover`. | +| `fry/language/diagnoseCell` | `{ language, source, context? }` | `{ diagnostics: [{ severity, code, message, startLine, startColumn, endLine, endColumn }] }`, on the cell's own lines. | + +`context` is the text of the cells before this one **in the same language**, in order. For C# they are put in front of the cell, so what they define is in scope (their `using` lines are hoisted, directive lines emptied, and what only joining cells makes wrong, such as defining a name twice, is not reported). For SQL the tables and views they create are offered. `hasCompletion` in `fry/initialize` says which languages answer `completeCell` and `hoverCell` (C# and SQL): the others get none. `diagnoseCell` reports a notebook directive that is not one (`#!pyton`, code `FRY0001`) in any language, and compiler errors for C#. + ### Runs | Method | Params | Result | @@ -122,6 +135,32 @@ The server owns an open notebook: its cells, outputs and kernels. Every tab that A cell's first lines can pick its language (`#!python`) and copy a variable from another kernel (`#!share --from csharp nums [--as numbers]`). See [kernel-protocol.md](kernel-protocol.md) for how the other languages' kernels run. +#### A notebook the client holds (VS Code) + +A client that keeps its notebook's cells itself (an editor with its own document model) has the server hold only the kernels. Every notebook request then takes `notebookId` instead of `path`: any text the client keeps stable for that notebook (its URI, an untitled notebook's name). Two notebooks with the same file name in different folders are two notebooks. + +| Method | Params | Result | +|---|---|---| +| `fry/notebook/execute` | `{ path, cellId, source, language?, notebookId?, workingDirectory?, executionId? }` | `{ executionCount, success, durationMs, lastSeq, diagnostics? }`. `path` is any text when `notebookId` is given. `workingDirectory` is the folder the notebook's kernels run in (its own folder, even outside the workspace). `diagnostics` say where the cell went wrong, in the cell's own lines and columns (1-based): the compiler's errors, a traceback's line, SQLite's error line. | +| `fry/notebook/{interrupt,restart,variables,close}` | `{ notebookId }` | As above. | + +`executionId` is chosen by the client for each run. Every `fry/notebook/output` and `fry/notebook/cellState` about the run carries it and a `seq` (1, 2, 3, ... in the order they were produced), and the answer to `fry/notebook/execute` carries `lastSeq`: the answer can arrive before some notifications, so the client waits until it has handled `lastSeq` of them. A `display` output may carry `displayId` and `update`: a redraw of an earlier display (a chart a program updates) replaces it instead of adding another. A stream output is one per cell, the text in the order it was printed, ANSI colours kept. + +The server's request `fry/input/request` also says which cell asks: `{ prompt, password, path?, cellId?, executionId? }`. + +Languages that run cells: C#, F# (`#!fsharp`, `#!fs`), Java (`#!java`, `#!jdk`), Rust (`#!rust`, `#!rs`), Go (`#!go`, `#!golang`), C++ (`#!cpp`, `#!c++`), Python, JavaScript and SQL (`#!sql`, `#!sqlite`). A SQL cell runs on the SQLite 3 CLI (`sqlite3`) installed on the machine, against a database that lives as long as the notebook's kernel. The rows a statement returns are one `application/vnd.fry.table+json` display each (titled `Result`, or `Result 1`, `Result 2` when there are several); the text a script prints (`.print`) and SQLite's errors are the stream. The notebook's tables are its variables, and `#!share --from csharp rows` makes a list, an object or a single value a table in the SQL cell. + +An F# cell runs in the notebook's F# Interactive session (one `dotnet fsi` per notebook, so it needs the .NET SDK): what a cell defines the next one sees. The value of its last expression is a display (a list of records is a table, a record or an object the Object Explorer, a scalar or a short list text), compile errors are `diagnostics` with their columns, and the values are the variables (a function written `let f x = ...` is not a value to F# Interactive and is not listed). `#!share` hands a value to another kernel as JSON, and takes one as an F# value: a list of objects is an array of dictionaries (`rows.[0].["name"]`). A package is a line of the cell, `#r "nuget: Package, 1.2.3"`, which F# Interactive restores and references itself. Stop ends a cell that waits (`Thread.Sleep`, a read); one that only computes ends the kernel, and its values are gone. + +A Java cell runs in the notebook's JShell session (one `java FryKernel.java` per notebook, so it needs a JDK): a variable, method, class or record a cell declares the next one uses. The value of an expression is a display (a list of records, beans or maps is a table, an object the Object Explorer, a scalar or a short list text); a declaration shows nothing. A compile error is a `diagnostic` with its column, and a runtime error names its real kind (`IllegalStateException`) and the line of the cell it went through (the innermost frame in a snippet of the cell). `//DEPS group:artifact:version` (or `%maven install ...`) at the top of a cell downloads the library from Maven Central and puts it on the classpath, even when it is the first cell of the notebook. Stop ends any cell, even one that only computes, and keeps what the notebook declared. + + +A Rust cell is built with Cargo as a small program (one `cargo build` of a crate per cell, so it needs the Rust toolchain; the first cell also builds the `fry` display crate, a few seconds, later cells about a second): the functions, types and imports earlier cells defined are written in front of it, and the `let` variables of plain data (numbers, text, lists, options, tuples, maps, sets) are written back as source for the next cell. A variable of a type the cell defined is not kept (the cell says so; `fry::share!(name)` keeps it as data for other languages). The value of a last expression without a semicolon is a display: a scalar or `Some(3)` is text, a short list of scalars is one line `[ 1, 2, 3 ]`, an empty list `[ ]`, a list of structs, tuples or maps a table, and a struct, tuple or enum variant with data the Object Explorer (read from the value's `Debug` text, so the type needs `Debug`). `fry::markdown`, `fry::table`, `fry::line_chart` ... show output. What the program prints keeps its order, stderr among it; a compile error and a panic are `diagnostics` on the cell's own line (the text says `--> cell:3:18` and `panicked at cell:3:2`). A plain `std::io::stdin()` read sees the end of input; `fry::input("Prompt: ")` and `fry::password(...)` ask (`fry/input/request`), and `None` is the end of input. `// #crate: name = "1"` or `%cargo add name` fetches a crate. Stop ends the program and keeps what earlier cells made. + +A Go cell is built with the Go toolchain as a small program (one `go build` per cell in a module of the notebook's own, against one build cache for everything, so it needs `go` 1.18 or newer; the first cell also builds the standard library and the `fry` package, a few seconds, later cells a fraction of a second). The functions, methods, types, constants, variables and imports earlier cells declared are written in front of it (a declaration written again takes the old one's place), and the variables of the cell's `main` are given back their values at its start, in an outer scope, so `x := 5` may make again what an earlier cell made. A variable made with `:=` or declared at the top with `var` is handed on as JSON with its Go type (`fry.Persist`, which checks that the value comes back the same); one that can't be saved (a func, a channel, a struct with lowercase fields, an error) isn't, and the cell says so once, and says why when a later cell uses the name. A variable declared at the top is a package variable, so a function in a later cell sees it; one made with `:=` is local to the cells' `main`. The value of a last expression is a display: a scalar is text (a string quoted), a short list of scalars is one line `[ 1, 2, 3 ]`, an empty one `[ ]`, a slice of structs or maps a table, a map a key/value table, and a struct or pointer to one the Object Explorer; a statement that prints isn't shown, and a call that returns nothing is run as it is. `fry.Markdown`, `fry.Table`, `fry.LineChart` ... show output. What the program prints keeps its order, stderr among it; a compile error and a panic are `diagnostics` on the cell's own line (the text says `cell:3:16`). A plain `os.Stdin` read sees the end of input; `fry.Input("Prompt: ")` and `fry.Password(...)` ask (`fry/input/request`), and false is the end of input. `#!share --from csharp rows` makes the list a typed variable (`[]int`, `[]float64`, `[]string`, `map[string]any`, `[]map[string]any` ...), and `#!share --from go name` hands on any variable that can be saved, or what `fry.Share("name", value)` offered. `%go get github.com/user/module` downloads a module (into a scratch module that every notebook build then requires); cells import it. Stop ends the program and keeps what earlier cells made. + +A C++ cell is built with a C++20 compiler (clang++, g++ or MSVC `cl`) as a small program (one compile per cell, a second or two, because the `` and `` headers that show values are included): the includes, functions (overloads live together, one with the same signature replaces the old), types, aliases, macros and `static`, `constexpr`, `const` or `extern` variables earlier cells declared are written in front of it, and the variables of the cell's `main` are given back their values at its start, in an outer scope, so `int x = 5;` may make again what an earlier cell made. A variable declared in a cell is handed on as JSON with its type as C++ writes it (`fry::persist`, which checks that the value comes back the same): numbers, text, vectors, arrays, deques, lists, sets, maps with text or number keys, optionals, pairs, tuples and structs of those; a struct's public data members are read from its definition by the notebook, which writes a `fry::fields` for it. One that can't be saved (a pointer, a C array, a lambda, a class with private members) isn't, and the cell says so once, and says why when a later cell uses the name. A `static` variable at the top is a global, so a function in a later cell sees it; one declared in a statement is local to the cells' `main`. The value of a last expression written without a `;` is a display: a scalar is text (a string quoted), a short list of scalars is one line `[ 1, 2, 3 ]`, an empty one `[ ]`, a vector of structs, pairs or maps a table, a map a key/value table, and a struct, pair or tuple the Object Explorer (a type the notebook doesn't know the members of is its `operator<<` text, or its name); a statement that prints isn't shown, and a call that returns nothing is run as it is. `fry::markdown`, `fry::display::table`, `fry::line_chart` ... show output. What the program prints keeps its order, stderr among it (output is unbuffered); a compile error is a `diagnostic` on the cell's own line (the text says `cell:3:5`), and an uncaught exception or a crash fails the cell naming its cause (C++ has no line to give). A plain `std::cin` read sees the end of input; `fry::input("Prompt: ")` and `fry::password(...)` ask (`fry/input/request`), and an empty `std::optional` is the end of input. `#!share --from csharp rows` makes the list a typed variable (`std::vector`, `std::vector`, `std::vector`, `std::vector>`; an object or a mixed list is a `fry::Json` the cell reads with `config["name"].as_string()`), and `#!share --from cpp name` hands on any variable that can be saved, or what `fry::share("name", value)` offered. `%vcpkg install name` adds a package. Stop ends the program and keeps what earlier cells made. + ## Notifications (server → browser) | Method | Params | diff --git a/samples/demo.frynb b/samples/demo.frynb index 9120b152..fe4eccdf 100644 --- a/samples/demo.frynb +++ b/samples/demo.frynb @@ -66,7 +66,7 @@ "Type": 0, "Source": "// Strongly-typed enterprise cluster telemetry record\npublic record ClusterNode(\n string Region,\n string NodeId,\n string Role,\n double CpuPercent,\n double MemoryGb,\n double LatencyMs,\n string Status);\n\n// Global production infrastructure nodes\nvar clusterNodes = new[]\n{\n new ClusterNode(\"us-east-1\", \"node-core-01\", \"Primary Router\", 24.5, 64.0, 12.4, \"Optimal\"),\n new ClusterNode(\"us-west-2\", \"node-worker-02\", \"Compute Shard\", 78.2, 128.0, 28.1, \"Under Load\"),\n new ClusterNode(\"eu-west-1\", \"node-db-03\", \"Postgres Leader\", 45.0, 256.0, 18.7, \"Optimal\"),\n new ClusterNode(\"ap-southeast-1\", \"node-edge-04\", \"Ingress Proxy\", 15.8, 32.0, 42.0, \"Optimal\"),\n new ClusterNode(\"sa-east-1\", \"node-cache-05\", \"Redis In-Memory\", 88.9, 64.0, 68.5, \"Degraded\"),\n new ClusterNode(\"ap-northeast-1\", \"node-ai-06\", \"Tensor Pipeline\", 92.1, 192.0, 31.2, \"Heavy Compute\")\n};\n\n// Render rich interactive dark-mode table\nDisplay.Table(clusterNodes, \"Global Cluster Infrastructure & Edge Telemetry\");\n\n// LINQ Aggregations\ndouble avgLatency = clusterNodes.Average(n => n.LatencyMs);\ndouble totalRam = clusterNodes.Sum(n => n.MemoryGb);\nint healthyCount = clusterNodes.Count(n => n.Status == \"Optimal\");\n\nConsole.WriteLine($\"✓ Telemetry Snapshot: {clusterNodes.Length} clusters monitored\");\nConsole.WriteLine($\" - Average Edge Latency: {avgLatency:F1} ms\");\nConsole.WriteLine($\" - Total Provisioned RAM: {totalRam:N0} GB\");\nConsole.WriteLine($\" - Healthy SLA Quorum: {healthyCount}/{clusterNodes.Length} nodes\");", "OutputText": "Global Cluster Infrastructure & Edge Telemetry: ClusterNode[6]✓ Telemetry Snapshot: 6 clusters monitored\n - Average Edge Latency: 33.5 ms\n - Total Provisioned RAM: 736 GB\n - Healthy SLA Quorum: 3/6 nodes\n", - "ExecutionCount": 9, + "ExecutionCount": 18, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, @@ -78,7 +78,7 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Region ↕\n\n NodeId ↕\n\n Role ↕\n\n CpuPercent ↕\n\n MemoryGb ↕\n\n LatencyMs ↕\n\n Status ↕\n
\n \n us-east-1node-core-01Primary Router24.56412.4Optimal
\n
\n
\n
🔍 Row #1 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-east-1
\n
\n
NodeId
\n
node-core-01
\n
\n
Role
\n
Primary Router
\n
\n
CpuPercent
\n
24.5
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
12.4
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n us-west-2node-worker-02Compute Shard78.212828.1Under Load
\n
\n
\n
🔍 Row #2 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-west-2
\n
\n
NodeId
\n
node-worker-02
\n
\n
Role
\n
Compute Shard
\n
\n
CpuPercent
\n
78.2
\n
\n
MemoryGb
\n
128
\n
\n
LatencyMs
\n
28.1
\n
\n
Status
\n
Under Load
\n
\n
\n \n
\n
\n \n eu-west-1node-db-03Postgres Leader4525618.7Optimal
\n
\n
\n
🔍 Row #3 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
eu-west-1
\n
\n
NodeId
\n
node-db-03
\n
\n
Role
\n
Postgres Leader
\n
\n
CpuPercent
\n
45
\n
\n
MemoryGb
\n
256
\n
\n
LatencyMs
\n
18.7
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n ap-southeast-1node-edge-04Ingress Proxy15.83242Optimal
\n
\n
\n
🔍 Row #4 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-southeast-1
\n
\n
NodeId
\n
node-edge-04
\n
\n
Role
\n
Ingress Proxy
\n
\n
CpuPercent
\n
15.8
\n
\n
MemoryGb
\n
32
\n
\n
LatencyMs
\n
42
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n sa-east-1node-cache-05Redis In-Memory88.96468.5Degraded
\n
\n
\n
🔍 Row #5 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
sa-east-1
\n
\n
NodeId
\n
node-cache-05
\n
\n
Role
\n
Redis In-Memory
\n
\n
CpuPercent
\n
88.9
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
68.5
\n
\n
Status
\n
Degraded
\n
\n
\n \n
\n
\n \n ap-northeast-1node-ai-06Tensor Pipeline92.119231.2Heavy Compute
\n
\n
\n
🔍 Row #6 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-northeast-1
\n
\n
NodeId
\n
node-ai-06
\n
\n
Role
\n
Tensor Pipeline
\n
\n
CpuPercent
\n
92.1
\n
\n
MemoryGb
\n
192
\n
\n
LatencyMs
\n
31.2
\n
\n
Status
\n
Heavy Compute
\n
\n
\n \n
\n
\n
\n\n
\n Showing 6 of 6 rows\n FrySharp Polyglot Data\n
\n\n \n
", + "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Region ↕\n\n NodeId ↕\n\n Role ↕\n\n CpuPercent ↕\n\n MemoryGb ↕\n\n LatencyMs ↕\n\n Status ↕\n
\n \n us-east-1node-core-01Primary Router24.56412.4Optimal
\n
\n
\n
🔍 Row #1 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-east-1
\n
\n
NodeId
\n
node-core-01
\n
\n
Role
\n
Primary Router
\n
\n
CpuPercent
\n
24.5
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
12.4
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n us-west-2node-worker-02Compute Shard78.212828.1Under Load
\n
\n
\n
🔍 Row #2 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-west-2
\n
\n
NodeId
\n
node-worker-02
\n
\n
Role
\n
Compute Shard
\n
\n
CpuPercent
\n
78.2
\n
\n
MemoryGb
\n
128
\n
\n
LatencyMs
\n
28.1
\n
\n
Status
\n
Under Load
\n
\n
\n \n
\n
\n \n eu-west-1node-db-03Postgres Leader4525618.7Optimal
\n
\n
\n
🔍 Row #3 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
eu-west-1
\n
\n
NodeId
\n
node-db-03
\n
\n
Role
\n
Postgres Leader
\n
\n
CpuPercent
\n
45
\n
\n
MemoryGb
\n
256
\n
\n
LatencyMs
\n
18.7
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n ap-southeast-1node-edge-04Ingress Proxy15.83242Optimal
\n
\n
\n
🔍 Row #4 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-southeast-1
\n
\n
NodeId
\n
node-edge-04
\n
\n
Role
\n
Ingress Proxy
\n
\n
CpuPercent
\n
15.8
\n
\n
MemoryGb
\n
32
\n
\n
LatencyMs
\n
42
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n sa-east-1node-cache-05Redis In-Memory88.96468.5Degraded
\n
\n
\n
🔍 Row #5 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
sa-east-1
\n
\n
NodeId
\n
node-cache-05
\n
\n
Role
\n
Redis In-Memory
\n
\n
CpuPercent
\n
88.9
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
68.5
\n
\n
Status
\n
Degraded
\n
\n
\n \n
\n
\n \n ap-northeast-1node-ai-06Tensor Pipeline92.119231.2Heavy Compute
\n
\n
\n
🔍 Row #6 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-northeast-1
\n
\n
NodeId
\n
node-ai-06
\n
\n
Role
\n
Tensor Pipeline
\n
\n
CpuPercent
\n
92.1
\n
\n
MemoryGb
\n
192
\n
\n
LatencyMs
\n
31.2
\n
\n
Status
\n
Heavy Compute
\n
\n
\n \n
\n
\n
\n\n
\n Showing 6 of 6 rows\n FrySharp Polyglot Data\n
\n\n \n
", "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, @@ -120,7 +120,7 @@ "Type": 0, "Source": "// Multi-series Fourier harmonic wave decomposition\nvar t = Enumerable.Range(0, 100).Select(i => i * 2.0 * Math.PI / 100.0).ToArray();\nvar fundamental = t.Select(Math.Sin).ToArray();\nvar harmonic3 = t.Select(x => (1.0 / 3.0) * Math.Sin(3 * x)).ToArray();\nvar composite = t.Select(x => Math.Sin(x) + (1.0 / 3.0) * Math.Sin(3 * x) + (1.0 / 5.0) * Math.Sin(5 * x)).ToArray();\n\nDisplay.LineChart(\n (\"Fundamental (sin t)\", fundamental),\n (\"3rd Harmonic (1/3 sin 3t)\", harmonic3),\n (\"Composite Square Wave\", composite));\n\nConsole.WriteLine(\"✓ 2D Multi-series harmonic decomposition rendered.\");", "OutputText": "✓ 2D Multi-series harmonic decomposition rendered.\n", - "ExecutionCount": 10, + "ExecutionCount": 19, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, @@ -174,7 +174,7 @@ "Type": 0, "Source": "// 1. Regional CPU load bar chart\nvar loads = clusterNodes.ToDictionary(n => n.Region, n => n.CpuPercent);\nDisplay.BarChart(loads, \"Regional Compute Load (%)\");\n\n// 2. Resource memory allocation donut chart\nvar ramAllocation = clusterNodes.ToDictionary(n => $\"{n.Role} ({n.Region})\", n => n.MemoryGb);\nDisplay.Chart(ramAllocation, \"Cluster Memory Distribution (GB)\", chartType: \"donut\");\n\nConsole.WriteLine(\"✓ Categorical bar & donut charts rendered.\");", "OutputText": "✓ Categorical bar & donut charts rendered.\n", - "ExecutionCount": 11, + "ExecutionCount": 20, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, @@ -186,7 +186,7 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "
📊 Cluster Memory Distribution (GB)
DONUT
\n \n \n Primary Router (us-east-1): 64.0 (8.7%)\n\n Compute Shard (us-west-2): 128.0 (17.4%)\n\n Postgres Leader (eu-west-1): 256.0 (34.8%)\n\n Ingress Proxy (ap-southeast-1): 32.0 (4.3%)\n\n Redis In-Memory (sa-east-1): 64.0 (8.7%)\n\n Tensor Pipeline (ap-northeast-1): 192.0 (26.1%)\n\n \n Primary Router (us-east-1): 8.7%\n\n \n Compute Shard (us-west-2): 17.4%\n\n \n Postgres Leader (eu-west-1): 34.8%\n\n \n Ingress Proxy (ap-southeast-1): 4.3%\n\n \n Redis In-Memory (sa-east-1): 8.7%\n\n \n Tensor Pipeline (ap-northeast-1): 26.1%\n
", + "HtmlContent": "
📊 Cluster Memory Distribution (GB)
DONUT
\n \n \n Primary Router (us-east-1): 64.0 (8.7%)\n\n Compute Shard (us-west-2): 128.0 (17.4%)\n\n Postgres Leader (eu-west-1): 256.0 (34.8%)\n\n Ingress Proxy (ap-southeast-1): 32.0 (4.3%)\n\n Redis In-Memory (sa-east-1): 64.0 (8.7%)\n\n Tensor Pipeline (ap-northeast-1): 192.0 (26.1%)\n\n \n Primary Router (us-east-1): 8.7%\n\n \n Compute Shard (us-west-2): 17.4%\n\n \n Postgres Leader (eu-west-1): 34.8%\n\n \n Ingress Proxy (ap-southeast-1): 4.3%\n\n \n Redis In-Memory (sa-east-1): 8.7%\n\n \n Tensor Pipeline (ap-northeast-1): 26.1%\n
", "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, @@ -228,7 +228,7 @@ "Type": 0, "Source": "// 3D Sombrero surface wave: z = sin(r) / r\nDisplay.Plot3D((x, y) => {\n double r = Math.Sqrt(x * x + y * y);\n return r == 0 ? 1.0 : Math.Sin(r) / r;\n}, (-6.0, 6.0), (-6.0, 6.0), resolution: 28, title: \"Sombrero Wave Surface (z = sin(r) / r)\");\n\nConsole.WriteLine(\"✓ 3D Parametric surface mesh rendered.\");", "OutputText": "✓ 3D Parametric surface mesh rendered.\n", - "ExecutionCount": 12, + "ExecutionCount": 21, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, @@ -240,7 +240,7 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "
📊 Sombrero Wave Surface (z = sin(r) / r)
SURFACE
\n
\n
\n 🔄 Rotate\n \n
\n
\n 📐 Tilt\n \n
\n (Drag on canvas to rotate)\n
\n\n \n \n
", + "HtmlContent": "
📊 Sombrero Wave Surface (z = sin(r) / r)
SURFACE
\n
\n
\n 🔄 Rotate\n \n
\n
\n 📐 Tilt\n \n
\n (Drag on canvas to rotate)\n
\n\n \n \n
", "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, @@ -282,7 +282,7 @@ "Type": 0, "Source": "using System.IO;\nusing System.IO.Compression;\n\nbyte[] GenerateMandelbrot(int w, int h)\n{\n var raw = new byte[h * (1 + w * 4)];\n for (int y = 0; y < h; y++)\n {\n int rowOff = y * (1 + w * 4);\n raw[rowOff] = 0;\n double cy = (y - h / 2.0) * 2.8 / h;\n for (int x = 0; x < w; x++)\n {\n double cx = (x - w * 0.72) * 2.8 / w;\n double zx = 0, zy = 0;\n int iter = 0;\n while (zx * zx + zy * zy < 4.0 && iter < 64)\n {\n double tmp = zx * zx - zy * zy + cx;\n zy = 2.0 * zx * zy + cy;\n zx = tmp;\n iter++;\n }\n\n int pxOff = rowOff + 1 + x * 4;\n if (iter == 64)\n {\n raw[pxOff] = 13; raw[pxOff + 1] = 17; raw[pxOff + 2] = 23; raw[pxOff + 3] = 255;\n }\n else\n {\n double t = (double)iter / 64.0;\n raw[pxOff] = (byte)(Math.Sin(t * 6.28) * 110 + 130);\n raw[pxOff + 1] = (byte)(Math.Sin(t * 6.28 + 2.0) * 110 + 130);\n raw[pxOff + 2] = (byte)(Math.Sin(t * 6.28 + 4.0) * 110 + 130);\n raw[pxOff + 3] = 255;\n }\n }\n }\n\n using var ms = new MemoryStream();\n using var bw = new BinaryWriter(ms);\n bw.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });\n\n void Chunk(string name, byte[] data)\n {\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(data.Length));\n var tag = System.Text.Encoding.ASCII.GetBytes(name);\n bw.Write(tag);\n bw.Write(data);\n uint crc = 0xFFFFFFFF;\n foreach (var b in tag) crc = Crc32(crc, b);\n foreach (var b in data) crc = Crc32(crc, b);\n bw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(~crc));\n }\n\n uint Crc32(uint c, byte b)\n {\n c ^= b;\n for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? (0xEDB88320 ^ (c >> 1)) : (c >> 1);\n return c;\n }\n\n using (var ihdr = new MemoryStream())\n using (var iw = new BinaryWriter(ihdr))\n {\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(w));\n iw.Write(System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(h));\n iw.Write((byte)8); iw.Write((byte)6); iw.Write((byte)0); iw.Write((byte)0); iw.Write((byte)0);\n Chunk(\"IHDR\", ihdr.ToArray());\n }\n\n using (var idat = new MemoryStream())\n {\n using (var deflate = new ZLibStream(idat, CompressionLevel.Optimal, true))\n {\n deflate.Write(raw);\n }\n Chunk(\"IDAT\", idat.ToArray());\n }\n\n Chunk(\"IEND\", Array.Empty());\n return ms.ToArray();\n}\n\nbyte[] pngBytes = GenerateMandelbrot(540, 270);\nDisplay.Image(pngBytes, \"PNG\");\nConsole.WriteLine($\"✓ Mandelbrot fractal synthesized ({pngBytes.Length:N0} bytes PNG).\");", "OutputText": "✓ Mandelbrot fractal synthesized (20,374 bytes PNG).\n", - "ExecutionCount": 13, + "ExecutionCount": 22, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, @@ -337,7 +337,7 @@ "Language": "python", "Source": "#!share --from csharp clusterNodes --as nodes\n\nprint(f\"🐍 Python Kernel received {len(nodes)} cluster nodes from C#:\")\nfor n in nodes:\n print(f\" • {n['Region']:<16} CPU: {n['CpuPercent']:>5.1f}% | RAM: {n['MemoryGb']:>3.0f}GB | Status: {n['Status']}\")\n\ncpus = [n['CpuPercent'] for n in nodes]\nmean_cpu = sum(cpus) / len(cpus)\nvariance = sum((x - mean_cpu) ** 2 for x in cpus) / len(cpus)\nstd_dev = variance ** 0.5\n\nprint(f\"\\n📊 Python Statistical Moments:\")\nprint(f\" - Mean CPU Utilization: {mean_cpu:.2f}%\")\nprint(f\" - Population Std Dev: {std_dev:.2f}%\")\n\n# Display summary metrics table directly from Python\ndisplay([\n {\"Metric\": \"Monitored Nodes\", \"Value\": str(len(nodes)), \"Status\": \"Optimal\"},\n {\"Metric\": \"Mean CPU\", \"Value\": f\"{mean_cpu:.1f}%\", \"Status\": \"Optimal\" if mean_cpu < 70 else \"Warning\"},\n {\"Metric\": \"CPU Std Dev\", \"Value\": f\"{std_dev:.1f}%\", \"Status\": \"Nominal\"},\n {\"Metric\": \"Peak Load Node\", \"Value\": max(nodes, key=lambda x: x[\"CpuPercent\"])[\"NodeId\"], \"Status\": \"Investigate\"}\n])", "OutputText": "🐍 Python Kernel received 6 cluster nodes from C#:\n • us-east-1 CPU: 24.5% | RAM: 64GB | Status: Optimal\n • us-west-2 CPU: 78.2% | RAM: 128GB | Status: Under Load\n • eu-west-1 CPU: 45.0% | RAM: 256GB | Status: Optimal\n • ap-southeast-1 CPU: 15.8% | RAM: 32GB | Status: Optimal\n • sa-east-1 CPU: 88.9% | RAM: 64GB | Status: Degraded\n • ap-northeast-1 CPU: 92.1% | RAM: 192GB | Status: Heavy Compute\n\n📊 Python Statistical Moments:\n - Mean CPU Utilization: 57.42%\n - Population Std Dev: 30.54%\nRecords[4]", - "ExecutionCount": 14, + "ExecutionCount": 23, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, @@ -349,7 +349,7 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Metric ↕\n\n Value ↕\n\n Status ↕\n
\n \n Monitored Nodes6Optimal
\n
\n
\n
🔍 Row #1 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Monitored Nodes
\n
\n
Value
\n
6
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n Mean CPU57.4%Optimal
\n
\n
\n
🔍 Row #2 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Mean CPU
\n
\n
Value
\n
57.4%
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n CPU Std Dev30.5%Nominal
\n
\n
\n
🔍 Row #3 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
CPU Std Dev
\n
\n
Value
\n
30.5%
\n
\n
Status
\n
Nominal
\n
\n
\n \n
\n
\n \n Peak Load Nodenode-ai-06Investigate
\n
\n
\n
🔍 Row #4 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Peak Load Node
\n
\n
Value
\n
node-ai-06
\n
\n
Status
\n
Investigate
\n
\n
\n \n
\n
\n
\n\n
\n Showing 4 of 4 rows\n FrySharp Polyglot Data\n
\n\n \n
", + "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Metric ↕\n\n Value ↕\n\n Status ↕\n
\n \n Monitored Nodes6Optimal
\n
\n
\n
🔍 Row #1 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Monitored Nodes
\n
\n
Value
\n
6
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n Mean CPU57.4%Optimal
\n
\n
\n
🔍 Row #2 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Mean CPU
\n
\n
Value
\n
57.4%
\n
\n
Status
\n
Optimal
\n
\n
\n \n
\n
\n \n CPU Std Dev30.5%Nominal
\n
\n
\n
🔍 Row #3 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
CPU Std Dev
\n
\n
Value
\n
30.5%
\n
\n
Status
\n
Nominal
\n
\n
\n \n
\n
\n \n Peak Load Nodenode-ai-06Investigate
\n
\n
\n
🔍 Row #4 Inspector
\n
3 properties
\n
\n
\n
\n
Metric
\n
Peak Load Node
\n
\n
Value
\n
node-ai-06
\n
\n
Status
\n
Investigate
\n
\n
\n \n
\n
\n
\n\n
\n Showing 4 of 4 rows\n FrySharp Polyglot Data\n
\n\n \n
", "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, @@ -392,7 +392,7 @@ "Language": "javascript", "Source": "#!share --from csharp clusterNodes --as nodes\n\nconsole.log(`⚡ Node.js Kernel received ${nodes.length} cluster nodes from C#!`);\n\nconst highLoadNodes = nodes.filter(n => n.CpuPercent > 70);\nconsole.log(`🚨 High Load Nodes (>70% CPU):`);\nhighLoadNodes.forEach(n => console.log(` - [${n.Region}] ${n.NodeId} (${n.Role}): ${n.CpuPercent}% CPU`));\n\nconst totalLatency = nodes.reduce((sum, n) => sum + n.LatencyMs, 0);\nconst avgLatency = (totalLatency / nodes.length).toFixed(1);\nconsole.log(`\\n⏱️ Aggregated JavaScript SLA Latency: ${avgLatency} ms`);\n\n// Display filtered high-load cluster nodes as interactive table from Node.js\ndisplay(highLoadNodes);", "OutputText": "⚡ Node.js Kernel received 6 cluster nodes from C#!\n🚨 High Load Nodes (>70% CPU):\n - [us-west-2] node-worker-02 (Compute Shard): 78.2% CPU\n - [sa-east-1] node-cache-05 (Redis In-Memory): 88.9% CPU\n - [ap-northeast-1] node-ai-06 (Tensor Pipeline): 92.1% CPU\n\n⏱️ Aggregated JavaScript SLA Latency: 33.5 ms\nArray[3]", - "ExecutionCount": 15, + "ExecutionCount": 24, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, @@ -404,7 +404,7 @@ "ImageFormat": null, "ImageWidth": null, "ImageHeight": null, - "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Region ↕\n\n NodeId ↕\n\n Role ↕\n\n CpuPercent ↕\n\n MemoryGb ↕\n\n LatencyMs ↕\n\n Status ↕\n
\n \n us-west-2node-worker-02Compute Shard78.212828.1Under Load
\n
\n
\n
🔍 Row #1 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-west-2
\n
\n
NodeId
\n
node-worker-02
\n
\n
Role
\n
Compute Shard
\n
\n
CpuPercent
\n
78.2
\n
\n
MemoryGb
\n
128
\n
\n
LatencyMs
\n
28.1
\n
\n
Status
\n
Under Load
\n
\n
\n \n
\n
\n \n sa-east-1node-cache-05Redis In-Memory88.96468.5Degraded
\n
\n
\n
🔍 Row #2 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
sa-east-1
\n
\n
NodeId
\n
node-cache-05
\n
\n
Role
\n
Redis In-Memory
\n
\n
CpuPercent
\n
88.9
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
68.5
\n
\n
Status
\n
Degraded
\n
\n
\n \n
\n
\n \n ap-northeast-1node-ai-06Tensor Pipeline92.119231.2Heavy Compute
\n
\n
\n
🔍 Row #3 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-northeast-1
\n
\n
NodeId
\n
node-ai-06
\n
\n
Role
\n
Tensor Pipeline
\n
\n
CpuPercent
\n
92.1
\n
\n
MemoryGb
\n
192
\n
\n
LatencyMs
\n
31.2
\n
\n
Status
\n
Heavy Compute
\n
\n
\n \n
\n
\n
\n\n
\n Showing 3 of 3 rows\n FrySharp Polyglot Data\n
\n\n \n
", + "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Region ↕\n\n NodeId ↕\n\n Role ↕\n\n CpuPercent ↕\n\n MemoryGb ↕\n\n LatencyMs ↕\n\n Status ↕\n
\n \n us-west-2node-worker-02Compute Shard78.212828.1Under Load
\n
\n
\n
🔍 Row #1 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
us-west-2
\n
\n
NodeId
\n
node-worker-02
\n
\n
Role
\n
Compute Shard
\n
\n
CpuPercent
\n
78.2
\n
\n
MemoryGb
\n
128
\n
\n
LatencyMs
\n
28.1
\n
\n
Status
\n
Under Load
\n
\n
\n \n
\n
\n \n sa-east-1node-cache-05Redis In-Memory88.96468.5Degraded
\n
\n
\n
🔍 Row #2 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
sa-east-1
\n
\n
NodeId
\n
node-cache-05
\n
\n
Role
\n
Redis In-Memory
\n
\n
CpuPercent
\n
88.9
\n
\n
MemoryGb
\n
64
\n
\n
LatencyMs
\n
68.5
\n
\n
Status
\n
Degraded
\n
\n
\n \n
\n
\n \n ap-northeast-1node-ai-06Tensor Pipeline92.119231.2Heavy Compute
\n
\n
\n
🔍 Row #3 Inspector
\n
7 properties
\n
\n
\n
\n
Region
\n
ap-northeast-1
\n
\n
NodeId
\n
node-ai-06
\n
\n
Role
\n
Tensor Pipeline
\n
\n
CpuPercent
\n
92.1
\n
\n
MemoryGb
\n
192
\n
\n
LatencyMs
\n
31.2
\n
\n
Status
\n
Heavy Compute
\n
\n
\n \n
\n
\n
\n\n
\n Showing 3 of 3 rows\n FrySharp Polyglot Data\n
\n\n \n
", "TableSnapshot": null, "InspectorSnapshot": null, "HadInteractiveControl": false, @@ -446,7 +446,7 @@ "Type": 0, "Source": "#!share --from python mean_cpu\n#!share --from python std_dev\n\nConsole.WriteLine(\"🪐 C# Kernel: Verifying Triangular Polyglot Closure\");\nConsole.WriteLine($\" ✓ Verified Python mean_cpu: {mean_cpu:F2}%\");\nConsole.WriteLine($\" ✓ Verified Python std_dev: {std_dev:F2}%\");\nConsole.WriteLine(\" ✓ Cross-language polyglot state synchronization verified.\");", "OutputText": "🪐 C# Kernel: Verifying Triangular Polyglot Closure\n ✓ Verified Python mean_cpu: 57.42%\n ✓ Verified Python std_dev: 30.54%\n ✓ Cross-language polyglot state synchronization verified.\n", - "ExecutionCount": 16, + "ExecutionCount": 25, "IsExecuting": false, "ExecutionTimeText": "", "HasError": false, diff --git a/vscode-frysharp/.gitignore b/vscode-frysharp/.gitignore index cd0a60e5..d060ab43 100644 --- a/vscode-frysharp/.gitignore +++ b/vscode-frysharp/.gitignore @@ -3,3 +3,5 @@ out/ dist/ *.vsix .vscode-test/ +out-test/ +preview/samples.json diff --git a/vscode-frysharp/.vscodeignore b/vscode-frysharp/.vscodeignore index b05ce68b..a081fa9d 100644 --- a/vscode-frysharp/.vscodeignore +++ b/vscode-frysharp/.vscodeignore @@ -3,3 +3,14 @@ src/** tsconfig.json **/*.map +out-test/** +test/** +node_modules/** +out/** +esbuild.js +eslint.config.mjs +bin/sidecar/*.pdb +bin/sidecar/*.xml +bin/sidecar/frysharp-sidecar +bin/sidecar/frysharp-sidecar.exe +preview/** diff --git a/vscode-frysharp/CHANGELOG.md b/vscode-frysharp/CHANGELOG.md new file mode 100644 index 00000000..2d80ce91 --- /dev/null +++ b/vscode-frysharp/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 1.0.0 + +First release. + +### Notebooks (`.frynb`, and `.ipynb` on request) + +- Cells in C#, F#, Python, JavaScript, Java, SQL, Rust, Go and C++, each run by its real toolchain through the FrySharp engine (a .NET 10 sidecar). What a cell declares stays for the cells after it. +- A `.frynb` file made by the desktop app opens, runs and saves back unchanged (charts, tables, inspectors, images and the fields this extension does not read are kept). A Jupyter file saves back byte for byte. +- `#!share` hands a value from one language to another. `%pip`, `%nuget`, `%maven`, `%cargo`, `%go` and `%vcpkg` lines install packages. +- Output as in the desktop app: the last expression is shown (scalars as text, a list of records as a table, an object in the Object Explorer), `Display.*` tables, charts, 3D plots, visualizers, ECharts, HTML, Markdown and images. A cell with several kinds of output draws them as tabs (Table, Console, Inspector, Chart, All ...; setting `frysharp.notebook.outputTabs`). +- The Object Explorer lists an object's members four levels down and a fifth level as a title, a reference back to an object above included, in every language, as the desktop does. +- Input: `Console.ReadLine`, `input()`, `Scanner`, `fry::input` and the rest ask in an input box; closing it ends the input. +- Errors are marked on the line and column of the cell, listed in Problems and shown as VS Code's own error output. Stop ends a running cell and keeps the session where the language allows it. +- C# completion and hover use the cells before; directive names, languages and `#!share` variables complete in every language; compile errors are marked as you type (setting `frysharp.notebook.liveDiagnostics`). +- Variables view (Panel > FrySharp), a note under a cell that runs as another language than the editor shows (a first line of `#!python`), a status bar item for the engine. + +### Script runs + +- Run, stop and re-run a file in a terminal (colour, input, Ctrl+C, clickable errors, Problems), from the editor title, the context menus or `Ctrl+Alt+R`; arguments, save before run, a scratchpad notebook and Send Selection. +- Optional Run and Stop CodeLens (setting `frysharp.codeLens.enabled`; never on plain `.cs` files). + +### Everything else + +- `FrySharp: Show Engine Info` lists the engine's version, the languages it can run, the toolchain it found for each and where its log is. +- Workspace Trust is required to run code; remote and WSL windows run the engine next to the files. +- The .NET 10 runtime is found on the PATH, in `frysharp.dotnetPath`, or installed by the .NET Install Tool. + +### Known limits + +- Rust, Go and C++ cells are built one at a time with what earlier cells declared written in front, so a variable of a type the cell defined is not kept (Rust), a function or a struct with lowercase fields is not kept (Go) and pointers, arrays and lambdas are not kept (C++). The README lists them. +- A plain read of standard input in Rust, Go and C++ sees the end of input; use `fry::input` / `fry.Input`. +- Live Avalonia controls (`Display.Control`) are desktop only. diff --git a/vscode-frysharp/LICENSE b/vscode-frysharp/LICENSE index b5b31a03..af76491b 100644 --- a/vscode-frysharp/LICENSE +++ b/vscode-frysharp/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 CodeFryDev +Copyright (c) 2026 Code Fry Dev / PrashantUnity Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/vscode-frysharp/README.md b/vscode-frysharp/README.md index aad7abc4..7b936c5a 100644 --- a/vscode-frysharp/README.md +++ b/vscode-frysharp/README.md @@ -1,34 +1,131 @@ # FrySharp for Visual Studio Code -**FrySharp** brings high-performance .NET 10 Roslyn scripting, polyglot notebooks (C#, Python, JavaScript, Java, SQL, Rust), and rich interactive visualizations directly into Visual Studio Code. +Polyglot notebooks and script runs for C#, F#, Java, Python, JavaScript and SQL, with the same engine as the FrySharp desktop +app: Roslyn for C#, your own .NET SDK, JDK, Python, Node.js and SQLite for the others, and rich output (tables, objects, charts, 3D +plots, images). -Following Microsoft's deprecation of Polyglot Notebooks (.NET Interactive) and Azure Data Studio, **FrySharp** provides a fast, native replacement powered by a lightweight .NET sidecar process. +FrySharp is a replacement for the Polyglot Notebooks extension, whose support ended in March 2026. ---- +## Notebooks -## Features +Open a `.frynb` file (or run **FrySharp: New Polyglot Notebook**). Cells run on the FrySharp kernel, which is picked for you. -- **Polyglot Notebooks (`.frynb` & `.ipynb`)**: - - Run C# cells using in-process Roslyn compiler (.NET 10). - - Polyglot support: mix C#, Python, JavaScript, Java, and SQL in the same notebook. - - Cross-language variable sharing: `#!share --from [--as ]`. -- **Script Runner**: - - Run standalone C# (`.cs`, `.csx`), Python (`.py`), or JavaScript (`.js`) scripts with streaming stdout/stderr in the output channel. -- **Rich Visuals & Displays**: - - Full support for interactive tables, ECharts, 3D surface plots, and data structures. -- **Zero Configuration**: - - Automatically launches the headless `FrySharp.Sidecar` over stdio JSON-RPC. No network ports, no web servers, no auth tokens. +- **Languages:** C#, F#, Java, Rust, Go, C++, Python, JavaScript and SQL, mixed in one notebook. A cell can start with `#!python`, + `#!javascript`, `#!csharp`, `#!fsharp`, `#!java`, `#!rust`, `#!go`, `#!cpp` or `#!sql`. Variables move between languages with `#!share --from csharp name [--as other]`. +- **F#:** all the cells of a notebook are one F# Interactive session, as in `dotnet fsi`: a `let`, a function or a type + one cell defines the next one uses. The value a cell ends with is shown (a list of records as a table, a record as the + Object Explorer), errors are underlined where the compiler found them, and `Display.table`, `Display.lineChart`, + `Display.markdown` ... show output. `#r "nuget: Package"` loads a package. Stop ends a cell that waits (`Thread.Sleep`, + `Console.ReadLine`); one that only computes ends the session, and its values are gone. +- **Java:** the cells of a notebook are one JShell session: a variable, method, class or record one cell declares the next + one uses. The value of an expression is shown (a list of records or maps as a table, an object as the Object Explorer), + compile errors are underlined with their column, and a runtime error is on the line of the cell it went through. + `//DEPS group:artifact:version` downloads a library from Maven Central and puts it on the classpath, and + `Display.table`, `Display.lineChart`, `Display.markdown` ... show output. Stop ends any cell, even one that only computes, + and keeps what the notebook declared. +- **Rust:** each cell is built with Cargo as a small program (about a second once the first has built): the functions, + types and imports one cell defines the next one uses, and so does a `let` of numbers, text, lists, options, tuples, maps + and sets. A variable of a type you defined can't be handed on (the cell says so; `fry::share!(name)` keeps it as data), and a + plain `stdin` read sees the end of input: use `fry::input("Prompt: ")`, which asks in an input box. The value a cell + ends with is shown (a list of structs as a table, a struct, tuple or enum as the Object Explorer, needing `Debug`). + `// #crate: name = "1"` or `%cargo add name` fetches a crate, errors are underlined and named on the cell's own lines + (a panic too), and `fry::line_chart`, `fry::table`, `fry::markdown` ... show output. Stop ends the program. +- **Go:** each cell is built with the Go toolchain as a small program (a fraction of a second once the first has built): + the functions, types, constants and imports one cell declares the next one uses, and so does a variable (`x := ...`, or + `var x` at the top) whose value can be saved (numbers, text, slices, maps, structs with capitalised fields). A variable + of a type that can't be handed on (a func, a struct with lowercase fields) isn't kept: the cell says so, and + `fry.Share("name", value)` shares it as data. A plain read of `os.Stdin` sees the end of input; `fry.Input("Prompt: ")` asks in an + input box. The value a cell ends with is shown (a slice of structs as a table, a struct as the Object Explorer), errors are + underlined and named on the cell's own lines (a panic too), `%go get module` adds a module, and `fry.LineChart`, + `fry.Table`, `fry.Markdown` ... show output. Stop ends the program. +- **C++:** each cell is built with your C++ compiler (clang++, g++ or MSVC; C++20) as a small program (a second or two a cell): + the includes, functions, types, aliases and `static` or `constexpr` variables one cell declares the next one uses, and so does a + variable (`int x = 5;`, `std::vector names;`) whose value can be saved (numbers, text, vectors, maps, optionals, + pairs and structs of those; the notebook reads a struct's public data members from its definition). A variable of a type that + can't be handed on (a pointer, an array, a class with private members) isn't kept: the cell says so, and + `fry::share("name", value)` shares what can be sent as data. A plain `std::cin` read sees the end of input; `fry::input("Prompt: ")` + asks in an input box. The value a cell ends with, written without a `;`, is shown (a vector of structs as a table, a struct as the + Object Explorer), errors are underlined and named on the cell's own lines, an uncaught exception names its cause (C++ has no line to give), + and `fry::line_chart`, `fry::display::table`, `fry::markdown` ... show output. Stop ends the program. `%vcpkg install name` adds a package. +- **SQL:** a cell runs on SQLite, in a database that lasts as long as the notebook's kernel: what one cell creates the next + one queries. Each statement's rows are a table you can sort and filter, the notebook's tables are its variables, and + `#!share --from csharp rows` turns a C# or Python list into a table to query. +- **Output as tabs:** a cell that makes more than one kind of output (printed text and a table, a chart and text ...) shows them as + tabs in one output, as the desktop app does: **Table** (with its row count), **Console** (with its line count), **Inspector**, + **Chart**, **Visualizer**, **3D**, **Image**, **HTML**, **Markdown**, and **All**. The console keeps its ANSI colours and redrawn + progress bars; the tab you chose stays while the cell runs and more output arrives; Copy takes the console's text or the table + as CSV. A cell with one kind of output keeps VS Code's own output. The file saved is the same either way (it holds the outputs, not + the tabs). Turn it off with `frysharp.notebook.outputTabs` to have the outputs one after another. +- **Output:** the text a cell prints is one output, colours included. Objects open in the **Object Explorer** (a tree you + expand and collapse; it lists four levels of members and shows a fifth as a title, an object that points back at itself included, + as the desktop app does), tables sort and filter and copy as CSV or JSON, and charts, 3D plots, visualizers, ECharts (offline), + images, HTML and Markdown are drawn in your theme (light, dark or high contrast). +- **Input:** `Console.ReadLine()` and `input()` open an input box; Escape is the end of input. +- **Errors:** a compile error or a traceback line is underlined in the cell and listed in Problems. +- **Run All** stops at the first cell that fails (setting `frysharp.notebook.stopOnError`). **Stop** interrupts the cell; + Python, JavaScript and Java keep their variables. A C# call that is blocked (`Thread.Sleep`) cannot be interrupted: FrySharp + gives up waiting after 3 seconds and the kernel stays busy until the call returns, so restart the kernel to free it. +- **Editing:** C# cells have completion, hover and live error marks (as you type) that know the cells above: what an earlier + cell defined is in scope, and defining a name again in a later cell is not an error. SQL cells have completion (keywords, + functions, SQLite's dot-commands, the tables and views the cells above create) and hover. `#!` completes the languages and + `share`; `#!share --from csharp ` completes the variables that kernel holds; `%pip` / `%npm` / `%nuget` lines are + completed. A directive that is not one (`#!pyton`) is underlined. Directives are coloured, and `fry` + Tab inserts a + snippet for `Display.Table`, a chart, Markdown or `#!share` in C#, F#, Java, Python and JavaScript (`#!share` in SQL). + JavaScript cells get VS Code's own completion. Python and the other languages get the help of the extensions you have + installed for them (for example Pylance), when those serve notebook cells; FrySharp adds nothing to them but the directives. +- **Variables** (Panel, FrySharp): the variables of the notebook in front, by language, refreshed after every cell. +- **Files:** `.frynb` is FrySharp's own format and loses nothing the desktop app saves. `.ipynb` Jupyter notebooks open with + **Reopen Editor With... > FrySharp Polyglot Notebook (Jupyter file)** and are saved back as Jupyter notebooks. ---- +## Scripts -## Commands +**FrySharp: Run with FrySharp** (the play button in the editor title, the Explorer and editor menus, `Ctrl+Alt+R`) runs a +`.cs`, `.csx`, `.fsx`, `.java`, `.rs`, `.go`, `.cpp`, `.py`, `.js` or `.sql` file in a terminal: -- `FrySharp: New Polyglot Notebook` — Create a new `.frynb` notebook with C# and markdown cells. -- `FrySharp: Run Script with FrySharp` — Execute the active script file with the FrySharp engine. -- `FrySharp: Restart Engine Sidecar` — Restart the backend .NET execution sidecar. +- the program's text and colours, as it prints them; +- type to answer a program that reads input; `Ctrl+C` stops it, any key runs it again; +- errors in the output are links to the file, and are listed in Problems; +- tables, charts and images a program shows appear in the **Results** panel. ---- +**Scratchpad:** **FrySharp: New Scratchpad** opens a notebook that keeps its kernels. **Send Selection to Scratchpad** runs +the selected code in it. ## Requirements -- [.NET 10 SDK](https://dotnet.microsoft.com/download) or later installed. +- **.NET 10** (the runtime is enough). FrySharp uses the `dotnet` on your PATH; without one it offers to install the + .NET Install Tool, which downloads it. Set `frysharp.dotnetPath` to use a particular one. +- The **.NET SDK** (not only the runtime) for F# cells and scripts: FrySharp runs `dotnet fsi`. +- A **JDK** (11 or newer, with `javac` and JShell; 17 for records) for Java cells and files. +- The **Rust toolchain** (`cargo`; install it from https://rustup.rs) for Rust cells and files. +- The **Go toolchain** (`go`, version 1.18 or newer; https://go.dev/dl) for Go cells and files. +- A **C++20 compiler** (clang++, g++ or MSVC) for C++ cells and files. +- **Python 3**, **Node.js** and **SQLite** (`sqlite3`) for cells and scripts in those languages (FrySharp finds the ones + installed). macOS and most Linux have `sqlite3`; on Windows run `winget install SQLite.SQLite`. A SQL cell without it says + how to get it. +- A **trusted workspace**: FrySharp runs code, so until you trust the folder notebooks open but nothing runs. + +## Settings + +| Setting | | +|---|---| +| `frysharp.dotnetPath` | The `dotnet` that runs the engine. | +| `frysharp.sidecarPath` | A `frysharp-sidecar.dll` to run instead of the packaged engine (for developing it). | +| `frysharp.notebook.stopOnError` | Run All stops at the first cell that fails (default on). | +| `frysharp.notebook.liveDiagnostics` | Mark errors in C# cells (and directives that are not one) as you type (default on). | +| `frysharp.run.arguments`, `frysharp.run.saveBeforeRun`, `frysharp.run.showEditorButton` | Script runs. | +| `frysharp.scratchpad.shiftEnter` | Shift+Enter sends the selection to the scratchpad. | + +## Getting started + +**Welcome: Open Walkthrough... > Get started with FrySharp** walks through a first cell, mixing languages, tables and charts, and +scripts. + +## Troubleshooting + +**FrySharp: Show Engine Log** shows what the engine wrote. The status bar item (bottom left, while a FrySharp notebook is +open) says whether the engine is ready, starting, busy or stopped, and its menu restarts a kernel or the engine. If the +engine stops, every notebook's variables are gone: run the cells again. + +## Not yet in VS Code + +Rust, Go and C++ cells run in the desktop app. Their engines are being moved so VS Code can run them too. diff --git a/vscode-frysharp/esbuild.js b/vscode-frysharp/esbuild.js new file mode 100644 index 00000000..e8fa8408 --- /dev/null +++ b/vscode-frysharp/esbuild.js @@ -0,0 +1,43 @@ +// Builds dist/: the extension (extension.js, one file with vscode-jsonrpc inside, so an installed extension starts by +// reading one file and ships no node_modules) and the output renderer (renderer.js, an ES module the notebook loads in its +// own web view) with the chart libraries it hands to the engine's pages (assets/). `node esbuild.js --production` is what +// the package build runs. +const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); + +const production = process.argv.includes('--production'); +const watch = process.argv.includes('--watch'); + +// The libraries the engine's pages name with data-fry-asset: the same files the desktop app embeds. +const assetsFrom = path.resolve(__dirname, '..', 'FrySharp.Core', 'Assets', 'echarts'); + +function copyAssets() { + const to = path.join(__dirname, 'dist', 'assets'); + fs.mkdirSync(to, { recursive: true }); + for (const file of fs.readdirSync(assetsFrom).filter((name) => name.endsWith('.js'))) { + fs.copyFileSync(path.join(assetsFrom, file), path.join(to, file)); + } +} + +const shared = { bundle: true, minify: production, sourcemap: !production, sourcesContent: false, logLevel: 'info' }; + +async function main() { + const contexts = await Promise.all([ + esbuild.context({ ...shared, entryPoints: ['src/extension.ts'], format: 'cjs', platform: 'node', target: 'node20', outfile: 'dist/extension.js', external: ['vscode'] }), + esbuild.context({ ...shared, entryPoints: ['src/renderer/index.ts'], format: 'esm', platform: 'browser', target: 'es2022', outfile: 'dist/renderer.js', tsconfig: 'src/renderer/tsconfig.json' }) + ]); + copyAssets(); + + if (watch) { + await Promise.all(contexts.map((context) => context.watch())); + } else { + await Promise.all(contexts.map((context) => context.rebuild())); + await Promise.all(contexts.map((context) => context.dispose())); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/vscode-frysharp/eslint.config.mjs b/vscode-frysharp/eslint.config.mjs new file mode 100644 index 00000000..8aa8a88a --- /dev/null +++ b/vscode-frysharp/eslint.config.mjs @@ -0,0 +1,24 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['out/**', 'out-test/**', 'dist/**', 'node_modules/**', 'esbuild.js', 'eslint.config.mjs'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['src/**/*.ts', 'test/**/*.ts'], + languageOptions: { parserOptions: { projectService: { allowDefaultProject: [] }, tsconfigRootDir: import.meta.dirname } }, + rules: { + // A promise nobody awaits is a failure nobody sees. + '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'off', // VS Code's metadata and JSON from files are untyped by nature + 'no-empty': ['error', { allowEmptyCatch: true }] + } + }, + { + // node:test's describe() and it() return promises that the runner awaits; the stub of 'vscode' hooks require itself. + files: ['test/**/*.ts'], + rules: { '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-require-imports': 'off' } + } +); diff --git a/vscode-frysharp/media/icon.png b/vscode-frysharp/media/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b51824a3264da99f4079b5ba78fac354b69025df GIT binary patch literal 28438 zcmW(+by!q=6J20|rBgZuVUbShlx~nxQc^mkyO9x?gkfFx;wx7emu_t zf9&2nzr7P@&Ybz8p(c-mNr4Fhfp8QR-fDqBNWi~HAP5@p$I!LZ3IuXoQG6@)&TH|| z$kXqg&eikk_3Gb@)Ik+YBtk7n9x46juU4?3@=E{1=A(}$ZOGLQMtXXh*CqR{6&xhB zmC-nfFO47_uTmrl6{Mwy7`0db`d&XhrCmoTD$rpJ{spf-3PJ`gc+OYF5ZUKqYv*F% zyXdlhG6s+!2n>RI8|S46+%79~c1*jTkGx^NJXmPR=-b#iI=^l8+@GzgU>jzM&Sx~J zngwSgvZC|np+zL2J*Kb1oaG88*dWNHPw(s5w7b@hEn_Dn>DIReNBhqGI`W}2j!kXR zh+8Eo{2^s!HIMx*rITY^Y-PN&w-DZbIHtVne&|V^c!rN#F1a}1^<|GS&+xcF^`=y`|Z}ELyN-T zA>vZ^ZFg-_kWWV)Qjg*VDIsvd!$#>qy=!ctiB(Nv)PSxoVSEU&QqoV=q{28I9i5Et z>>qyZcitaW*t}H2zko=2f=Vt~BwYTzb^Q1HODukVSm1F*P3O?TQj=5lilfBR$GTWP zinvEM5@l((U#LOhROdJt}BXEL?CT>UQ9M1aa=osJOW8(H2-%UjAOx^Jw{!v*SX` zooE;NRoKp`q)8p^DyLEFJB>JrpQ)TC**aQUu6C-bBYsUw{%O=*76bZ{j}n$8$N6#M zcA;AyZFqCj(|R!l>WV}hRj^&5=>Qx*5Fnafnub{l# zwhjh&d9%2WY0j1ljdo3^d{Cqg@xejfgswHr(1DJ*szdE zcJO)`|lE&q8$dzo(obU9ZN1&dj=IPqg<2~m@Eg?U$xv*|7a1ovdG|Gef z<1TedQLmFmkG99+=H}*}xI~{Fxi$LHTqz3;J-w!xEm!}a?)$UqB`QC(CSyZqzaN1X z1N_O%sbDVAx(sWzW__ixv9U`I0!uSY$%ADM8s)v0cl?K5*5fzt@BKRW?(Xi!@MuJD zYBu^JYo?lUQ?!iT=Y4zt1GIS`{O_-fk}@+NuKm=-0%=#18_mUTsOWt%$Qd{4|R_%?eL+LtB+8MWcxx zGUS*WI6FJj^SwTLBnKH3eB6%*Ty>*VoX^>5EL&`<_4DqTP1*+Q>q}j{a}hnoujSJg z#%^Hwc&b}vHMP<9;-j`B(CBzE)gdDk3T>tXZp6~I5)hxgmeVO1JiPT@)7bdQ@OZU@ zRDj@i7k6C+akBNyLRNF}(l2$(ly~W@F@sFYb-@}dz$%uTBysCX?APM$>Wxoo?xB@`V`oJBFIaCJ&j z{(99;uzPd=$Wnp2-<8RD826i=O^HgI(2s`V<=&^0zZKMkW;-#O?loM%2mbqDS<#T0 zFOW6#=jPrD=~f#qlmNNnlYGuy+;^+kgw7iQh9B0v^uD4Zh;-jH*uHu69+ldzmsZxF zMqVo(LG%Vsi6`M3UzAp&oj*3Yz(;q7zOmlhZ1qklev1+@k`9Q6lhMCj=n8y3j=-V( zHM{#+&0amRhQBZxqaZRQsM{=|FS?P^Wr=|^gaMpVmw4v*;T9D%&M$=Y+p0g-pvJ5x zX$BumQ=k96Rs@srvv3wsBybsz=cmUmhA=1LhUXYfEO`8ya05K7k}|1jS@!>Lq%5_PkMr*M%K%;JPp4Asy1h%FK!*kb#;c0jlVa{nsb&JhP&nTl@=G z6c)V47#tsU#P`K@!LbSNzic@q=6hu*1Nqulq1K8LW!R~XM=i`JA|m3OJ!pZ6cUzDl zSpKOS6%qS3VY)hzQ6k9n$*BouHo#l+uS31dA(?1=5sPQ4mA>i&=?w}Gad@v;lNtQi zO7qdG!3jDt4%OGrMSlzsaq{5ipV855&zum1rQ;y*F())Kp+Hop@CI@Yi6 zdt9iH>yb;Qf@!~34p1j_5`(FhknG0w&I7Wwh9LAx*GDTkWu>KQ*ME7(^da;=>qPym zC51(B=D&aYR>Y=XO9x-Ht9cyV{cN0zDGI4$Y8dJ6CJ5nBRP(#pQeXF~4kn<)M((^Z z6Cxz~$L+HNf?}f5kBlA2Fbr|STuLj;dpu+GdhG>*dC-(tDh04+tJb1@YVb%^$*A+0 zi8^@e7^wWAIF0&jp@6r0JT<$(NiKJ%N_=)Fi+a&%{UqJd$Ky>+bahkP9ama6806z0 zxH&kgyV8%b>Y7^`i3F?cNGlh9qKIH!_1Tm3MLJ3IQJi$qewV@bQh7HVkupI-W;dQo zeJbF0Gb8;cn(&TXiJVOrE45l5Z^FJe?5nkuCiF9Va(v9yH1s`*Y@NKV?VeM0^S|hC z@PRm1vsjEQvtRUc)uhN0WkBLu%; zu#MgCYdsgt#t|l|Jub_N`?pl`k}NVcv3#-Q0{Aoucz*g}QY^CVyfo_V7kKUGr*jTy zRF^ZthCy28`c<1I-VVg>C10#xDD}8e{n|Q)&6jxwJ5xdEVZm-8-30?W37cSaW1mgOW#L0Y zP*5dweJ1CVl1u68VSnwgqs&B}Yg%js+LimWsxh^?D+g6V7BZl8O+?A10hye0OHAIODmIq{# zD?!$y_A4GyFIeMFb`o(clo&P4S&Ax%8y_SeuWP>Zsww~1C{8dUA%;URK9mjp7MZs5 zj<~wT!u1c*?z-vQfgh)lxYRzv?Cgg^Rk75{UqQ5?W?fo9RrCXV^zW$SLwe+lk{=LA zahCf6=1yh=_PWZ+-s0xiBn=qR`KGsfqJFKW7<+pwJ1e}@r!K!VY{r7h*fVy*1*lpO~0*s&@O=_ zuTY?-=7hGG&&5rH+`gF6&jqOEx?o6j0e_nCqf6!cCUT4~YG8{NgIU_K)05lBlUd*K zDql>;<_A&0EwnDi2C>CB>4pX08^tUSJIcE|P-BOTGa^N4*P-iWQIe`s#UY+QlSbei z5Jj_jWzVVpg1u_7U$TqTfuX%a#OaHI4ub+M=6P{a_aaf!Prm4V}KMIGK1rwl*8D!*;;{bg$ z<;=@wsN_<37|5!DTmCm(Ou%;_${;58sOoT>Q-@2}KAQ9G+ocIVoA6LZzY z`vb05cH{zE_D^H=AiBmhB$^z&fE;l0LaYk|TAw{(-OhItGvYYrLlioYS8ybZRqE`P z%j(ATVYhmp`?B{m4`D+Vj7##XhR8JbG(0@~jg__aSlllnx{1vz^>jYP)q2a}exQ}T z)g_kAA6;j#9->noBJ+yx!yBIcR}Fg(Nt$GE7Cz8ON##Ftrb3bzguavNSi~AIrbwRl6t)HC_(2I{!B+==W68corkY;Lu{|LsLCo$GNJUk3X zV3VJPT47noqKuC}0gWv7{#41Bt0r+(R@~;4v9c~PL^L5Za)|5kOV_`WR}}<^D6Dey zM(GV^FQo>i^XJqjQ6vq-mE_kKn->Sc%FGG8!3cl*g~q}S639)9AB z-wSL6B`3BmU31J*XI^G?23%*ANX-xJ4K7bY4C7sTR-q|Db-7a*$MWNPF(ssK03`2^ zA>|bJ-J34!Pj|@tg)6C}Nh;P* z@moVbL&(pV&od!(ia!G?rM@7 zg7GyK6-y@o)1mH#$nx|9=`%9FNVb{2V}1R4oB7o%qpHS>y$>5VHr@NlC(D`7>y6h4 zsicRfgoehLT$>LC$ZAEjzW#(3fuMB=ArX~eg$d_>nSI&09~EaY>vHCd4L?Z3z~I&! zXh`|LZbK5G(|6V-MMYu-l+S}1Q6STifkUs&{-!!tcbZsTEERUi8-@*RGBN;()aW5! zDyKcQRh(YA;e~Re#XFDtOYUb$Dk=^(0I4O7d~gW#g>gquL&I4I=(P~(NeKa3>&*hj zRd4^e;IOpj_%)CP2Ef+@y_(wSxkFg|JEE+f&XU|W$QOmV@BxAO zs8gYPNEdbx4~9bDB47Bf2c!Q*LBqazM=aa+dZ#CA(3IAa7AdXKpCx2C*<>PW$nOPaY@v=UaVg4IAA-^jy&cxjhwT z4Fd99lP%&fmlGe+w9gyFDxId7qDMaX@hCdP07MYN4_Y!xV6cp=`}8PGObrdQ5eO~K z(OLJNZr;uvdQ@c*3^L?jjN-VqVI!B9d zoe)`xnQFU;6b7e*Aa6>7Sie8U>I?@)^qAVyx#$`l_TIwf*QbQWUiO$p0;p%2SlWcW z01Kc(?i%gpw$-vly*Z11%A5ekl#7>^l^Iab(gwyaZoqQBlmuA1vjgoa?ZbWpYH7`N zw^c(g-3dMWGuepz#--0h;5fda`-*C!pYaPk8>sk@3)m_KXJ3~c){AP=XKKF*w%y|@ zbrM*8I+r1P^U=BVH%0`!oQhp?aEwnP7qo6?XQwAf6|cC*Z{x{N-&ug>MFn8jFDWT0 zAI5%;n@}$IxPWs4yNKsTz0f2c+9~oCv9DGtiaPbP7yHOUCv4s>o%`17CmGn{V}UTr@)(MKyM(1|eM7!hXC!|?>fCNzfq z%qizCFr=#{tx2X5QRp&MiYoy?HF699D$n2DUp?GG=i4Lj|M)yhz)$*o04m0$u`5$z zWyzQF&DHd5o6GUvxs#jWXY?k$kQGPmiNM*ie6k+X?%>`IsBkSU6|BepSFe3tH#C}y zA{HMTjVw+sh}+`ouiS1P;k%UY;VF?U=3EpwbYXpammj#U_hsj^yJ(pvqNb|{@#7+D zr(f=f$`no13T(Fe;Hl)FsuYqFfu`*R)YEt@hdkl11PMN;RndT_hg-I7El}eo42M=c z^FzJG;CuJ?$K(Wey!@F6Z<>99D(p?7Dh{$l zc?z2GWB`4b%g=h`7h9I4)#V{$`=59WNxj6OR$9kMu=Aa%26U!a8UWHqn9b*k12Dyh zO5^5mtm0(PH|L{?8Fzs1?d{F5c^96lqIf6|GAiiUwMF1R-?{*LCMampxJYVpwe%Ti z9&%LF)dP=IFdjooUPwxw9KlTBd5G<91YWme1D$g^Hhf$`Dw<6u)e?-(TLvE@M?pau zJ6%WJ2>VAbwQZW%zjLW56@UVx?=h3Ndarjeq#`3{t9K$AdHKP^d|@#zH@4>^Pmhs7 zcnR&jShoL3JleZYnNm8z`T6-JJUl$vPGjYuPwLZwzyp2!kGz<$;iDJ@ThiiWevI7+ zwqEPb#1oGc8^z2#1TlaJ7x9xg@@ML;@ot zNgn!a#-jZ;7@?!GU7nRq4o z2=G@VB3YwuV`1Yayia22O3v`dhSU9|8OacoT$~6H8ZAXtD!3!K$0lgOViM*VDNFXA z^(Wdne{~W7DE9W=FT2CPc_G5(M}I>lk-GKt^qw;l6P=BHWswR|AQIMLrznyRS(FVX zfwcXR33LFH03dD`5-c_=f-v~{m6xX8u~gPls816*f3*)ZWkd4R%zk(cJ+voRBv@DQ zOAs5w1bG7c+A}%^?)?vans=a+Lq&YTMWh?^=LduYk(Rc$i7oMq>?89$0_%qL#*$-) znK2qDOCSHW=nHJ@k1qO#*dj&~rrmk$@KO<%r|b6zKHH1{j->`1y3{2Oz;Yy=W=R6fD;;~uk0$3FM8Z$~Z6U?~fg%nz^R z%}i<;>BIU4&!r$EbKKI1jkEL&P{w{ARq-L=JeMn`WW^@HQ5Qw6?5{+!$v zxo4cVzSUgzX0cSdlTtTd5SmJx1yA)49*pNZdnA$?Lp(d)K6#P0N22)LSsmKfuLL{^ zpD(7==WESSGS#dV1~TD-({pohpo-hfbZiRZis#^&1U~Wu+_xFe4@njrQnE{%!q*btew|+$Jn~U=b*_e z__Ljn_l&%q^K7m-kV&rBLrxixKpi|&wf2X_rE;Lc4O*B()~`bxe;{4z?Se)s89 zHRmv^$&4pVL3;_|z3P8-=C$5@?g zv|r#+NFvVnx5JZ$3?$If;v^=b=K^(7^FJFg=u@}{AGlyOg3A+uiUmuSg5ZTS-i@^9{lj`9c^BL$5v!k^N=Tu4RPG z<`Qsv zb`rXuTNlTRjr&A*yK5RKlIz~0SKpueL}>kRtzD(>DFb!bnc_$Kt)0YU050-RJ+jn# zMxp!F$SBCGI@ER}$c8KT=K~Z#*x{tKEp02tzH2=)`VF7ZQ>#b&%G(|015faglW5x~ zK*=^?io{4miAkH-!YJXv2t|~_006uKa~(+d?V(xa@ORG0F+}Ksy*LMEx}eKPHfON@ zT(wcopOKNr{6@{7@x`e?fJ2?zDGBV;mR|R6zufqKGn|RHuA*Wy=N$mBV|O+<)y+09 z#RJTl*tSjem*#I|R=h!XmqU`7Ba3G6-^5C04L?pN*Pi223c<^POMD6e5&$#8H{6c2 z^_BWQnf`P1xoG4;>^lqV!hZv-oIy+beGye{wO(6j5A}|`9!Fw9>8(EFtv)I=yp>XJ zI?YH=Kmv7cTfrHB{zXwSJKgBIr4(gWq=TcOqNvzYs#%av{`4jad3!YLo>Kacb=4Q- zu-TB>l~#9OeTm0DrkfMd0D~rSyr)~7`79Ty!3vM}Xuv!w*SXdb=R{iB2}KOpl(|Mv)&|`Js#J8P#;M<&%9grj5tUR*?tkh#6kI9Im zj(>$m4dr4R%f}gykjePiVsfN&QRo@*5#&#dcYlLM6Q(dOb32In6>}zsb3ka}#jcjPApH@~@h9?jOceYYL-x3S~ z*cz~i=VADW?Wd+G6QGruo`oL}$eavj8gP+;xn}FNO! zp`e^in9dZf{L|A*i0%9`$J7@cpT^=ANfw7Q3cIgk(JxpvK73f}-u*OCp7+fDY9A5; z8X(iH)ORtwp@o2f*<&oX`2b%cM+|E5XFG=!yDy`(^kLOTZ31()joH4%u1^^}Pbja7 zyv{?%%wiHkEB_qMnr@!nCH|Owk<)Q$J2wuPKFq(`_ZW7g=sED! zlBWnSYtf+iDQ03Y4aav%|Awj4KJ-ADgd>(J8c2Qz;<)o3*QxWC2^cYdI8cBVK{+_Y zM{mf?@ns1}bCLDt##)IXS1|3a&bLpXS(`>@}3=y7*0ivE2#&JBhGQ>X2ukiJ(G=~%-Pe@5c9>WubDApMVP zHXezGZ#QLC1jbhF$X#Rjg-IiHWQxh8CdD&04TtiCZ zzw5wekcHk)3*v^*PiJWt{gUYtc@Rr9@3=l-3{|x-2p+MO^|s{aXuYL>@Za`t-r8fy zo8A?C?L;(;IM1uE=YiZcuKPsiLy`xlH+PRc+Co1TR2d3nR2lSDm6ZvZ3FmD@O-dyAGv)hQF4# z659#wU1oJr9Hn}ODV75f?p}wxz8{uLR%eC6m30vtk(PFK?12(PtQ8vj<&o-V40Pz| zL1-4JNCvvf&U=GOp~xj~*Uh*yO!>%iGZSlQ?cclliZ;9K$$H(bei#?2fVoK0VmV0X z55>3vb*?*|-;N^4iP?6>s4GB>gu@UL(?1ESJogf9zL-?zw)m5%%m0ioeKH|WyD^?8 zgCi-+&fa|ZYY8aW>N{YL&YO<_NVM4{(HbCY0>mS3W5WBDub6w0)%HNISy?4iR8-m( z(VyeL%2c=SyZE))pb}=D0uY(b+wafOkNBlPUzTnECa?%5rkOyaN(zG@L1d2w-$%IT zae;gjM_$}Y>K+)9euz9BOXnwpWOMzAvRrGbHE`l2u+lc4i%i@D1dDxBMD9#qKq>gSTbH-I=! zx4xV7ZaDKrk&&6-Y-cznrl&8K&sEga!1_r|u$z`}0EfKpJM>_9^M;f*;Kr;Zjlub1 zXCeX6H8_fYMZr%ex@Qc70(6G*FMYP}Wf_qY$$ihJy9qL8E-bp0YsfMEu|owjJIPsJ zd>zxH*NntaMPVD4fn$+9bU!g~+*)j19xo9L9llB#o7IilRwgbit*=DP8qzYvzBNu} z{VKx=0fnjpU}5s#nx4L4bl}K`peBh2!bW$;-!OxW-%@1e8f2k;W`HBWLvPpMeVhSr zuTvN=_P+%`2^sACSf&1Cl>BukHnPw0ze%J%dog|s!XoE(XQJQ+IC;FtBft3}Fi^KJ zaGYSNgklQD_cg5r#MIZJ<++t<&cKoYordK3 z%Y}TVL>tS!>9B-TYIv2ZW#XR{b^uI)g)6$Eb&~~7(Ltt{ z#aX4EzKew%h2i_@-SVX{6CzO@3{UVEx%x^|)8lOYi?unI%U6i}*7B>cSITjf1sa&4 zbO4dlyBizH+k+(Ej^mN>BA0j&<8<%*Qw&%b(A{}t768RoAcLK8dLk)V-z^O|;C#C` z?_Vo76n3eaxtM^*J9-h3wh@;1e9m?yQ@0;3-Ns3{k;u)d=)(d46`}!2%of!P=JnBw z#=YXNIpTY@`WAuEz%EQX(~N z?XsBd|4mGb(f(6PR$tBm*;PG9d}pRYFD6ds{uW@?foyx$9PyGp9}@RkzJ!cZjg4ej z2A!!;x6$2?23ude%%iy0J8qA=jjx{Mos~2<@?7oL+_+NSfA)I!+l{{Da-u@r{I|iQrXIqPKf(+j z+-z&qe06s2toxjqJUhyd@^Cq4qWS*4PiCD`QkdJQ5IPmCabCZ1t;v3&9{*%cFn@gU zi^w3~OJqp`*$Gh~c@C#HTo+gKpn@X%T*AeF?dg}|f&d0zDxa5zkF8|7dSVsj6||x> zWPcsR(1ix zs%!d2U(9UZ8;;BKM3TtoxH?=V7B&(rCY7}Dy_4rZUm5;WO}T7zjUi;#I{iPq(ELs4 z*}vgzNZ`0MA`JR=r`&bBo$(paCcf|RYCUi=U+I!VP6O5Yu1P6~o9qjqn}f#aCkWQI zrQ*_Sg#0$4g7}qdsR_~L@13c>=x4khb4h%G+TmLaFM=Hfx2>+^?{}YP3X3TY_y&4Y zm9Z%1x$H;h^WBb9xbN-l)dIf9)c1UFU=PqmVe@_?9jRA9XA6uuEA(pQWM%tZ2*Sq@ zeGAq(I0O1SEzG{Z|7;8rNSpHfkP8H)!MkDj!fx9t)duKX0G)#iLc=C=&W1*lh&?z_ zaM{@&06jS(Vc_^blmPY#6|?0OMCb!-{IaxD*y` zQ$C6e|F{9X!xjLoIuL5aCM3YV=@~Dq|7Wp6dTtR?DiR7>CaELbrWm_4{P}xW#q!?% z?;tYJfsh8_^oE`6#9Q-JgTKYUN~tpuIMH0ymu` zPk@Ajx~BbXNU;UHajcqZX+K|!L=saZC8ViItY4+;cQGLkGeco+t!Y~IVFiSagqIit z6moE9K@Oyo063qd&D*EEnvSdVJ`0{WZFP~y3yB4c>e(N7-)N9HPpWJSF&wREm?q}8 z&rMi`bldiN$U;Xx_AZXJ)!DD;s?dlPs{YQ`RzxLM;!a0svd$ayhObK&Dsac&)YM=P^i<00eWq@ z_iU*g&VqT(1YbojYWq$}Jc90Ui-s(ligdgSi!(VzPYHA5M)yFL-Ft3Pa5 z{e5=Cc%AUB*Hj=+IiIKneg_Qe2jFMtqjLGY;wpM|8v;OeQ&3ZLtf%CfmdSnUDkEkW z7w^=OBjtEKnaQL1@#DuBlVmN5bSHdrHR^d9SGRHmC;nSGu!ntGNbLcBBZCSh@{`03>medHG zG2*ez!a2+%;IR0as>`HeCV^Vy*NhnPf7PeW(ciaQ`0Fvj;L>fue zSz_8~Kmo7o-ySEci|+zFCt5fH_fD7v4=d8J{rNOX{aWi%bk6rm(%6IDnsNb3SaG6R?@u@&+0{ zEIniISJ~d%KXkk15IKP~fE3Xo(bcAVeM(&-&a5vYYtlXH>nbQsrC2J*?@W<`zEEEq z^B>4`w$cC$0#pb*((KQg9S@}B3}0UWn!$0aGRrE=#H1aF;y1fRRE$%Xo9pi}C%5XX z>IU^~RgCmflmxR%Le&E=GXUHX%MGaLKr{&m!CEzZuu3`~%f)gsMxd>X%s!A5fvSP_ z0=+6T?c29-AaC$_1t`;U|G_NGhra*s#xIw*TTWCacRy#ZP?;;3Fc(A4ZTWNgae?9- zR2ep4)YOk+gSN)naPMOIdWfvjiU6Jm>Q{iC}e9{wX+~(5X(2TQ8mnY@1ITrs6Pzr)%WliL>c^J0- zHU$1RA`W&Uh--9NRC^Ikq36G}79yv8v_z!V#-0`(mKBQ@3Q8DD$e{H{wfr`dr$sGn z#+xc|)yDGUNV7n$$F}j;rvp!OQF^ZxZ2F_;-Jc)&>w+q zBAhjd!AEnyaueooIPY}$_B$g6z#51-CM7Z|DyXZ&fQF?&?7F67I2d2H?pfrxJ^3xf zES9_X(;6A~$KN1LVj}53sh#iJ|5_0+X@V_(_D^q|ICh^6z0O>8j$beCy32P_75Mij zI^;%>RluIJyAiMb^Ir_;&tyJxml51u`{8+~k@+Uam1saK$LFQW{y;0{^Z5^VCX=5a za?qFjG_H@oqmc5HDRrMElpk}W9Ve?|kW}&sfH;qqmLUt${eE?iGU7;Ayc&{JAFVyq z=Eb4!-+Z>ry_5WdA{IH_8F}<{R1ZyDj5(Eki796MZh3! z-u1!!|1{;=u>{8h!dqN-AfV}53qr4;q9V$gA{9|r6S0f75-|ygBpYC`h3VS#Qs!h@ zvDT9H(S44{CAh=31*QiI2{nfk*J@kUt0ut;a$d()Do3X&qc8PTmDu+4#zDhJ+2Ka_ zF)}kqvlzY412V$umIqA$*#hv=$>v?!+S*?;GJJ)@pW}X{_EC%nBH?QiquUlDt^E8@ z@&W<^4xteqQtD8hv=q9wCir4Z+zXwJsZP;~tUwB(spSK%uPkM2)!rE{!G2>bPL9~b zm2~K6Q@qMyV|D($P}@{W{C%09XzjUP{12Wf^&5eMluI>tFgD7!I` z`oA*BM<^(iXp6*JEtOXhfiUGEW^UVm6}iZ;4SY~@46xD)IS(<>7|9E%VTQ#Q= z+~?T0+2FR};hVs#fQD8eCAs^xKu}9(%8HJQPY)O?2g6KgTd0{PAO@d&P==$CGy1_z zbCHy^PprmX2wpCd!qWjQrCjp$U=(x>4GmDm48^}~N^G>7#{ht#=-vJNYhIrpy&hm- z6_UWAJ;dQaJ|MQxcTexxUO*GEhtk zD>?2!blcGt6KX<3!iFCq<1Xlw|m zVj&Fid`e#YbZS6MOcT<5ZfeGC$rkpEMZzZO;=&CeF%50)(DZZ)U}#fNTwJWP(KWk* zxV}FGcolZi zYS0wmzMll?Juj`QfZa^=aUD*&$y7D}r%62cOC$JB7pb6bqgF8+j-SYQ0EPZu=tIvs zD{LhBml{3z4tdJ1S=Ussm-FF-NIdgPE^fbX%KDZ&4(pZcvL6OgAG@SK_YI>xllAf7h>(9#tIvA11?@I+jtbrDY9A|R{2N8*`pJfN@3jhLyX zs>r?b`{HLZJpN6#S;HsvR$&wAt+{tj#8T36F>Be2z9StpMb>Co#tq)MAQdLc!S6W&wmItT-rF4nst4PRjkXJL?K*@pEWa77u2BjJ5nl`_CxU2k|#Tu{E zeLHw_QM1FAM4!7+@Y0x7(=SS)(P}FgKUe14#U+w5PXV!dL(jN|vZiK_RD6Yn-gQ(cT~cz?OYixx_^ zK6E}Hc{Z%y89?b3A3D*U;~WIB#76s1VEGY|{qpa<3o2tEs0GDGiVqDInmi?9BA!(M z8ik+w{gJraDe!Omyz81k2YKs&J;^!7Qi!Z-B%xY9Y{La$t@V3ZR)CpIAg84Vn42}) z?y5P%$-SrB)YliF?#&6A#N-L;;aMk{#$jq=LLsE9;pwPudlD&@zI+|ZI_FEKi=EWi z%-EyXTR)`d5~Qp@RlalY^{?sn8jP;9pI= zAYK0Jua(kbX8PX7(d+SPWf(~a(}5q-h+J-HVaz#oo5UiZpg`y+8NE;9%0DjGVB zh6gwZ3uBr_H5f>e_g}~8_S!Z?*pB7*1WndsUI0919*R$7IXd;5Z8oyS5_i?!#_wQM z^Et;=oE&06W@4|&*L^MuC`3M-W1aS^SC}@WaobCB0_OtrIshs8>=sydIGF`@@&aD> zYju4ffFP5hMBwbi9e=NpaoHtEd#jV6#-DyxpaT%mF-z3g_Tm(|To7cYc&ad$7L>ZG zb{a}1ePlHy}!%)VgW6VHj1b9CV`9r3O>Ca z3xQCC5U?aQ?Je4DHN;5QTUGX+FV_qOE|2~Dh5)6Gy$jzLk0xS)F{1co4mM@2!3dP2 z;`als9v%BDj8Grz2&$-n)wkcUB-@Q^5(4jbOROp4nfrX;I6rW^ha|9df&Bu+` z0AvMRKD-v+e@ylp4&-T&Imd6(l#9$gflACtMG(1MLxs$rgpkU^ufYcO?F1wN7Af3B z3Jg2x|7l7lPXtMI|JDrU_E9jgpYKFR$E6LPvA3-wOi<+exG^M3O!FER4tF)f_tGdA z2`-H*O1Av*r@r!JLc@2QG@Wo8-;0Jeumj>LdPhhWVh+-0b^{I z6ZNAUlAbOxHsG!Wk+|In!*d%uc|2aw{%NcMd3H=2!3Iu!7OogMl^ItJtr$ zVtx4T7XnmC8R5`6y7{u1C(k3+ft9iL;JCIch6#eXd=~_ol%$RWb;RY^0I6HHbUp)z zg8D2g_^bOKup+i_!d6OV_#x8n$OjuA{2+RLnTGY9-&zN8wVrL@1doxeNZkAx`ud^} zIT?VL6Y0XSpT=}TO{NmPghUh(=KNXe=o6t{wU%s(m@1Wf1))RyNj>-Y-g92 z;pcp%TL)t6E~>|ri>HH$vSBMd7m0w8Sy0-@ukLl69C_bD-(7)n2ntr}BS21r7s8p% ziu)J->VIn@B58=K>${WDB<|oD55B>n2LtP>ecS`30gA#%dO^RRN?%Juyc&~SVEm;2 z4y~U+3F@{48}g|y7=__n_f-jX&NBlVn1waC?%)(GF!H4twe;N{7`d>n^XHeoITHbb z=d>4}k=diU3^a%~62_iT`Ym6q&jgp;?Ik)&f{}xL;^Bg{A1z2SJJoWEgo5o#RJtkE zSK@pUt_xd1??pZvSn?nFK2d87buWr?=$J5PB>r{DOn`fLsy#*q0222nHA!9PsjcMG zNtvenMV-J&!kkK|^T5&xJX*9}=z7VNICm$KXz@GfHz){kE3BrOV*8l1Q_+_hgdYnI z2wZt}GexaeP(Oegj8HF^JoveE@=qkk%(n;WL=;_C9d#T@C&+g6&_i#RC8%?}Cut{I zn)@7Y?!O%S&n6D5S5YqIlnT9O6IM|300?!!_tWmQpx!o5vKNQ zgf9WDHQ*sRz?O6zTKoM%LgEYAV?z^Q1Uz9;A|+TNQ>F#Et$FFUe4y_F~=go^&; z7UkyC%m7+)%%%GtF7P$=85$X_m?`V`&MaMwqduj~0z3bqTp4czlv`YM16ieRv$;tdTc&GS3UGCz!S z9SPdEjyNrA9khs#fcY_y)&6xe;gE6N_!a+4rt2jiOob?V=+vOBU9TBWh1_{b+gSuc zLA9wB0XYu&Oo;!dhGghWJCL_%%7zQNtmQ0{z+k${*ttZ0-;C8i`-bch4tod)U*bnq zjL>P~b&-R3Hlf%q!Gfnp)P;5uW_h5j z@ZhU{UbRB}vKSH6X1-TpIsav1bbK009G;r=9esKI=9FSYRWF^KRUbK9ezBWDG7Nnl zh8O=<(n2DN!rrZdUaF9I?!b4aH1dhMIohnuxr*R|sA4{>K{Fx2mDHu#M`H_LDSH-b zbk4aP#E-nY$KFKWY-h?y=qrhz-AH3@x1(o6Ks4Wx)DcyT9}d0x$ekT@bY=VO-|XPW z)Rg-dk06%xdB=y=;UMQ!p;~nlgYW?S#rJH{l0;DzB8UvcG`(~K66cLjwO0_5LH6TA zJOQ*xIm79XS<>OykGb~t>1M|6UTObtrU6T}TWOe?WCd{+vja1$zO;Pe*lqt=4wqTT zIrN90Pmdr?(XUS~YuG-YS4sa4Jczt}+)xY=Seu9eyEvWQ=%u^gp+K`NV(E7E`Z5+G zQWY+tpPuJ67KkJZ*=A7o()jS2I9jcfMw;jWD@imo3Rx6Q81WS8k~fCfTPbx}dxb?( zK~gznn@w+v$&svc7VAOEDAaYoN-w?=(Io*{MjIQy%J0~WEA!95=>QZVks%H?@Bawi zoQFEi9Gvn1(HI1^QZ8F62in05#3a$Mewql!kk;$i`pks=x@RyUXLUR6OQJqkQXw zL@PN2kUyk=*Gz-)-0}Cbp2{X{&2SmfmD>a&$nP%qdsO^wh}wP1tKX^S|0yxI`7u{G zo1(3yPB6H~<*3OT?F1XTHauJ`R`e;bpT81PQI{zBnE#K2XJal z#>Og(WyKA1Be=I^<`>*_8Gc9Jc^Z5D&wa!@^0CwdMoS3a4J=LM5}FDXt8a31QI}|F zx%ije!HvbrzF*RMU6zJ>qjhI{rF5B_67A3 z{`!kaC%r*`cmXiJRnayCLLm-=1zKZyMWBxEkjs}?0zfHASrjEf8^GvF#J4sCON9}@ z$!xZ;ijqg`b3tXMb*u`IbqPvM6>iW1X>I3%=?>leMP6y@M%NT)?1}^7E|U2;LHfX> z?@59Jt5>%-V&^2}Pj$;?GyT}O^MY<6X}b)^;I7CB$`D($c|#rYklnh_9y|Q!G%*EO z&_uC3a1<-?92p1{DlQsHUr^%qH5Km^)mP|Ph!vF(5JEypnzuLlM2+>w-AwDG4)NgKI|K4g5JCVTxZkrT^Tb(FAFqj5l?;gviZkbh9{RI^1$4c;-CS+)n2ht@)g$ zr6?JHZbiuHvuY4NCWTEg09rz}mWs&NBHct#PL*PPds7S64M%4F zjmg%4g4-xcQaISK3#8xWw#nXe^B4<*S7?B-@yB{=TNvI!PyAJStgeAV7Cmm{i~YM} zJGGeKyv$>#$c7SUX@?vWQZ!%VMYaV$O47cc3Y?@AcqD@e{OTiNDfH}%ho$~o6tzyt zZ-r2)3n(%L{JL7dLZZXJ0TK~GJJ5_VRsKWC!T+JqWPH?di@#)>xnX3FmreLcNILdR zK7`#{j$0Klqk+5CvAad=91pG#9jnhbM96k!^!?bgml zhqZJagW8Q~C;AUzYaTy-jCImECUseD-cqYgCn_VR11?UgmC;&kf#k0O(+^<+t1gya z((H6soT{wv z0BGrs=n}Xy>$O6cpklAI$;pL1`c>vAO^-M<(O`e8v2O0&#c0eR!H6qNSns4m>sne~ znAY*b6_8Imc;KR?)->nooH1VPIV2`Zbis$rwkOR4Hkvww#d)o0nqC&U_r^)xQ9cz{ zx0d7uQ_7)_Vu0c-88IR}4naYXAd(ioohVUXaQNOr%gRq*gWINaT3cH^$JErEKM?AwE1Uyq13w5*4KA{J3YlIJwehrZeRi8( zTN-Z(D#wyJLE!|1A}>VQ$KicOTQQ`J6=ypA{Ik0vOQk0mKZ(%Ojlps`Gr|(2(i=kE zcbER^+U*J0ijqVqf0IKc2q%94H^xY$`*`tsc;Wt3R%Yh8DjOf{WB_*lobZv@v;1d4 z9ik%}M$r;*s`4ty#7mr~ZS=tFnqE8k`U4KC<>!&r>ff&{UTR)@biyN#JHy0^tHy$tlWu?SdaF;e(FAo10m%qbkRI*1jAA@fcKv_1S}8Y&R#v;rQ{Vusjp88r!P~p z|Hh*{i^r-`&=O!lzAUK8;ymC(rdNKNjus-P`AgGFlj7m(&~Z~Tb++f<+IyD^rk_<= z39*;J^tZNWi_&HysU#|5ak@5<8SXMsV}0pu@MGq_6y!Plo3-zl(QAN+DXc`6(WgAJ zNx#Lhph6WlxUjZ;{Td*UFi}c)_V@742vWoa1u?)r7@1loffzvNSYLjA`KF=S?-a8i z?!!&<**gX~ySEVKqk@JVlj*1GATl9H{JQ_SK(c7q;rcIke+Wb*92WvX0pyZAK_tqs zJI>j5>IY?ys^-TeF^XZH`G5Y49UD8V(oi}#q6(j2P`YZr;<9!_Jm55Zr}I&jNej6* zXs;XGR_;oyZr#o^vAgt+L`5($G2yr;{me%KgFl+_s*=3|H;xiGXih51=D2@d)gV(a zN}+VrY9fAaR8OZDL`TE{ygl0^8R1__{AVDhdQn>Zr>i2WgLF~(XNj{3wZ*|NzFdFl zn@r5RbIc>JVj0K`EE`xiChq$m<|Ju2(wH4VrE$y}#~F~CMhry~L#z!9YI_0lWeBlX zGe(DMERh6S_e#{?6B;<|#q9fq4)T)Og8=Sy&l?o6x4$Ri=>y8~U)z213MiB!j)rf& z6KHnsfNPm#p`*VP-ofojecGBNtQag`<}cu@z- z0tpSGvG?C2_XHBABIT$F>o7SY@Y1g{HlVDKF^lf83RBe~yjrJO{NdNr9DRz&r4L6a zaw{C>vcsXlH=u+iC)8 zi3C@`WCB*w)y6ZR)k+CnZ*iYN65)NkzovZbGABG6C^yfhZQUb4BD+0_0NQ*-SWE)V z9N>+3G%zm}CTgaLLthUC(e;W~mnGZe;UloV)j;zh3L&SAtm-`UX{0 z1Y30@1X?I>$6WAMk&`pvkQyOn_D(4iDRH<2=Nkn;rfUA&gAw(SMAGBL&56cq+L{1wH{lp1g*eYhj*PUkGFw76pU{FxjqSK zr(owh8^aIRUX7G~hN;J~5f$`ohAIc_zizNi5?SRWx+I(A;3T2##R5KsOCoO8V!r}T7Z@B-?;aJm#eREG6t`tq0C%sNp$8&6nH(BQja-#PwUS-2hUb4ePR z_VPEdzaHL#r2p-4Piut$DN7VAunqY5$&Ps@{RS8kR1^h8loZP@fl%&J*;VKAlf>~; ze4GgkJXtS)EMI}yIl7}2d-FS7kK2$hc7P*3zA!i1H`kwp&PEy3zVuS&q-er}cHFB@ zd@0xc%)g|h!3YZfss;u5PX_#peRaLmMDhX@DI6RA?R#br4i!fL*LS!P*sG6A z67s!X&drY$_%2WC>${mSwis=YB-KmNMSV1&Vyes-P%wIzm-+r?!Ug(W|Ri8-jzMFRc$a(^}J!*bGIxW;l5{9CZ5)%uORfMWu{)zUt zpv+KDmL&XUCt-VuRcypfn?5~tDUm&#!7iLt#VUz6WxpU z6SQpNXpld>Dk)UXQ+hYjPa>?K=hM`g*Hp42N>a#0%T#6%Yp~S4F)F0o@b`<&iZF@x zC1@*n{%2itol66z6Px5``FH*0ILL^2#t43Qs$8 zxU|$FF5e{jeQk*r9&Wp1YvH|QU;fTVk%3z8Z7U3oR1bT+Hcfmq9po#Rhogd!6A_Gj z&%ev*nEcG30RFq1QW5F}%+oWb0OaRaDnMYyfKR|I{JT!P#>Q3i%7E1`?}>Kn&a`8| zy6`1rko0WWm)FasdvH$%c*tkd%`an$JO8~m8_**(4=ji-n@(ZHw7>yopeVDeV`b3D z(6CiD?EE;tB0@N&k&rA`rXv(c!UhA|raN8uQQSA!2Ut2Y!dqFj9c2hUB6i+Tnd*VN z+haX_F0xGYL&V6Rz@7ILA!8Vq<9)X++`ecX=E4Zq@ce_pNb z_2)pqQ#=O9#PC#6zpqzXn+&MZSRju8PUAj1u>O2dD}$PCf~Mu&&9(#hkCxS9^fz4w z3e_mW@W4IAZ`mhiPRJ5%CYNi!2lqhT0r?#>7q&j_58LrG|B-B6d3Mi=d~hbnB{B)-2wJFu=ue&(|8}8^S~2s;T41?+#BY zc7SsOJg-+&XuIYC5H4Gc?uqpFkq;W(5tuWO|1rb0=1h%D0l-kG08!Nyz3{=Fw^od# zWgw9mC(f=N-IJ4XM zG8}U_olHV#6}DbrbHLSHd`Sg455ow{%ZJCoW9yPay*D1jWa@uSW4+~IY-;+;hfbJs z7r_uaUy;OXx7u|@&tM@j$19$M3I{B1*EP=w@1HjlWGGgpUQ_}AgLeDf8 z4-7Q0domgtX|tQWmx)_U@040cX}XJb%IE)M*EL;vdwdYS1zgF|F#3T>YiN6mMRiIB zuMS6>Z6^suM1jlZ%=8Ti8J8Pi3iS5(QvyB3f;Ptbb+0lXx|PHO_sdOnu(=BN4kY{go2t zg-}p7qAm#fXW4qR(jWq-0?pfOtQt|IsxK(3x2y%7U%U*qhqgb5k|`@I>pXn$K&Ix` zebW8$`mF-vw@>~z2}Ap@0Te)kD3Y6ZwE@naANw< zwBMDBoO+~1($GlQ)fXTK`UBu3cez)HqMUqS-k$+?@q^od3dn%;YJ~4}lgL<+r%%M= zLWAv24~*yI1cXqB)Wk5=8 zq*}StGGmo^*}D5c(9rn-1Pz`)`4-{T_Rwkm26FIu_dBJag-FI|CDCte9o z3pRh(YgLiWiWWU9Q)-`>146T6(Lry-q!lMP^T^&B^_u#(G}jG;44!@}gkD!{^!!P% z`j$h>olwniWQ4N7_=5Ewlg*qkreouM@9IhvwRZB}@FY*O=E!}|eS zx~KJYwGwwIc%nQBjnQE=t9LP)0k#g`V!q#iu$-hDQfWjCEIWymp3R&xgQP~Wc7jx6 zh)Cn~S)X7!kX)9Ec6x0*T$*pKjRzEm8Dz5_AFlIacN z_NULrsWZB%I=il@F04-~sBPp-mX_GtVA(Xt$kfVj?J7-31(2IWf2MHfC|t~hPwX}@ zGuRC$9vp}Lg1h}}W&`jsk!&R(4uM5wPpJPj9MZ{RJm1F7h0@cY&m zFl{l*y{+=|G)M$3a7h;AXJs8~)}gRVGv^SUwtZU3EBT@W!@Lm5gSzLXkX9>U6j}3i zUOTl*L^7KkOG(^ePeh*JV;yxD?@>cCHIKhMIp%VGX&1`z@4b6Cs;1vF0`rd&Ft`S+ zPR>%10Iw3V>4`7RBLk5yq%M{R$IDxTv%mtK{IV5~;`@aMu&pn*%Jb8p{*i9Sz?zDS z?ZWi+N5<%gi90hLaFTmQtq0RGk{MT!J!%hUALL2|(BC?~5Nk~*VtR^YqgK=QLRPBY z!Rt4|+QfzhxEW=eeHC8t@aFA!i{vV?P{&=F=K<>bhMxGuRd}ZHecSM1TfAIq>Wrq? zCR`MxEJfAdsYOVlL5E(vLS<0esNZMk?QZ#7kfRrVW~QC-wfx2s{_x?yB>=XVDDSRO zs(LBZ@rnkDdZSb55d^%WSv_4{)Mc6ch#uwlzLI36QvsK?*=Hw5qDCBUA8zSJ0$DEs zlvN)Ixdwyq5eCo{JH-sxMS`9K;Hcz|QPuKwIKiUyh$&T+(r0MXjH4>2sg2u6J<^!L z9}N`EB8dzwfZh!|K?Z%0$o$1pdAiZ9Cug2rd$c-f%{#8anCfbe&Q4l+nYn!b>7BO- zIFs%3iv($CDh!PQ&k6^~cc%Gs2galIHJZp?3IBdduB_*^!C8oXF>_M^ZMXD@peMd= zl@NQLuTP@CNm)pN9V3H}T|0Xy?ujyn0$^^){O)<3}Z=W7J&WLwYyFcnHHHlhw}RMRsghn_=O zM>hlEGv{>(@+aT5*NAd_s9Y*)a)N)dJ5smQp>3S9qb2jR+DUbA!5$bAPuVkFuhxLa z{h8-Bgl_g+v8i{O@B@?wlgfV;t85s$?kOKMkM4b_7)1XA^ic?2E@hMb?!dR>>c zZw`A`bHDgvOi1RmCwFJ01Vei}QMsw@g;@77;54(<*48em(C?&4k-|2K5)%_~KLH3t zDreoBNQ&YW^?C?uDaChFjgV5yIeMeFprq z4s|K=*AXc0)b<6S1{X$}z~{i5AX@`=vd4Cby%m1O>jux&GCtfeOj-&ACbmrd){Du` zM)%*>6x!34N}7JX40=y~?b$l8 zQ*$SpBB9k^QdtTxU323!rFeGE)N(c}_?^O}0Y0rsZQ}H9G@;Wh5h#Tf z=m3n()sYoQ$MsP`^IAGg@Uj#GryN09XPB80nKmXwK490_c+94^UGDh!z;wAVgH@ng z2fB@?WpV*pW}k@|uG|Q!jl2mC<;Qh(b-aMryZ`uSDHqeQRj4xdi)iO||9OL`?Y^#| z(S72zzlh;?H%azFMT!8Z;f-aOTvq6*gCP8J6r{@^9-Mz*f{JG`7|;N!xe~xo?(utG zwPTN&vy8z-B==Yav4vkw$Yg6?)hyQ2A&1@l_Bp758Nn5Bh`qbrJ9Q^UrBT(O+k9=E zu$_^SQTFAtXFE|Y=i*t(^7q+M(T``_BMEPfjEqdr1{Nr%3o=ymLLhse&7;m7F{vZm z&jCvjyLq=w16Kbs6lh%y$BI1*1ST98NEUDMK>{!{!Y?v2C+R7DaoQV@j(ngt$7O>L)MX~s$-X$B9VKgJuM0@JCm>|yZv;fRm@)xWf!>!7vd3sS*}!a>`;M9}KZkz(Y?V{)?f-|Wk$aA{Vi%)e`QsDKr zzdk-(08 zihs7hVsJBYz6VsE*4;mtkkFwSG;VOwO_D2AI6svIodXGvydbWCNY&}XkRLx9(cIX5 zry_SQXI-7A9Y$+t4SfO!`NyQVtrPiII)hY3lyrKdo31hY21eiPaceagIbVF6HCa?n zK;Yhr<^)@QReuVYCm`1Cg$GCD86GP2q1WIvHxX?3QzvdWEltD|OCNAee%<@N7lQe~ zxgkI9TjvfLAI0k0>Rdv@LYAu#ws5mqh$oPVvMEgRXAg^_v-3(VAXeNW=PJrnX)U}= z_JPcOMaRIJ9R~|s@%#1mOU+cLS%MS+V#s^HQJU7>L7n%rOGbcxJ?}QIYaPkKn1>>& z|DGb*LXByAEXL8gU#VJPoZRTg*9)$3%nX+rzgpxLL(Y-j&+1i1PH_s zpk~n4Kl@wplj2FYpo~@vC*=M=_s+cV!JepVm2=fc?RK@G?Jd}4JBcgF zrloW{*F=^ca0ttI2pm+bbIP$>eZB zbaH2YqMz1(lUDG(H_-%Q%2(=5W#);nYNz##^XFS9dlBcTpSqsQDxEeQ3ss=F9!i!o{H4HSV}(a zEiW8GL?Fgg^E+aY={#9kU}6C@k|Ml7g@`PjJx!S||9M3}9Zj;R8V+Q2oG7xOH~u}D z>h>ZV>3x!pf1i?|`1K5;Sj=9@8dzGkxh;Lq_fKQHBUC8b?Z4kBGzHD;z_a&7oJV+A zA^zL5*d8zq;WSw^;ET#_xFiYF2+0*E4S`gx6fyslc|M8EIlpAi6ZBYpHJNDvEBV?A-S*sX9=UCk)zH(4a3af z>zi&WD&0vCr=Qc(@4i--O;M;myyN~`{3F=MKI?vGy(S4H9jeaJMGB^Cx`Eg5)SIOH z)jc__?tFqmv40PsGPHW)XmK*GhiqEyL_^ncN@NQVRO756S6tC~<6qi#;S;C^AioX? z)RJ~nb0$-Er?mr*Lp(ZUVENV6OVLJjaOb~A7tP_9I99OBE^fE3K9=4U(*DC3Hv|W- zIu3Ph?J!{6G%Gf};UvV5Oj+CxY?X7HYNM@`aO+x4`SZd3C@eFRJ<>RzS&aw^2r$t| zprR7F8IJIw=(%wrwt=y~Qz$-3!o}+S2$+42^%*{Dg3xtL2u0n8q(%=oCj-zIW@E1z63yWf?B2p%%3>RFn{13=P`mbPtmJaxuY z4{k#aCs&;fBqQN)&{N7AiwbC=CQp@oyp|sGBlAIN*&8J2V2rf9MqZPGzESDskju}e z%r#_K5O_Q|<>BuQyvnx#MCFn^31R;++#1Z(Nu`c}p~0Td6%X9~+mo-o#Hpmm&{^7Z zoXxTS3NAbokpXhyAM#NDj>cM-(ytQRjZ zzrl3|VGK{5VJ8X`)tCqY20mY}{>Hu5Jsdar+83?h;D7C$${~ZCNrd;7x z4|o>tfLt3hx=l5hjw03Ry!1|$W^N_y zr1NXQD-rzRtFr&!sT1Jo6#`P@YaXge?x6A-FtDcz9^)wz2KH12_;}Wty?X%|c1q%z zX;+(XNnR^)B+Cfg3u;rH@;LGbspaLy4Ca!4U{1ns=yZe~#S=gj5>V3H8o1XCBxOMw z78aAYuV*+%fk9mHsw5RCHBE7@E~k}LXpu|T?Z+c$A#(rs1ftftdq(TSogtyhJx{)a z`AEh(MMGvyj~@B~2z)ZWD9w@9GXNKg6=l+8P_|9h^IH_Eu!@cd(n9wy>@?yboXm`j z119+YvGCXp3`1%Si&zm@ERMpTjNpAr=70$`$PcLh^+3?{`Ok*jWDHkt1zm4hlVZ{T zdtj4+@WJm@m6|e0K8e6AJiaVV_Aq-dj?IJR8NhQEjJdE2C(a`?`W`sH(PGT;0Ap?* zGZCa~^xE2n9y(QQ{Qm!qlUfkJcg<;~scjP?y_6&6bN$Z$8jn>aDt$3#Z2b`Im^%|5 z4TT_^Qvu4AFfjZJI^h3N^m$Iz6sBB~bf7owfJU$Uhxv$w>g%3`w}4670(1#Apo2FC z@Y8#5wDW_CyaOUf+A%G`*R+>|6~P5fc<@Ph4=8DnwsQ2 zhAvzDa4g_j1XIrX3IjHgl)=bv>M^+L*uep*B$50GuV|IyncHPP#cd^g6hL(8i-hrI z@;K-6sI!4@UP9RXliNh-(Uftd${v(KneNAtmam8om^fzf7*9_aD9TZ)odIsOMzv@d=eUOyKF>E$hq8OC2>7TgYb#ZwZKD1MEr9I$ literal 0 HcmV?d00001 diff --git a/vscode-frysharp/media/walkthrough/mix-languages.md b/vscode-frysharp/media/walkthrough/mix-languages.md new file mode 100644 index 00000000..c69ff3d1 --- /dev/null +++ b/vscode-frysharp/media/walkthrough/mix-languages.md @@ -0,0 +1,12 @@ +# Mix languages and share variables + +A cell can start with `#!python`, `#!javascript`, `#!rust`, `#!go`, `#!cpp` or `#!csharp` to run in that language. + +Copy a variable from another language's kernel with `#!share`: + +```python +#!share --from csharp people +print(len(people)) +``` + +Type `#!` in a cell to see the languages, and `#!share --from ` to see the variables a kernel holds. diff --git a/vscode-frysharp/media/walkthrough/run-a-cell.md b/vscode-frysharp/media/walkthrough/run-a-cell.md new file mode 100644 index 00000000..08fa859e --- /dev/null +++ b/vscode-frysharp/media/walkthrough/run-a-cell.md @@ -0,0 +1,14 @@ +# Run a cell + +Open a `.frynb` file, or run **FrySharp: New Polyglot Notebook**. FrySharp is picked as the kernel for you. + +Type some C# in a cell and press `Shift+Enter`: + +```csharp +var people = new[] { new { Name = "Ada", Age = 36 }, new { Name = "Linus", Age = 55 } }; +people +``` + +The result opens in the **Object Explorer**: expand and collapse it. Text the cell prints is one output, colours included. + +While you type, errors are underlined as the compiler finds them, with the cells above in scope. diff --git a/vscode-frysharp/media/walkthrough/run-a-script.md b/vscode-frysharp/media/walkthrough/run-a-script.md new file mode 100644 index 00000000..bbea3297 --- /dev/null +++ b/vscode-frysharp/media/walkthrough/run-a-script.md @@ -0,0 +1,7 @@ +# Run a script + +Open a `.cs`, `.csx`, `.py` or `.js` file and press the play button in the editor title, or `Ctrl+Alt+R`. + +It runs in a terminal: type to answer `Console.ReadLine()` or `input()`, `Ctrl+C` stops it, and errors in the output are links to the file. Tables and charts a program shows appear in the **Results** panel. + +**FrySharp: New Scratchpad** opens a notebook that keeps its kernels, and **Send Selection to Scratchpad** runs the selected code in it. diff --git a/vscode-frysharp/media/walkthrough/show-output.md b/vscode-frysharp/media/walkthrough/show-output.md new file mode 100644 index 00000000..cc58002b --- /dev/null +++ b/vscode-frysharp/media/walkthrough/show-output.md @@ -0,0 +1,12 @@ +# Show a table or a chart + +`Display` shows more than text. In C#: + +```csharp +Display.Table(people, "People"); +Display.LineChart(new double[] { 1, 4, 9, 16 }, "Squares"); +``` + +Tables sort and filter and copy as CSV or JSON. Charts, 3D plots, ECharts, images, HTML and Markdown are drawn in your theme. + +Type `fry` in a cell to insert one of these as a snippet, in C#, Python or JavaScript. diff --git a/vscode-frysharp/package-lock.json b/vscode-frysharp/package-lock.json index 7c6a442c..b4c31e15 100644 --- a/vscode-frysharp/package-lock.json +++ b/vscode-frysharp/package-lock.json @@ -7,35 +7,2180 @@ "": { "name": "frysharp", "version": "1.0.0", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^20.14.0", + "@types/vscode": "^1.90.0", + "@vscode/test-electron": "^3.1.0", + "esbuild": "^0.28.2", + "eslint": "^10.12.0", + "typescript": "^5.4.5", + "typescript-eslint": "^8.71.2", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.2.0" + }, + "engines": { + "vscode": "^1.90.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.140.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.140.0.tgz", + "integrity": "sha512-KAB5RBZIgnUhpp4mKsVJPPHLOQKu6WtGy2xpfWjaajEXO4xIIC+UESAXLiQ9ewx6QD1uBmvC/S8BJzkbvv4bRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vscode/test-electron": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.1.0.tgz", + "integrity": "sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", "dependencies": { - "vscode-jsonrpc": "^8.2.0" + "immediate": "~3.0.5" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" }, - "devDependencies": { - "@types/node": "^20.14.0", - "@types/vscode": "^1.90.0", - "typescript": "^5.4.5" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" }, "engines": { - "vscode": "^1.90.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/vscode": { - "version": "1.140.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.140.0.tgz", - "integrity": "sha512-KAB5RBZIgnUhpp4mKsVJPPHLOQKu6WtGy2xpfWjaajEXO4xIIC+UESAXLiQ9ewx6QD1uBmvC/S8BJzkbvv4bRA==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "dev": true, "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -50,6 +2195,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -57,6 +2226,23 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", @@ -65,6 +2251,59 @@ "engines": { "node": ">=14.0.0" } + }, + "node_modules/vscode-oniguruma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", + "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-textmate": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.2.0.tgz", + "integrity": "sha512-rkvG4SraZQaPSN/5XjwKswdU0OP9MF28QjrYzUBbhb8QyG3ljB1Ky996m++jiI7KdiAP2CkBiQZd9pqEDTClqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/vscode-frysharp/package.json b/vscode-frysharp/package.json index d12840f9..c7a59939 100644 --- a/vscode-frysharp/package.json +++ b/vscode-frysharp/package.json @@ -1,10 +1,19 @@ { "name": "frysharp", "displayName": "FrySharp: Polyglot Notebooks & Roslyn Scripting", - "description": "Interactive .NET 10 Roslyn scripting, polyglot notebooks (C#, Python, JavaScript), variable sharing, and rich visuals for VS Code", + "description": "Polyglot notebooks and script runs for C#, F#, Python, JavaScript, Java, SQL, Rust, Go and C++: tables, charts, 3D, an Object Explorer, variable sharing between languages, and live diagnostics.", "version": "1.0.0", - "publisher": "CodeFryDev", + "publisher": "Codefrydev", "license": "MIT", + "icon": "media/icon.png", + "galleryBanner": { + "color": "#181a26", + "theme": "dark" + }, + "homepage": "https://github.com/PrashantUnity/CSharpPlayground#readme", + "bugs": { + "url": "https://github.com/PrashantUnity/CSharpPlayground/issues" + }, "repository": { "type": "git", "url": "https://github.com/PrashantUnity/CSharpPlayground" @@ -25,14 +34,25 @@ "polyglot", "roslyn", "jupyter", - "interactive" + "interactive", + "fsharp", + "python", + "javascript", + "java", + "sql", + "rust", + "go", + "cpp", + "charts", + "repl" ], "activationEvents": [ "onNotebook:frysharp-notebook", + "onNotebook:frysharp-jupyter", "onCommand:frysharp.newNotebook", "onCommand:frysharp.runScript" ], - "main": "./out/extension.js", + "main": "./dist/extension.js", "contributes": { "notebooks": [ { @@ -42,14 +62,21 @@ { "filenamePattern": "*.frynb" }, - { - "filenamePattern": "*.ipynb" - }, { "filenamePattern": "*.csnb" } ], "priority": "default" + }, + { + "type": "frysharp-jupyter", + "displayName": "FrySharp Polyglot Notebook (Jupyter file)", + "selector": [ + { + "filenamePattern": "*.ipynb" + } + ], + "priority": "option" } ], "commands": [ @@ -60,8 +87,9 @@ }, { "command": "frysharp.runScript", - "title": "Run Script with FrySharp", - "category": "FrySharp" + "title": "Run with FrySharp", + "category": "FrySharp", + "icon": "$(play)" }, { "command": "frysharp.restartSidecar", @@ -91,44 +119,404 @@ "title": "Clear All Outputs", "category": "FrySharp", "icon": "$(clear-all)" + }, + { + "command": "frysharp.showEngineInfo", + "title": "Show Engine Info", + "category": "FrySharp" + }, + { + "command": "frysharp.showLog", + "title": "Show Engine Log", + "category": "FrySharp" + }, + { + "command": "frysharp.refreshVariables", + "title": "Refresh Variables", + "category": "FrySharp", + "icon": "$(refresh)" + }, + { + "command": "frysharp.copyVariableValue", + "title": "Copy Value", + "category": "FrySharp" + }, + { + "command": "frysharp.engineMenu", + "title": "Engine Menu", + "category": "FrySharp" + }, + { + "command": "frysharp.runScriptWithArguments", + "title": "Run with FrySharp and Arguments...", + "category": "FrySharp" + }, + { + "command": "frysharp.stopRun", + "title": "Stop Run", + "category": "FrySharp", + "icon": "$(debug-stop)" + }, + { + "command": "frysharp.rerun", + "title": "Run Again", + "category": "FrySharp", + "icon": "$(debug-restart)" + }, + { + "command": "frysharp.clearResults", + "title": "Clear Results", + "category": "FrySharp", + "icon": "$(clear-all)" + }, + { + "command": "frysharp.newScratchpad", + "title": "New Scratchpad", + "category": "FrySharp" + }, + { + "command": "frysharp.sendToScratchpad", + "title": "Send Selection to Scratchpad", + "category": "FrySharp" } ], "menus": { "notebook/toolbar": [ { "command": "frysharp.restartNotebook", - "when": "notebookType == frysharp-notebook", + "when": "notebookType == frysharp-notebook || notebookType == frysharp-jupyter", "group": "navigation@1" }, { "command": "frysharp.interruptNotebook", - "when": "notebookType == frysharp-notebook", + "when": "notebookType == frysharp-notebook || notebookType == frysharp-jupyter", "group": "navigation@2" }, { "command": "frysharp.showVariables", - "when": "notebookType == frysharp-notebook", + "when": "notebookType == frysharp-notebook || notebookType == frysharp-jupyter", "group": "navigation@3" }, { "command": "frysharp.clearNotebookOutputs", - "when": "notebookType == frysharp-notebook", + "when": "notebookType == frysharp-notebook || notebookType == frysharp-jupyter", "group": "navigation@4" } + ], + "view/title": [ + { + "command": "frysharp.refreshVariables", + "when": "view == frysharp.variables", + "group": "navigation" + }, + { + "command": "frysharp.clearResults", + "when": "view == frysharp.results", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "frysharp.copyVariableValue", + "when": "view == frysharp.variables && viewItem == frysharp.variable" + } + ], + "commandPalette": [ + { + "command": "frysharp.copyVariableValue", + "when": "false" + } + ], + "editor/title/run": [ + { + "command": "frysharp.runScript", + "when": "config.frysharp.run.showEditorButton && resourceLangId in frysharp.runnableLanguages && !frysharp.runActive", + "group": "navigation@5" + }, + { + "command": "frysharp.stopRun", + "when": "config.frysharp.run.showEditorButton && frysharp.runActive", + "group": "navigation@5" + } + ], + "explorer/context": [ + { + "command": "frysharp.runScript", + "when": "!explorerResourceIsFolder && resourceLangId in frysharp.runnableLanguages", + "group": "navigation@30" + } + ], + "editor/context": [ + { + "command": "frysharp.runScript", + "when": "editorTextFocus && resourceLangId in frysharp.runnableLanguages", + "group": "navigation@30" + }, + { + "command": "frysharp.sendToScratchpad", + "when": "editorTextFocus && resourceLangId in frysharp.runnableLanguages", + "group": "navigation@31" + } ] - } + }, + "configuration": { + "title": "FrySharp", + "properties": { + "frysharp.dotnetPath": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "markdownDescription": "The `dotnet` executable that runs the FrySharp engine (needs the .NET 10 runtime). Empty: the `dotnet` on the PATH, or a runtime the .NET Install Tool downloads." + }, + "frysharp.sidecarPath": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "markdownDescription": "A `frysharp-sidecar.dll` to run instead of the one packaged with the extension (for developing the engine)." + }, + "frysharp.codeLens.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Show **Run with FrySharp** (and **Stop** while it runs) above the first line of a script of a language FrySharp runs. Not on plain `.cs` files: the C# extension has its own lenses there." + }, + "frysharp.notebook.outputTabs": { + "type": "boolean", + "default": true, + "markdownDescription": "A cell that makes more than one kind of output (printed text, a table, a chart ...) shows them as tabs in one output, as the desktop app does: **Table**, **Console**, **Chart** ... and **All**. Off: the outputs come one after another. Takes effect for cells that run from now on and for notebooks opened from now on; the file saved is the same either way." + }, + "frysharp.notebook.stopOnError": { + "type": "boolean", + "default": true, + "markdownDescription": "Run All and Run Below stop at the first cell that fails." + }, + "frysharp.run.arguments": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "Arguments passed to a script run with FrySharp." + }, + "frysharp.run.saveBeforeRun": { + "type": "boolean", + "default": true, + "markdownDescription": "Save the file before running it." + }, + "frysharp.run.showEditorButton": { + "type": "boolean", + "default": true, + "markdownDescription": "Show a Run button in the editor title for C#, Python and JavaScript files." + }, + "frysharp.scratchpad.shiftEnter": { + "type": "boolean", + "default": false, + "markdownDescription": "Shift+Enter sends the selection of a C#, Python or JavaScript file to the scratchpad." + }, + "frysharp.notebook.liveDiagnostics": { + "type": "boolean", + "default": true, + "markdownDescription": "Mark the errors of C# cells (and directives that are not one) as you type, with the cells before in scope." + } + } + }, + "viewsContainers": { + "panel": [ + { + "id": "frysharp", + "title": "FrySharp", + "icon": "$(symbol-variable)" + } + ] + }, + "views": { + "frysharp": [ + { + "id": "frysharp.variables", + "name": "Variables", + "when": "frysharp.notebookActive" + }, + { + "id": "frysharp.results", + "name": "Results", + "type": "webview" + } + ] + }, + "keybindings": [ + { + "command": "frysharp.runScript", + "key": "ctrl+alt+r", + "mac": "cmd+alt+r", + "when": "editorTextFocus && resourceLangId in frysharp.runnableLanguages" + }, + { + "command": "frysharp.stopRun", + "key": "shift+f5", + "when": "frysharp.runActive && !inDebugMode" + }, + { + "command": "frysharp.sendToScratchpad", + "key": "shift+enter", + "when": "editorTextFocus && editorHasSelection && resourceLangId in frysharp.runnableLanguages && !notebookEditorFocused && config.frysharp.scratchpad.shiftEnter" + } + ], + "notebookRenderer": [ + { + "id": "frysharp-output", + "displayName": "FrySharp Output", + "entrypoint": "./dist/renderer.js", + "mimeTypes": [ + "application/vnd.fry.table+json", + "application/vnd.fry.inspector+json", + "application/vnd.fry.cell-output+json", + "text/html" + ], + "requiresMessaging": "optional" + } + ], + "grammars": [ + { + "scopeName": "frysharp.directives.injection", + "path": "./syntaxes/frysharp-directives.injection.json", + "injectTo": [ + "source.cs", + "source.python", + "source.js", + "source.fsharp", + "source.java", + "source.rust", + "source.go", + "source.cpp", + "source.sql" + ] + } + ], + "snippets": [ + { + "language": "csharp", + "path": "./snippets/csharp.json" + }, + { + "language": "python", + "path": "./snippets/python.json" + }, + { + "language": "javascript", + "path": "./snippets/javascript.json" + }, + { + "language": "sql", + "path": "./snippets/sql.json" + }, + { + "language": "fsharp", + "path": "./snippets/fsharp.json" + }, + { + "language": "java", + "path": "./snippets/java.json" + }, + { + "language": "rust", + "path": "./snippets/rust.json" + }, + { + "language": "go", + "path": "./snippets/go.json" + }, + { + "language": "cpp", + "path": "./snippets/cpp.json" + } + ], + "walkthroughs": [ + { + "id": "frysharp.start", + "title": "Get started with FrySharp", + "description": "Polyglot notebooks and script runs for C#, F#, Java, Rust, Go, C++, Python, JavaScript and SQL.", + "steps": [ + { + "id": "run", + "title": "Run a cell", + "description": "Open a notebook and run C# with Shift+Enter.\n[New Polyglot Notebook](command:frysharp.newNotebook)", + "media": { + "markdown": "media/walkthrough/run-a-cell.md" + }, + "completionEvents": [ + "onCommand:frysharp.newNotebook" + ] + }, + { + "id": "mix", + "title": "Mix languages", + "description": "Run a cell in Python or JavaScript and share variables between kernels with #!share.", + "media": { + "markdown": "media/walkthrough/mix-languages.md" + } + }, + { + "id": "output", + "title": "Show a table or a chart", + "description": "Display.Table, charts, 3D, ECharts, images and HTML, in your theme.", + "media": { + "markdown": "media/walkthrough/show-output.md" + } + }, + { + "id": "script", + "title": "Run a script, or keep a scratchpad", + "description": "Run a file in a terminal with input and clickable errors.\n[New Scratchpad](command:frysharp.newScratchpad)", + "media": { + "markdown": "media/walkthrough/run-a-script.md" + }, + "completionEvents": [ + "onCommand:frysharp.newScratchpad", + "onCommand:frysharp.runScript" + ] + } + ] + } + ] }, "scripts": { - "vscode:prepublish": "npm run compile", - "compile": "tsc -p ./", - "watch": "tsc -watch -p ./" + "build": "node esbuild.js", + "watch": "node esbuild.js --watch", + "typecheck": "tsc -p . --noEmit && tsc -p src/renderer --noEmit && tsc -p test --noEmit", + "lint": "eslint src test", + "test": "tsc -p test && node --test \"out-test/test/unit/*.test.js\"", + "vscode:prepublish": "npm run typecheck && node esbuild.js --production", + "test:integration": "node esbuild.js && tsc -p test && node out-test/test/integration/runTests.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^20.14.0", "@types/vscode": "^1.90.0", - "typescript": "^5.4.5" + "@vscode/test-electron": "^3.1.0", + "esbuild": "^0.28.2", + "eslint": "^10.12.0", + "typescript": "^5.4.5", + "typescript-eslint": "^8.71.2", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.2.0" }, "dependencies": { "vscode-jsonrpc": "^8.2.0" + }, + "extensionKind": [ + "workspace" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited", + "description": "FrySharp runs the code of notebooks and scripts. Until the workspace is trusted, notebooks open but nothing runs.", + "restrictedConfigurations": [ + "frysharp.dotnetPath", + "frysharp.sidecarPath" + ] + }, + "virtualWorkspaces": { + "supported": false, + "description": "FrySharp runs code on the files of a real folder, which a virtual workspace does not have." + } } } diff --git a/vscode-frysharp/preview/capture.js b/vscode-frysharp/preview/capture.js new file mode 100644 index 00000000..9487ec3f --- /dev/null +++ b/vscode-frysharp/preview/capture.js @@ -0,0 +1,53 @@ +// Captures what the real engine sends for a handful of cells (an object, tables, charts, an ECharts chart, HTML), into +// preview/samples.json, for index.html to draw with the real renderer. Needs the sidecar built: dotnet build FrySharp.Sidecar +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { SidecarClient } = require('../out-test/src/sidecar/sidecarClient'); + +const dll = path.resolve(__dirname, '../../FrySharp.Sidecar/bin/Debug/net10.0/frysharp-sidecar.dll'); +const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'preview-')); + +const cells = [ + ['Object Explorer', `public class Address { public string City { get; set; } public int Zip { get; set; } public bool Primary { get; set; } } +public class People { public string Name { get; set; } public string Class { get; set; } public int[] Numbers { get; set; } public Address Home { get; set; } public People Another { get; set; } public string Nothing { get; set; } } +var people = new People { Name = "Code", Class = "II", Numbers = new[] { 1, 34, 45, 235, 25 }, Home = new Address { City = "Zurich", Zip = 8001, Primary = true } }; +people.Another = people; +people`], + ['Table', `Display.Table(new[] { + new { Name = "Ada Lovelace", Age = 36, Active = true, Score = 98.6, City = "London" }, + new { Name = "Grace Hopper", Age = 85, Active = false, Score = 1234.5, City = (string)null }, + new { Name = "Linus Torvalds", Age = 54, Active = true, Score = 77.25, City = "Portland, OR" }, + new { Name = "Margaret Hamilton", Age = 88, Active = true, Score = 100.0, City = "Boston" }, + new { Name = "Alan Turing", Age = 41, Active = false, Score = 64.0, City = "Wilmslow" } }, "People");`], + ['Line chart', 'Display.LineChart(new[] { 3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0 }, "Latency (ms)");'], + ['Bar chart', 'Display.BarChart(new[] { 12.0, 19.0, 3.0, 5.0, 2.0 }, "Weekly sales");'], + ['3D surface', 'Display.Surface3D(new double[,] { {0,1,2,1,0}, {1,2,4,2,1}, {2,4,8,4,2}, {1,2,4,2,1}, {0,1,2,1,0} }, "Hill");'], + ['ECharts', 'EChart.Line(new[] { 3.0, 5.0, 2.0, 8.0, 6.0, 9.0 }, "Sales").Title("Sales").Smooth().Show();'], + ['HTML', 'Display.Html("

Hello html

");'], + ['Markdown', 'Display.Markdown("# A heading\\nSome **bold** text.");'] +]; + +(async () => { + const client = new SidecarClient({ + log: () => undefined, + prepare: async () => ({ command: 'dotnet', args: [dll, '--workspace', workspace], cwd: workspace }) + }); + const samples = []; + let current = []; + client.onNotebookOutput((n) => current.push(n)); + for (const [name, source] of cells) { + current = []; + const result = await client.executeCell({ path: '', cellId: name, source, language: 'csharp', notebookId: 'preview', executionId: `p-${samples.length}` }); + const displays = current.filter((n) => n.output.kind === 'display').map((n) => ({ data: n.output.data, metadata: n.output.metadata })); + const text = current.filter((n) => n.output.kind === 'stream').map((n) => n.output.text).join(''); + console.log(`${result.success ? 'ok ' : 'FAIL'} ${name}: ${displays.length} display(s)${text ? ', text: ' + JSON.stringify(text.slice(0, 120)) : ''}`); + for (const display of displays) samples.push({ name, ...display }); + } + client.dispose(); + fs.writeFileSync(path.join(__dirname, 'samples.json'), JSON.stringify(samples, null, 1)); + console.log(`${samples.length} samples written`); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/vscode-frysharp/preview/index.html b/vscode-frysharp/preview/index.html new file mode 100644 index 00000000..b163170b --- /dev/null +++ b/vscode-frysharp/preview/index.html @@ -0,0 +1,100 @@ + + + + +FrySharp renderer preview + + + +
+ + +
+
+ + + diff --git a/vscode-frysharp/snippets/cpp.json b/vscode-frysharp/snippets/cpp.json new file mode 100644 index 00000000..be8385a3 --- /dev/null +++ b/vscode-frysharp/snippets/cpp.json @@ -0,0 +1,37 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["fry::display::table(${1:rows}, \"${2:Title}\");"], + "description": "Show a list of values, pairs or rows as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["fry::line_chart(${1:values}, \"${2:Title}\");"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["fry::bar_chart(${1:values}, \"${2:Title}\");"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["fry::markdown(\"${1:# Heading}\");"], + "description": "Show Markdown" + }, + "FrySharp input": { + "prefix": "fryinput", + "body": ["auto ${1:answer} = fry::input(\"${2:Prompt: }\"); // empty is the end of input"], + "description": "Ask the notebook for a line of input (a std::optional)" + }, + "FrySharp share a value": { + "prefix": "fryshareout", + "body": ["fry::share(\"${1:name}\", ${2:value});"], + "description": "Offer a value to other languages' cells (#!share --from cpp name)" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,python,javascript,fsharp,java,rust,go,sql|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell" + } +} diff --git a/vscode-frysharp/snippets/csharp.json b/vscode-frysharp/snippets/csharp.json new file mode 100644 index 00000000..648f208e --- /dev/null +++ b/vscode-frysharp/snippets/csharp.json @@ -0,0 +1,32 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["Display.Table(${1:rows}, \"${2:Title}\");"], + "description": "Show a collection as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["Display.LineChart(${1:values}, \"${2:Title}\");"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["Display.BarChart(${1:values}, \"${2:Title}\");"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["Display.Markdown(\"${1:# Heading}\");"], + "description": "Show Markdown" + }, + "FrySharp HTML": { + "prefix": "fryhtml", + "body": ["Display.Html(\"${1:Hello}\");"], + "description": "Show HTML" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|python,javascript,csharp|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell" + } +} diff --git a/vscode-frysharp/snippets/fsharp.json b/vscode-frysharp/snippets/fsharp.json new file mode 100644 index 00000000..224b23cb --- /dev/null +++ b/vscode-frysharp/snippets/fsharp.json @@ -0,0 +1,27 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["Display.table ${1:rows}"], + "description": "Show a collection of records as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["Display.lineChart ${1:values}"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["Display.barChart ${1:values}"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["Display.markdown \"${1:# Heading}\""], + "description": "Show Markdown" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,python,javascript,sql|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell (a list becomes an array of dictionaries)" + } +} diff --git a/vscode-frysharp/snippets/go.json b/vscode-frysharp/snippets/go.json new file mode 100644 index 00000000..6a48ca1d --- /dev/null +++ b/vscode-frysharp/snippets/go.json @@ -0,0 +1,42 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["fry.Table(\"${1:Title}\", ${2:rows})"], + "description": "Show a list of structs (or maps) as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["fry.LineChart(${1:values}, fry.Title(\"${2:Title}\"))"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["fry.BarChart(${1:values}, fry.Title(\"${2:Title}\"))"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["fry.Markdown(\"${1:# Heading}\")"], + "description": "Show Markdown" + }, + "FrySharp input": { + "prefix": "fryinput", + "body": ["${1:answer}, ${2:ok} := fry.Input(\"${3:Prompt: }\")"], + "description": "Ask the notebook for a line of input (false is the end of input)" + }, + "FrySharp module": { + "prefix": "frygoget", + "body": ["%go get ${1:github.com/user/module}"], + "description": "Add a Go module: it is downloaded, and cells can import it" + }, + "FrySharp share a value": { + "prefix": "fryshareout", + "body": ["fry.Share(\"${1:name}\", ${2:value})"], + "description": "Offer a value to other languages' cells (#!share --from go name)" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,python,javascript,fsharp,java,rust,sql|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell" + } +} diff --git a/vscode-frysharp/snippets/java.json b/vscode-frysharp/snippets/java.json new file mode 100644 index 00000000..4cb3527e --- /dev/null +++ b/vscode-frysharp/snippets/java.json @@ -0,0 +1,32 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["Display.table(${1:rows});"], + "description": "Show a list of records (or maps) as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["Display.lineChart(${1:values}, \"${2:Title}\");"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["Display.barChart(${1:values}, \"${2:Title}\");"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["Display.markdown(\"${1:# Heading}\");"], + "description": "Show Markdown" + }, + "FrySharp maven package": { + "prefix": "frydeps", + "body": ["//DEPS ${1:group}:${2:artifact}:${3:version}"], + "description": "Use a library from Maven Central: it is downloaded when the cell runs" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,python,javascript,fsharp,sql|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell (a list of objects becomes a List of Maps)" + } +} diff --git a/vscode-frysharp/snippets/javascript.json b/vscode-frysharp/snippets/javascript.json new file mode 100644 index 00000000..f29d9e07 --- /dev/null +++ b/vscode-frysharp/snippets/javascript.json @@ -0,0 +1,27 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["Display.table(${1:rows}, '${2:Title}');"], + "description": "Show an array of objects as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["Display.lineChart(${1:values}, '${2:Title}');"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["Display.barChart(${1:values}, '${2:Title}');"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["Display.markdown('${1:# Heading}');"], + "description": "Show Markdown" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,python|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell" + } +} diff --git a/vscode-frysharp/snippets/python.json b/vscode-frysharp/snippets/python.json new file mode 100644 index 00000000..d2c09b8a --- /dev/null +++ b/vscode-frysharp/snippets/python.json @@ -0,0 +1,27 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["from fry import Display", "", "Display.table(${1:rows}, \"${2:Title}\")"], + "description": "Show a list of records as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["from fry import Display", "", "Display.line_chart(${1:values}, \"${2:Title}\")"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["from fry import Display", "", "Display.bar_chart(${1:values}, \"${2:Title}\")"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["from fry import Display", "", "Display.markdown(\"${1:# Heading}\")"], + "description": "Show Markdown" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,javascript|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell" + } +} diff --git a/vscode-frysharp/snippets/rust.json b/vscode-frysharp/snippets/rust.json new file mode 100644 index 00000000..424df16e --- /dev/null +++ b/vscode-frysharp/snippets/rust.json @@ -0,0 +1,42 @@ +{ + "FrySharp table": { + "prefix": "frytable", + "body": ["fry::table(&${1:rows}, \"${2:Title}\");"], + "description": "Show a list of structs (or tuples, or a map) as a sortable, filterable table" + }, + "FrySharp line chart": { + "prefix": "frylinechart", + "body": ["fry::line_chart(&${1:values}).title(\"${2:Title}\").show();"], + "description": "Show numbers as a line chart" + }, + "FrySharp bar chart": { + "prefix": "frybarchart", + "body": ["fry::bar_chart(&${1:values}).title(\"${2:Title}\").show();"], + "description": "Show numbers as a bar chart" + }, + "FrySharp markdown": { + "prefix": "frymarkdown", + "body": ["fry::markdown(\"${1:# Heading}\");"], + "description": "Show Markdown" + }, + "FrySharp input": { + "prefix": "fryinput", + "body": ["let ${1:answer} = fry::input(\"${2:Prompt: }\").unwrap_or_default();"], + "description": "Ask the notebook for a line of input (None is the end of input)" + }, + "FrySharp crate": { + "prefix": "frycrate", + "body": ["// #crate: ${1:name} = \"${2:1}\""], + "description": "Use a crate from crates.io: it is downloaded when the cell runs" + }, + "FrySharp share a value": { + "prefix": "fryshareout", + "body": ["fry::share!(${1:name});"], + "description": "Offer a variable to other languages' cells (#!share --from rust name)" + }, + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,python,javascript,fsharp,java,sql|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell" + } +} diff --git a/vscode-frysharp/snippets/sql.json b/vscode-frysharp/snippets/sql.json new file mode 100644 index 00000000..181b712e --- /dev/null +++ b/vscode-frysharp/snippets/sql.json @@ -0,0 +1,7 @@ +{ + "FrySharp share from another language": { + "prefix": "fryshare", + "body": ["#!share --from ${1|csharp,python,javascript|} ${2:name}"], + "description": "Copy a variable of another language's kernel into this cell as a table (a list becomes its rows)" + } +} diff --git a/vscode-frysharp/src/engineInfo.ts b/vscode-frysharp/src/engineInfo.ts new file mode 100644 index 00000000..312b5cca --- /dev/null +++ b/vscode-frysharp/src/engineInfo.ts @@ -0,0 +1,53 @@ +import type { EngineInfo, ToolchainReport } from './sidecar/protocol'; + +// "FrySharp: Show Engine Info": what the engine runs on and which tool each language found, as a page a person can read and paste into a +// bug report. Pure text, so it is tested without VS Code. + +export interface EngineFacts { + extensionVersion: string; + vscodeVersion: string; + /** Which sidecar and which dotnet started the engine. */ + launch?: { description?: string; command: string }; + logFolder?: string; + /** The settings that change what the engine does, with what they are set to. */ + settings: Array<[name: string, value: string]>; +} + +const cell = (text: string | undefined): string => (text ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); + +function row(report: ToolchainReport): string { + if (report.found) return `| ${cell(report.displayName)} | ✓ found | ${cell(report.name ?? report.version)} | ${cell(report.path) || 'inside the engine'} | ${cell(report.source)} |`; + return `| ${cell(report.displayName)} | ✗ **not found** | | | |`; +} + +/** The report as Markdown. */ +export function engineInfoMarkdown(info: EngineInfo, facts: EngineFacts): string { + const lines: string[] = ['# FrySharp engine info', '', '| | |', '|---|---|']; + lines.push(`| Extension | ${facts.extensionVersion} |`); + lines.push(`| Engine | ${info.serverVersion} (protocol ${info.protocolVersion}) |`); + lines.push(`| Runtime | ${cell(info.runtime)} |`); + lines.push(`| System | ${cell(info.operatingSystem)} (${info.architecture}) |`); + lines.push(`| Editor | VS Code ${facts.vscodeVersion} |`); + lines.push(`| Workspace | ${cell(info.workspaceRoot)} |`); + if (facts.launch) lines.push(`| Engine started as | ${cell(facts.launch.description ?? facts.launch.command)} |`); + if (facts.launch) lines.push(`| .NET | ${cell(facts.launch.command)} |`); + if (facts.logFolder) lines.push(`| Logs | ${cell(facts.logFolder)} |`); + + lines.push('', '## Languages', '', '| Language | Status | Version | Where | From |', '|---|---|---|---|---|'); + for (const report of info.toolchains) lines.push(row(report)); + + const missing = info.toolchains.filter((report) => !report.found); + if (missing.length > 0) { + lines.push('', '## What is missing'); + for (const report of missing) { + lines.push('', `### ${report.displayName}`, '', ...(report.missing ?? 'Its tools were not found.').trimEnd().split('\n').map((line) => ` ${line}`)); + } + } + + if (facts.settings.length > 0) { + lines.push('', '## Settings', ''); + for (const [name, value] of facts.settings) lines.push(`- \`${name}\`: ${value || '(not set)'}`); + } + + return lines.join('\n') + '\n'; +} diff --git a/vscode-frysharp/src/extension.ts b/vscode-frysharp/src/extension.ts index e640edb5..327e6eeb 100644 --- a/vscode-frysharp/src/extension.ts +++ b/vscode-frysharp/src/extension.ts @@ -1,189 +1,369 @@ +import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { FryNotebookController } from './notebook/notebookController'; +import { FryNotebookController, isFryNotebook, NOTEBOOK_TYPES, notebookIdentity } from './notebook/notebookController'; +import { engineInfoMarkdown } from './engineInfo'; +import { CellStatusBar } from './notebook/cellStatusBar'; +import { RunCodeLens } from './run/runCodeLens'; import { FryNotebookSerializer } from './notebook/notebookSerializer'; +import { outputTabsEnabled } from './notebook/outputSettings'; +import { CellFeatures } from './language/cellFeatures'; +import { CellProblems } from './language/cellProblems'; +import { LiveDiagnostics } from './language/liveDiagnostics'; +import { RunHub } from './notebook/runHub'; +import { Scratchpad } from './notebook/scratchpad'; +import { EngineStatusBar } from './notebook/statusBar'; +import { VariablesTree } from './notebook/variablesTree'; import { showVariablesWebview } from './notebook/variablesView'; -import { SidecarClient } from './sidecar/sidecarClient'; +import { ResultsView } from './run/resultsView'; +import { ScriptRuns } from './run/scriptRuns'; +import { resolveSidecarLaunch } from './sidecar/launcher'; +import { DotnetMissingError, REQUIRED_MAJOR, resolveDotnet } from './sidecar/runtime'; +import { SidecarClient, SidecarProcess } from './sidecar/sidecarClient'; + +const DOTNET_INSTALL_TOOL = 'ms-dotnettools.vscode-dotnet-runtime'; let sidecar: SidecarClient | undefined; -export async function activate(context: vscode.ExtensionContext) { - const outputChannel = vscode.window.createOutputChannel('FrySharp Script Runner'); - sidecar = new SidecarClient(context); +/** What the extension hands to the tests that run it in a real VS Code. */ +/** What the output renderer says in a notebook's web view each time it has drawn an output (or could not). */ +export interface RendererReport { + type: 'rendered' | 'error'; + mime: string; + id: string; + /** For an engine page that is a whole chart: whether the chart really drew, as the page itself said. */ + chartDrawn?: boolean; + /** For a cell's output drawn as tabs: the tabs it has (in order) and the one showing. */ + tabs?: string[]; + selected?: string; + message?: string; +} - // Auto-start sidecar - sidecar.start().catch((err) => { - vscode.window.showErrorMessage(`Failed to start FrySharp Sidecar: ${err.message}`); - }); +/** The languages whose files the run buttons are offered for before the engine has reported what it runs. */ +export const DEFAULT_RUNNABLE_LANGUAGES = ['csharp', 'fsharp', 'java', 'rust', 'go', 'cpp', 'python', 'javascript', 'sql']; - // Register Notebook Serializer - context.subscriptions.push( - vscode.workspace.registerNotebookSerializer( - 'frysharp-notebook', - new FryNotebookSerializer(), - { transientOutputs: false } - ) - ); +export interface FrySharpApi { + rendererReports: RendererReport[]; + variables: VariablesTree; + scripts: ScriptRuns; + results: ResultsView; + statusText(): string; +} - // Register Notebook Controller - const controller = new FryNotebookController(sidecar); - context.subscriptions.push(controller); +export async function activate(context: vscode.ExtensionContext): Promise { + const log = vscode.window.createOutputChannel('FrySharp', { log: true }); + context.subscriptions.push(log); - // Register Output stream for Run Script - context.subscriptions.push( - sidecar.onRunOutput((n) => { - outputChannel.append(n.text); - }), - sidecar.onRunExited((n) => { - outputChannel.appendLine(`\n[Process exited with code ${n.exitCode} in ${n.durationMs}ms]`); - if (n.problems.length > 0) { - outputChannel.appendLine(`Problems (${n.problems.length}):`); - for (const p of n.problems) { - outputChannel.appendLine(` ${p.path || ''}(${p.line},${p.column}): ${p.severity} ${p.code}: ${p.message}`); - } - } - }) - ); + const client = new SidecarClient({ + log: (message) => log.info(message), + prepare: () => prepareSidecar(context, log) + }); + sidecar = client; + context.subscriptions.push({ dispose: () => client.dispose() }); - // Command: New Polyglot Notebook + // When the engine stops on its own, what the notebooks had in memory is gone: say so, rather than let a variable + // vanish for no reason a user can see. context.subscriptions.push( - vscode.commands.registerCommand('frysharp.newNotebook', async () => { - const initialData = new vscode.NotebookData([ - new vscode.NotebookCellData( - vscode.NotebookCellKind.Markup, - '# Welcome to FrySharp Polyglot Notebook\nRun interactive C#, Python, JavaScript, and share variables across cells with `#!share`.', - 'markdown' - ), - new vscode.NotebookCellData( - vscode.NotebookCellKind.Code, - '// C# .NET 10 Roslyn cell\nint a = 42;\nConsole.WriteLine($"Hello from FrySharp! a = {a}");', - 'csharp' - ) - ]); - initialData.metadata = { title: 'New Notebook', kernel: 'csharp' }; - - const doc = await vscode.workspace.openNotebookDocument('frysharp-notebook', initialData); - await vscode.window.showNotebookDocument(doc); + client.onDidLoseState(({ reason }) => { + void vscode.window.showWarningMessage(`${reason} Variables and state from earlier cells are gone: run the cells again.`, 'Show Log').then((choice) => { + if (choice) log.show(); + }); }) ); - // Command: Run Script with FrySharp + // The output renderer reports what it drew: an error in it goes to the log, and a test can see that VS Code really ran it. + const rendererReports: RendererReport[] = []; + const messaging = vscode.notebooks.createRendererMessaging('frysharp-output'); context.subscriptions.push( - vscode.commands.registerCommand('frysharp.runScript', async () => { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showWarningMessage('No active script open to run.'); - return; - } - - const filePath = editor.document.uri.fsPath; - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/'); - - outputChannel.clear(); - outputChannel.show(true); - outputChannel.appendLine(`▶ Running ${path.basename(filePath)} with FrySharp...\n`); - - const runId = `run-${Date.now()}`; - try { - await sidecar!.startRun({ - runId, - path: relativePath - }); - } catch (err: any) { - vscode.window.showErrorMessage(`Run failed: ${err.message}`); - } + messaging.onDidReceiveMessage((event) => { + const report = event.message as RendererReport; + rendererReports.push(report); + if (rendererReports.length > 500) rendererReports.shift(); + if (report.type === 'error') log.error(`The output renderer failed on ${report.mime}: ${report.message}`); }) ); - // Command: Restart Sidecar + // The run buttons and keys are for the files of the languages the engine runs. Until it has said (it starts when first + // needed) they are offered for the languages it has always run. + const setRunnable = (ids: string[]) => void vscode.commands.executeCommand('setContext', 'frysharp.runnableLanguages', ids); + setRunnable(DEFAULT_RUNNABLE_LANGUAGES); + context.subscriptions.push(client.onDidInitialize(() => setRunnable(client.languages.filter((language) => language.canRun).map((language) => language.id)))); + + const problems = new CellProblems(); + const hub = new RunHub(client, problems); + context.subscriptions.push(problems, hub, new CellFeatures(client), new LiveDiagnostics(client, problems)); + const cellStatus = new CellStatusBar(client); + context.subscriptions.push(cellStatus); + for (const type of NOTEBOOK_TYPES) { + context.subscriptions.push(vscode.notebooks.registerNotebookCellStatusBarItemProvider(type, cellStatus)); + context.subscriptions.push(vscode.workspace.registerNotebookSerializer(type, new FryNotebookSerializer(type === 'frysharp-jupyter' ? 'ipynb' : 'frynb', () => outputTabsEnabled()), { transientOutputs: false })); + context.subscriptions.push(new FryNotebookController(client, hub, type)); + } + + const variables = new VariablesTree(client, hub); + const statusBar = new EngineStatusBar(client, hub); + context.subscriptions.push(variables, statusBar, vscode.window.registerTreeDataProvider('frysharp.variables', variables)); + + // The engine starts when a notebook opens (or the first cell runs), not when VS Code does, and not in a folder that is not trusted. + const warm = (notebook?: vscode.NotebookDocument) => { + if (vscode.workspace.isTrusted && (notebook === undefined || isFryNotebook(notebook))) { + client.ensureStarted().catch((error) => log.error(String(error?.message ?? error))); + } + }; context.subscriptions.push( - vscode.commands.registerCommand('frysharp.restartSidecar', async () => { - await sidecar?.restart(); - vscode.window.showInformationMessage('FrySharp engine sidecar restarted successfully.'); + vscode.workspace.onDidOpenNotebookDocument(warm), + vscode.workspace.onDidGrantWorkspaceTrust(() => { + if (vscode.workspace.notebookDocuments.some(isFryNotebook)) warm(); + }), + // A closed notebook's kernels (and the Python and Node processes behind them) are stopped. + vscode.workspace.onDidCloseNotebookDocument((notebook) => { + if (isFryNotebook(notebook)) void client.closeNotebook({ notebookId: notebookIdentity(notebook).notebookId }); }) ); + vscode.workspace.notebookDocuments.forEach(warm); + + const register = (command: string, callback: (...args: any[]) => unknown) => context.subscriptions.push(vscode.commands.registerCommand(command, callback)); + + register('frysharp.newNotebook', async () => { + const initialData = new vscode.NotebookData([ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Markup, + '# Welcome to FrySharp Polyglot Notebook\nRun interactive C#, Python, JavaScript, and share variables across cells with `#!share`.', + 'markdown' + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '// C# .NET 10 Roslyn cell\nint a = 42;\nConsole.WriteLine($"Hello from FrySharp! a = {a}");', + 'csharp' + ) + ]); + initialData.metadata = { format: 'frynb', title: 'New Notebook', kernel: 'csharp' }; - function getTargetNotebook(arg?: any): { notebook: vscode.NotebookDocument; relativePath: string } | undefined { - let doc: vscode.NotebookDocument | undefined; - if (arg?.notebook) doc = arg.notebook; - else if (arg?.document) doc = arg.document; - else if (arg?.uri) { - doc = vscode.workspace.notebookDocuments.find(d => d.uri.toString() === arg.uri.toString()); - } else { - doc = vscode.window.activeNotebookEditor?.notebook; + const doc = await vscode.workspace.openNotebookDocument(NOTEBOOK_TYPES[0], initialData); + await vscode.window.showNotebookDocument(doc); + }); + + // Scripts: a terminal for each, problems in the Problems panel, and a Results panel for what is not text. + const results = new ResultsView(context.extensionUri); + const scripts = new ScriptRuns(client, results); + const runnable = () => (client.languages.length > 0 ? client.languages.filter((language) => language.canRun).map((language) => language.id) : DEFAULT_RUNNABLE_LANGUAGES); + const lenses = new RunCodeLens(scripts, runnable); + context.subscriptions.push(lenses, vscode.languages.registerCodeLensProvider({ scheme: 'file' }, lenses), vscode.languages.registerCodeLensProvider({ scheme: 'untitled' }, lenses), client.onDidInitialize(() => lenses.refresh())); + context.subscriptions.push(results, scripts, vscode.window.registerWebviewViewProvider(ResultsView.viewType, results, { webviewOptions: { retainContextWhenHidden: true } })); + + // The file a command is about: the one it is given (the Explorer's menu), else the one being edited. + const scriptOf = (arg?: unknown): vscode.Uri | undefined => { + if (arg instanceof vscode.Uri) return arg; + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.isUntitled) { + void vscode.window.showWarningMessage('Open a saved script to run it with FrySharp.'); + return undefined; } + return editor.document.uri; + }; + register('frysharp.runScript', async (arg?: unknown) => { + const file = scriptOf(arg); + if (file) await scripts.run(file); + }); + register('frysharp.runScriptWithArguments', async (arg?: unknown) => { + const file = scriptOf(arg); + if (!file) return; + const typed = await vscode.window.showInputBox({ + title: `Run ${path.basename(file.fsPath)} with arguments`, + prompt: 'Arguments, separated by spaces (use quotes around one that has a space)', + value: context.workspaceState.get(`frysharp.args:${file.fsPath}`, '') + }); + if (typed === undefined) return; + await context.workspaceState.update(`frysharp.args:${file.fsPath}`, typed); + await scripts.run(file, splitArguments(typed)); + }); + const scratchpad = new Scratchpad(); + context.subscriptions.push(scratchpad); + register('frysharp.newScratchpad', () => scratchpad.open()); + register('frysharp.sendToScratchpad', async () => { + const editor = vscode.window.activeTextEditor; + if (editor) await scratchpad.send(editor); + }); + register('frysharp.stopRun', () => scripts.stopActive()); + register('frysharp.rerun', () => scripts.rerunActive()); + register('frysharp.clearResults', () => results.clear()); - if (!doc) return undefined; - const filePath = doc.uri.fsPath; - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/'); - return { notebook: doc, relativePath }; - } + register('frysharp.restartSidecar', async () => { + await client.restart(); + vscode.window.showInformationMessage('FrySharp engine restarted: the variables of every notebook are reset.'); + }); - // Command: Restart Notebook Kernel - context.subscriptions.push( - vscode.commands.registerCommand('frysharp.restartNotebook', async (arg?: any) => { - const target = getTargetNotebook(arg); - if (!target) { - vscode.window.showWarningMessage('No active FrySharp notebook found to restart.'); - return; - } - try { - await sidecar?.restartNotebook(target.relativePath); - vscode.window.showInformationMessage(`✓ FrySharp Kernel Restarted: session state and variables reset for ${path.basename(target.relativePath)}.`); - } catch (err: any) { - vscode.window.showErrorMessage(`Restart failed: ${err.message}`); - } - }) - ); + register('frysharp.showLog', () => log.show()); - // Command: Interrupt Notebook Kernel - context.subscriptions.push( - vscode.commands.registerCommand('frysharp.interruptNotebook', async (arg?: any) => { - const target = getTargetNotebook(arg); - if (!target) { - vscode.window.showWarningMessage('No active FrySharp notebook found to interrupt.'); - return; - } - try { - await sidecar?.interruptNotebook(target.relativePath); - vscode.window.showInformationMessage(`⚠️ FrySharp Kernel Interrupted for ${path.basename(target.relativePath)}.`); - } catch (err: any) { - vscode.window.showErrorMessage(`Interrupt failed: ${err.message}`); - } - }) - ); + const targetNotebook = (arg?: any): vscode.NotebookDocument | undefined => { + if (arg?.notebook) return arg.notebook; + if (arg?.document) return arg.document; + if (arg?.uri) return vscode.workspace.notebookDocuments.find((d) => d.uri.toString() === arg.uri.toString()); + return vscode.window.activeNotebookEditor?.notebook; + }; - // Command: Show Polyglot Variables - context.subscriptions.push( - vscode.commands.registerCommand('frysharp.showVariables', async (arg?: any) => { - const target = getTargetNotebook(arg); - if (!target) { - vscode.window.showWarningMessage('No active FrySharp notebook found to inspect variables.'); - return; - } - try { - const vars = (await sidecar?.getVariables(target.relativePath)) || []; - showVariablesWebview(context, target.relativePath, vars, async () => { - return (await sidecar?.getVariables(target.relativePath)) || []; - }); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to fetch variables: ${err.message}`); - } - }) - ); + register('frysharp.restartNotebook', async (arg?: any) => { + const notebook = targetNotebook(arg); + if (!notebook) { + vscode.window.showWarningMessage('No active FrySharp notebook found to restart.'); + return; + } + try { + await client.restartNotebook({ notebookId: notebookIdentity(notebook).notebookId }); + vscode.window.showInformationMessage(`✓ FrySharp Kernel Restarted: session state and variables reset for ${path.basename(notebook.uri.path)}.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Restart failed: ${err.message}`); + } + }); - // Command: Clear All Outputs - context.subscriptions.push( - vscode.commands.registerCommand('frysharp.clearNotebookOutputs', async () => { - await vscode.commands.executeCommand('notebook.clearAllCellsOutputs'); - }) - ); + register('frysharp.interruptNotebook', async (arg?: any) => { + const notebook = targetNotebook(arg); + if (!notebook) { + vscode.window.showWarningMessage('No active FrySharp notebook found to interrupt.'); + return; + } + try { + await client.interruptNotebook({ notebookId: notebookIdentity(notebook).notebookId }); + vscode.window.showInformationMessage(`⚠️ FrySharp Kernel Interrupted for ${path.basename(notebook.uri.path)}.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Interrupt failed: ${err.message}`); + } + }); + + register('frysharp.showVariables', async (arg?: any) => { + const notebook = targetNotebook(arg); + if (!notebook) { + vscode.window.showWarningMessage('No active FrySharp notebook found to inspect variables.'); + return; + } + const identity = notebookIdentity(notebook); + try { + const fetch = () => client.getVariables({ notebookId: identity.notebookId }); + showVariablesWebview(context, identity.path, await fetch(), fetch); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch variables: ${err.message}`); + } + }); + + register('frysharp.refreshVariables', () => variables.refresh()); + register('frysharp.copyVariableValue', async (node?: unknown) => { + const variable = VariablesTree.variableOf(node); + if (variable) await vscode.env.clipboard.writeText(variable.value); + }); + + register('frysharp.engineMenu', async () => { + const notebook = vscode.window.activeNotebookEditor?.notebook; + const items: Array = [ + { label: '$(info) Show Engine Info', description: 'versions, and the tool each language found', command: 'frysharp.showEngineInfo' }, + { label: '$(output) Show Engine Log', command: 'frysharp.showLog' }, + { label: '$(debug-restart) Restart Kernel of This Notebook', description: notebook ? path.basename(notebook.uri.path) : undefined, command: 'frysharp.restartNotebook' }, + { label: '$(primitive-square) Interrupt This Notebook', command: 'frysharp.interruptNotebook' }, + { label: '$(symbol-variable) Show Variables', command: 'frysharp.showVariables' }, + { label: '$(refresh) Restart the Engine', description: 'every notebook loses its variables', command: 'frysharp.restartSidecar' } + ]; + const picked = await vscode.window.showQuickPick(items, { title: 'FrySharp' }); + if (picked) await vscode.commands.executeCommand(picked.command); + }); + + register('frysharp.showEngineInfo', async () => { + try { + const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'FrySharp: asking the engine what it found…' }, async () => { + await client.ensureStarted(); + return client.engineInfo(); + }); + const config = vscode.workspace.getConfiguration('frysharp'); + const setting = (name: string) => String(config.get(name) ?? ''); + const content = engineInfoMarkdown(info, { + extensionVersion: String(context.extension.packageJSON.version), + vscodeVersion: vscode.version, + launch: client.launch && { command: client.launch.command, description: client.launch.description }, + logFolder: context.logUri.fsPath, + settings: [ + ['frysharp.dotnetPath', setting('dotnetPath')], + ['frysharp.sidecarPath', setting('sidecarPath')], + ['frysharp.notebook.outputTabs', String(vscode.workspace.getConfiguration('frysharp.notebook').get('outputTabs', true))] + ] + }); + const document = await vscode.workspace.openTextDocument({ language: 'markdown', content }); + await vscode.window.showTextDocument(document, { preview: true }); + await vscode.commands.executeCommand('markdown.showPreviewToSide', document.uri); + } catch (error) { + void vscode.window.showErrorMessage(`FrySharp could not get the engine's info: ${error instanceof Error ? error.message : String(error)}`, 'Show Log').then((choice) => { + if (choice) log.show(); + }); + } + }); - context.subscriptions.push(sidecar); + register('frysharp.clearNotebookOutputs', () => vscode.commands.executeCommand('notebook.clearAllCellsOutputs')); + + return { rendererReports, variables, scripts, results, statusText: () => statusBar.text }; } export function deactivate() { sidecar?.dispose(); } + +// ─── Starting the engine: which .NET, which sidecar, in which folder ───────────────────────────────────────────────── + +async function prepareSidecar(context: vscode.ExtensionContext, log: vscode.LogOutputChannel): Promise { + const config = vscode.workspace.getConfiguration('frysharp'); + + let dotnet: string; + try { + dotnet = await resolveDotnet({ + setting: config.get('dotnetPath') || undefined, + acquire: () => acquireWithInstallTool(context), + log: (message) => log.info(message) + }); + } catch (error) { + if (error instanceof DotnetMissingError) offerDotnet(error.message); + throw error; + } + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? (await ensureFolder(context.globalStorageUri.fsPath)); + const launch = resolveSidecarLaunch( + { + setting: config.get('sidecarPath') || undefined, + extensionPath: context.extensionPath, + development: context.extensionMode !== vscode.ExtensionMode.Production, // running from the repository: Development, or the Extension Host tests + exists: fs.existsSync + }, + workspaceRoot + ); + log.info(`Using ${launch.description}.`); + return { command: dotnet, args: launch.args, cwd: workspaceRoot, description: launch.description }; +} + +async function ensureFolder(folder: string): Promise { + await fs.promises.mkdir(folder, { recursive: true }); + return folder; +} + +// The .NET Install Tool, when it is installed, downloads a runtime for us; it is not a dependency, so a machine without it still works. +async function acquireWithInstallTool(context: vscode.ExtensionContext): Promise { + if (!vscode.extensions.getExtension(DOTNET_INSTALL_TOOL)) return undefined; + const result = await vscode.commands.executeCommand<{ dotnetPath?: string }>('dotnet.acquire', { + version: `${REQUIRED_MAJOR}.0`, + requestingExtensionId: context.extension.id + }); + return result?.dotnetPath; +} + +function offerDotnet(message: string): void { + const installTool = 'Install .NET Install Tool'; + const download = 'Download .NET'; + const settings = 'Open Settings'; + const actions = vscode.extensions.getExtension(DOTNET_INSTALL_TOOL) ? [download, settings] : [installTool, download, settings]; + void vscode.window.showErrorMessage(message, ...actions).then((choice) => { + if (choice === installTool) void vscode.commands.executeCommand('workbench.extensions.installExtension', DOTNET_INSTALL_TOOL); + else if (choice === download) void vscode.env.openExternal(vscode.Uri.parse(`https://dotnet.microsoft.com/download/dotnet/${REQUIRED_MAJOR}.0`)); + else if (choice === settings) void vscode.commands.executeCommand('workbench.action.openSettings', 'frysharp.dotnetPath'); + }); +} + +/** `a "b c" d` is three arguments: a, b c, d. */ +export function splitArguments(text: string): string[] { + const found: string[] = []; + for (const match of text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) found.push(match[1] ?? match[2] ?? match[3]); + return found; +} diff --git a/vscode-frysharp/src/language/cellContext.ts b/vscode-frysharp/src/language/cellContext.ts new file mode 100644 index 00000000..09d35e91 --- /dev/null +++ b/vscode-frysharp/src/language/cellContext.ts @@ -0,0 +1,53 @@ +/** A cell as the language features see it. */ +export interface CellLike { + kind: 'code' | 'markdown'; + /** The language the editor says the cell is in (the notebook's, or the one chosen for the cell). */ + language: string; + text: string; +} + +/** Maps a name used in `#!name` (an id or alias: "python", "c#", "js") to a language id; undefined when it is none. */ +export type ResolveLanguage = (name: string) => string | undefined; + +/** + * The language a cell runs in: its own, unless its first lines (blank lines, comments and other directives may come before) + * pick another with `#!python`, `#!csharp`, as the engine reads them. `#!share` does not change the language. + */ +export function effectiveLanguage(text: string, declared: string, resolve: ResolveLanguage): string { + let language = declared; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line === '' || line.startsWith('//') || (line.startsWith('#') && !line.startsWith('#!'))) continue; + if (line.startsWith('#!') && !line.startsWith('#![') && !line.startsWith('#!/')) { + const name = /^#!\s*([^\s]+)/.exec(line)?.[1]; + if (name && name !== 'share') language = resolve(name) ?? language; + continue; + } + break; // the first line of code ends the directives + } + return language; +} + +/** + * The text of the code cells before `index` that run in `language`, in order: what they define is in scope for the cell. + * Markdown cells and cells in other languages are not part of it. + */ +export function contextFor(cells: readonly CellLike[], index: number, language: string, resolve: ResolveLanguage): string[] { + const context: string[] = []; + for (let i = 0; i < index && i < cells.length; i++) { + const cell = cells[i]; + if (cell.kind === 'code' && effectiveLanguage(cell.text, cell.language, resolve) === language) context.push(cell.text); + } + return context; +} + +/** Builds a language-name resolver from the engine's languages (an id, or one of its aliases, in any case). */ +export function resolverFor(languages: ReadonlyArray<{ id: string; aliases: string[]; shortName?: string }>): ResolveLanguage { + const byName = new Map(); + for (const language of languages) { + byName.set(language.id.toLowerCase(), language.id); + for (const alias of language.aliases) byName.set(alias.toLowerCase(), language.id); + if (language.shortName) byName.set(language.shortName.toLowerCase(), language.id); + } + return (name) => byName.get(name.toLowerCase()); +} diff --git a/vscode-frysharp/src/language/cellFeatures.ts b/vscode-frysharp/src/language/cellFeatures.ts new file mode 100644 index 00000000..383ecc2b --- /dev/null +++ b/vscode-frysharp/src/language/cellFeatures.ts @@ -0,0 +1,150 @@ +import * as vscode from 'vscode'; +import { contextFor, effectiveLanguage, resolverFor } from './cellContext'; +import { directiveCompletions, DirectiveItem } from './directives'; +import { isFryNotebook, NOTEBOOK_TYPES, notebookIdentity } from '../notebook/notebookController'; +import { CompletionItem as EngineCompletion } from '../sidecar/protocol'; +import { SidecarClient } from '../sidecar/sidecarClient'; + +/** The notebook a cell's document belongs to, and the cell. */ +export function cellOf(document: vscode.TextDocument): { notebook: vscode.NotebookDocument; cell: vscode.NotebookCell } | undefined { + if (document.uri.scheme !== 'vscode-notebook-cell') return undefined; + for (const notebook of vscode.workspace.notebookDocuments) { + if (!isFryNotebook(notebook)) continue; + const cell = notebook.getCells().find((candidate) => candidate.document.uri.toString() === document.uri.toString()); + if (cell) return { notebook, cell }; + } + return undefined; +} + +/** What the engine knows about the cell: the language it runs in and the text of the earlier cells in that language. */ +export function cellScope(client: SidecarClient, notebook: vscode.NotebookDocument, cell: vscode.NotebookCell): { language: string; context: string[] } { + const resolve = resolverFor(client.languages); + const cells = notebook.getCells().map((c) => ({ kind: c.kind === vscode.NotebookCellKind.Code ? ('code' as const) : ('markdown' as const), language: c.document.languageId, text: c.document.getText() })); + const language = effectiveLanguage(cell.document.getText(), cell.document.languageId, resolve); + return { language, context: contextFor(cells, cell.index, language, resolve) }; +} + +const KINDS: Record = { + Method: vscode.CompletionItemKind.Method, + ExtensionMethod: vscode.CompletionItemKind.Method, + Property: vscode.CompletionItemKind.Property, + Field: vscode.CompletionItemKind.Field, + Class: vscode.CompletionItemKind.Class, + Struct: vscode.CompletionItemKind.Struct, + Record: vscode.CompletionItemKind.Struct, + Interface: vscode.CompletionItemKind.Interface, + Enum: vscode.CompletionItemKind.Enum, + Keyword: vscode.CompletionItemKind.Keyword, + Snippet: vscode.CompletionItemKind.Snippet, + Variable: vscode.CompletionItemKind.Variable, + Namespace: vscode.CompletionItemKind.Module +}; + +/** An engine completion as an editor one: a snippet where the engine marked a caret place, the rest as plain text. */ +export function toCompletionItem(item: EngineCompletion): vscode.CompletionItem { + const result = new vscode.CompletionItem({ label: item.label, detail: item.detail ? ` ${item.detail}` : undefined }, KINDS[item.kind] ?? vscode.CompletionItemKind.Text); + result.insertText = item.kind === 'Snippet' ? new vscode.SnippetString(item.insertText) : item.insertText; + if (item.documentation) result.documentation = new vscode.MarkdownString(item.documentation); + result.sortText = item.sortText; + return result; +} + +function directiveKind(kind: DirectiveItem['kind']): vscode.CompletionItemKind { + return { directive: vscode.CompletionItemKind.Keyword, language: vscode.CompletionItemKind.EnumMember, variable: vscode.CompletionItemKind.Variable, option: vscode.CompletionItemKind.Property, snippet: vscode.CompletionItemKind.Snippet }[kind]; +} + +/** + * Completion and hover in the cells of a notebook. A language whose engine has them (C#, SQL) gets the engine's own, with + * what the earlier cells define in scope; any cell gets the notebook's directives (`#!python`, `#!share --from csharp name`, + * `%pip install`). Other languages are the business of the language extensions installed for them. + */ +export class CellFeatures implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + + constructor(private readonly client: SidecarClient) { + // Any language of our notebooks: the ones whose engine has intelligence answer, the rest are left to the editor's own + // (and to the language extensions installed for them). + const any = NOTEBOOK_TYPES.map((type) => ({ notebookType: type })); + this.disposables.push( + vscode.languages.registerCompletionItemProvider(any, this, '.'), + vscode.languages.registerCompletionItemProvider(any, new DirectiveCompletion(client), '!', ' ', '-', '%'), + vscode.languages.registerHoverProvider(any, this) + ); + } + + async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { + const found = cellOf(document); + if (!found || !vscode.workspace.isTrusted) return undefined; + const scope = cellScope(this.client, found.notebook, found.cell); + if (!this.hasIntelligence(scope.language)) return undefined; + + try { + const result = await this.client.completeCell({ language: scope.language, source: document.getText(), offset: document.offsetAt(position), context: scope.context }, token); + return result.items.map(toCompletionItem); + } catch { + return undefined; // no engine, or the request was cancelled: no completions, not an error in the editor + } + } + + async provideHover(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { + const found = cellOf(document); + if (!found || !vscode.workspace.isTrusted) return undefined; + const scope = cellScope(this.client, found.notebook, found.cell); + if (!this.hasIntelligence(scope.language)) return undefined; + + try { + const result = await this.client.hoverCell({ language: scope.language, source: document.getText(), offset: document.offsetAt(position), context: scope.context }, token); + return result && result.markdown.length > 0 ? new vscode.Hover(result.markdown.map((part) => new vscode.MarkdownString(part))) : undefined; + } catch { + return undefined; + } + } + + /** The engine has completion and hover for the language (it says so when it starts: before that, only C#, which it always had). */ + private hasIntelligence(language: string): boolean { + const known = this.client.languages.find((l) => l.id === language); + return known ? known.hasCompletion : language === 'csharp'; + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} + +/** The completions of a notebook's own directives, in a cell of any language. */ +class DirectiveCompletion implements vscode.CompletionItemProvider { + constructor(private readonly client: SidecarClient) {} + + async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise { + const found = cellOf(document); + if (!found) return undefined; + const linePrefix = document.lineAt(position.line).text.slice(0, position.character); + if (!/^\s*[#%!]/.test(linePrefix)) return undefined; // not a directive line: nothing to ask + + const scope = cellScope(this.client, found.notebook, found.cell); + const languages = this.client.languages; + const completions = await directiveCompletions(linePrefix, { + languages, + cellLanguage: scope.language, + variablesOf: async (languageId) => { + try { + const variables = await this.client.getVariables({ notebookId: notebookIdentity(found.notebook).notebookId }); + const language = languages.find((l) => l.id === languageId); + return variables.filter((v) => v.kernel === language?.displayName || v.kernel === language?.shortName || v.kernel === languageId).map((v) => v.name); + } catch { + return []; + } + } + }); + if (!completions) return undefined; + + const range = new vscode.Range(position.line, completions.from, position.line, position.character); + return completions.items.map((item) => { + const result = new vscode.CompletionItem(item.label, directiveKind(item.kind)); + result.detail = item.detail; + result.insertText = item.insertText ?? item.label; + result.range = range; + return result; + }); + } +} diff --git a/vscode-frysharp/src/language/cellProblems.ts b/vscode-frysharp/src/language/cellProblems.ts new file mode 100644 index 00000000..d0ac5042 --- /dev/null +++ b/vscode-frysharp/src/language/cellProblems.ts @@ -0,0 +1,46 @@ +import * as vscode from 'vscode'; + +/** + * The problems marked in a notebook cell's code, from two places: what the engine finds as the code is typed (live), and what + * it found when the cell last ran (run). One list is shown, with a problem both found shown once. + */ +export class CellProblems implements vscode.Disposable { + private readonly collection = vscode.languages.createDiagnosticCollection('frysharp'); + private readonly live = new Map(); + private readonly run = new Map(); + + setLive(uri: vscode.Uri, diagnostics: vscode.Diagnostic[]): void { + this.live.set(uri.toString(), diagnostics); + this.publish(uri); + } + + setRun(uri: vscode.Uri, diagnostics: vscode.Diagnostic[]): void { + this.run.set(uri.toString(), diagnostics); + this.publish(uri); + } + + clearRun(uri: vscode.Uri): void { + if (this.run.delete(uri.toString())) this.publish(uri); + } + + /** Forgets a cell altogether (it was closed). */ + forget(uri: vscode.Uri): void { + this.live.delete(uri.toString()); + this.run.delete(uri.toString()); + this.collection.delete(uri); + } + + private publish(uri: vscode.Uri): void { + const key = uri.toString(); + const merged = new Map(); + for (const diagnostic of [...(this.live.get(key) ?? []), ...(this.run.get(key) ?? [])]) { + const where = `${diagnostic.range.start.line}:${diagnostic.range.start.character}:${String(diagnostic.code)}`; + if (!merged.has(where)) merged.set(where, diagnostic); + } + this.collection.set(uri, [...merged.values()]); + } + + dispose(): void { + this.collection.dispose(); + } +} diff --git a/vscode-frysharp/src/language/directives.ts b/vscode-frysharp/src/language/directives.ts new file mode 100644 index 00000000..871392e0 --- /dev/null +++ b/vscode-frysharp/src/language/directives.ts @@ -0,0 +1,91 @@ +/** A language of the engine, as the directives name it. */ +export interface DirectiveLanguage { + id: string; + displayName: string; + shortName: string; + aliases: string[]; + /** Cells can be written in it (it has a notebook kernel). */ + canRunCells: boolean; +} + +export interface DirectiveItem { + label: string; + insertText?: string; + detail?: string; + kind: 'directive' | 'language' | 'variable' | 'option' | 'snippet'; +} + +export interface DirectiveContext { + languages: readonly DirectiveLanguage[]; + /** The language the cell is in. */ + cellLanguage: string; + /** The names of the variables a language's kernel holds. */ + variablesOf(languageId: string): Promise; +} + +export interface DirectiveCompletions { + items: DirectiveItem[]; + /** Where in the line the text being completed starts: the editor replaces from here to the caret. */ + from: number; +} + +/** + * What to offer after the text of a line up to the caret, when the line is a directive of the notebook: `#!` and the language + * names, `#!share` and its options, the languages after `--from`, the variables of that language, and the package lines + * (`%pip install`). Undefined when the line is not one. + */ +export async function directiveCompletions(linePrefix: string, context: DirectiveContext): Promise { + const indent = linePrefix.length - linePrefix.trimStart().length; + const text = linePrefix.slice(indent); + const runnable = context.languages.filter((language) => language.canRunCells); + + // "#!": the directive's name + const name = /^#!(?[^\s]*)$/.exec(text); + if (name) { + const items: DirectiveItem[] = [{ label: 'share', detail: 'Copy a variable from another language: #!share --from ', kind: 'directive' }]; + for (const language of runnable) { + for (const word of [language.id, ...language.aliases.filter((alias) => alias.toLowerCase() !== language.id)]) { + items.push({ label: word, detail: `Run this cell as ${language.displayName}`, kind: 'language' }); + } + } + return { items, from: indent + 2 }; + } + + const share = /^#!share\s+(?.*)$/.exec(text); + if (share) { + const rest = share.groups!.rest; + const argumentsStart = indent + text.length - rest.length; + const words = rest.split(/\s+/); + const typing = words[words.length - 1]; + const before = words.slice(0, -1); + const from = argumentsStart + rest.length - typing.length; + + if (before.length === 0) { + return { items: [{ label: '--from', detail: 'The language the variable comes from', kind: 'option' }], from }; + } + if (before[before.length - 1] === '--from') { + return { items: runnable.map((language) => ({ label: language.id, detail: language.displayName, kind: 'language' as const })), from }; + } + if (before[0] === '--from' && before.length === 2) { + const source = runnable.find((language) => language.id === before[1] || language.aliases.some((alias) => alias.toLowerCase() === before[1].toLowerCase())); + if (!source) return { items: [], from }; + const variables = await context.variablesOf(source.id); + return { items: variables.map((variable) => ({ label: variable, detail: `A variable of ${source.displayName}`, kind: 'variable' as const })), from }; + } + if (before.length >= 3 && !before.includes('--as')) { + return { items: [{ label: '--as', detail: 'Name it differently here: --as ', kind: 'option' }], from }; + } + return { items: [], from }; + } + + // package lines: "%pip install", "%npm install" + const magic = /^[%!](?[A-Za-z0-9]*)$/.exec(text); + if (magic) { + const items: DirectiveItem[] = []; + if (context.cellLanguage === 'python') items.push({ label: 'pip install', insertText: 'pip install ', detail: 'Install a Python package into the notebook\'s environment', kind: 'snippet' }); + if (context.cellLanguage === 'javascript') items.push({ label: 'npm install', insertText: 'npm install ', detail: 'Install an npm package', kind: 'snippet' }); + if (context.cellLanguage === 'csharp') items.push({ label: 'nuget', insertText: 'nuget ', detail: 'Reference a NuGet package', kind: 'snippet' }); + return items.length > 0 ? { items, from: indent + 1 } : undefined; + } + return undefined; +} diff --git a/vscode-frysharp/src/language/liveDiagnostics.ts b/vscode-frysharp/src/language/liveDiagnostics.ts new file mode 100644 index 00000000..0cf2ce37 --- /dev/null +++ b/vscode-frysharp/src/language/liveDiagnostics.ts @@ -0,0 +1,118 @@ +import * as vscode from 'vscode'; +import { CellProblems } from './cellProblems'; +import { cellOf, cellScope } from './cellFeatures'; +import { placeDiagnostics } from '../notebook/cellDiagnostics'; +import { isFryNotebook } from '../notebook/notebookController'; +import { SidecarClient } from '../sidecar/sidecarClient'; + +const DELAY_MS = 700; +const MAX_CELLS = 40; + +/** + * Marks the problems of a notebook's cells as they are typed: what the C# compiler finds with the earlier cells in scope, and + * a directive that is not one (`#!pyton`). It waits for a pause in typing, asks about the cell and the ones after it (they + * depend on it), skips a cell whose text and context have not changed, and drops an answer that a later keystroke made old. + */ +export class LiveDiagnostics implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + private readonly timers = new Map(); + private readonly running = new Map(); + /** What each cell was last checked with, so it is not checked again for nothing. */ + private readonly checked = new Map(); + + constructor( + private readonly client: SidecarClient, + private readonly problems: CellProblems + ) { + this.disposables.push( + vscode.workspace.onDidChangeTextDocument((event) => { + const found = cellOf(event.document); + if (found) this.schedule(found.notebook, found.cell.index); + }), + vscode.workspace.onDidChangeNotebookDocument((event) => { + if (!isFryNotebook(event.notebook) || event.contentChanges.length === 0) return; + this.schedule(event.notebook, Math.min(...event.contentChanges.map((change) => change.range.start))); + }), + vscode.workspace.onDidOpenNotebookDocument((notebook) => { + if (isFryNotebook(notebook)) this.schedule(notebook, 0); + }), + vscode.workspace.onDidCloseNotebookDocument((notebook) => { + for (const cell of notebook.getCells()) { + this.problems.forget(cell.document.uri); + this.checked.delete(cell.document.uri.toString()); + } + this.cancel(notebook.uri.toString()); + }), + client.onDidInitialize(() => vscode.workspace.notebookDocuments.filter(isFryNotebook).forEach((notebook) => this.schedule(notebook, 0))) + ); + vscode.workspace.notebookDocuments.filter(isFryNotebook).forEach((notebook) => this.schedule(notebook, 0)); + } + + private enabled(): boolean { + return vscode.workspace.isTrusted && vscode.workspace.getConfiguration('frysharp.notebook').get('liveDiagnostics', true); + } + + private schedule(notebook: vscode.NotebookDocument, from: number): void { + if (!this.enabled()) return; + const key = notebook.uri.toString(); + clearTimeout(this.timers.get(key)); + this.cancel(key); + this.timers.set(key, setTimeout(() => void this.check(notebook, from), DELAY_MS)); + } + + private cancel(key: string): void { + this.running.get(key)?.cancel(); + this.running.delete(key); + } + + private async check(notebook: vscode.NotebookDocument, from: number): Promise { + if (notebook.isClosed || this.client.state === 'crashed') return; + const key = notebook.uri.toString(); + const source = new vscode.CancellationTokenSource(); + this.running.set(key, source); + + try { + const cells = notebook.getCells().filter((cell) => cell.index >= from && cell.kind === vscode.NotebookCellKind.Code).slice(0, MAX_CELLS); + for (const cell of cells) { + if (source.token.isCancellationRequested) return; + const text = cell.document.getText(); + const scope = cellScope(this.client, notebook, cell); + // C# is compiled; other languages only have their directives read + if (scope.language !== 'csharp' && !/^\s*#!/m.test(text)) { + this.problems.setLive(cell.document.uri, []); + continue; + } + + const stamp = JSON.stringify([cell.document.languageId, scope.context, text]); + if (this.checked.get(cell.document.uri.toString()) === stamp) continue; + + const version = cell.document.version; + const result = await this.client.diagnoseCell({ language: cell.document.languageId, source: text, context: scope.context }, source.token); + if (source.token.isCancellationRequested || cell.document.version !== version) return; // the code changed while asking: the answer is for old code + + this.checked.set(cell.document.uri.toString(), stamp); + const placed = placeDiagnostics(result.diagnostics, text); + const severity = { error: vscode.DiagnosticSeverity.Error, warning: vscode.DiagnosticSeverity.Warning, information: vscode.DiagnosticSeverity.Information }; + this.problems.setLive( + cell.document.uri, + placed.map((p) => { + const diagnostic = new vscode.Diagnostic(new vscode.Range(p.startLine, p.startCharacter, p.endLine, p.endCharacter), p.message, severity[p.severity]); + diagnostic.code = p.code; + diagnostic.source = 'FrySharp'; + return diagnostic; + }) + ); + } + } catch { + // the engine is not there, or the question was withdrawn: nothing is marked, and nothing is said + } finally { + if (this.running.get(key) === source) this.running.delete(key); + } + } + + dispose(): void { + this.timers.forEach((timer) => clearTimeout(timer)); + this.running.forEach((source) => source.cancel()); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/vscode-frysharp/src/notebook/cellDiagnostics.ts b/vscode-frysharp/src/notebook/cellDiagnostics.ts new file mode 100644 index 00000000..bbd64e1c --- /dev/null +++ b/vscode-frysharp/src/notebook/cellDiagnostics.ts @@ -0,0 +1,42 @@ +import type { DiagnosticDto } from '../sidecar/protocol'; + +/** A diagnostic placed in a cell: 0-based, as an editor has it. */ +export interface PlacedDiagnostic { + startLine: number; + startCharacter: number; + endLine: number; + endCharacter: number; + severity: 'error' | 'warning' | 'information'; + code: string; + message: string; +} + +/** + * Where the engine's diagnostics go in a cell's text. The engine counts lines and columns from 1 and may name the end of a + * line as a very large column; the editor counts from 0 and has the line it has. A range always has something in it when + * the line does, so it can be seen. + */ +export function placeDiagnostics(diagnostics: DiagnosticDto[] | undefined, text: string): PlacedDiagnostic[] { + if (!diagnostics || diagnostics.length === 0) return []; + const lines = text.split(/\r\n|\r|\n/); + const clamp = (value: number, max: number) => Math.max(0, Math.min(value, max)); + + return diagnostics.map((diagnostic) => { + const startLine = clamp(diagnostic.startLine - 1, lines.length - 1); + const endLine = clamp(Math.max(diagnostic.endLine, diagnostic.startLine) - 1, lines.length - 1); + const startCharacter = clamp(diagnostic.startColumn - 1, lines[startLine].length); + let endCharacter = clamp(diagnostic.endColumn - 1, lines[endLine].length); + if (endLine === startLine && endCharacter <= startCharacter) { + endCharacter = Math.min(startCharacter + 1, lines[endLine].length); + } + return { + startLine, + startCharacter, + endLine, + endCharacter, + severity: diagnostic.severity === 'warning' ? 'warning' : diagnostic.severity === 'info' ? 'information' : 'error', + code: diagnostic.code, + message: diagnostic.message + }; + }); +} diff --git a/vscode-frysharp/src/notebook/cellStatus.ts b/vscode-frysharp/src/notebook/cellStatus.ts new file mode 100644 index 00000000..1accf825 --- /dev/null +++ b/vscode-frysharp/src/notebook/cellStatus.ts @@ -0,0 +1,27 @@ +import { effectiveLanguage, resolverFor } from '../language/cellContext'; + +export interface StatusLanguage { + id: string; + displayName: string; + aliases: string[]; + shortName?: string; + canRunCells: boolean; +} + +export interface CellStatusItem { + text: string; + tooltip: string; +} + +/** + * What the strip under a code cell says about the language it runs in, when that is not the one the editor shows: a first line of + * `#!python` runs the cell as Python whatever the cell is set to, which is easy to forget. Undefined when the cell runs as itself. + */ +export function languageNote(text: string, declared: string, languages: readonly StatusLanguage[]): CellStatusItem | undefined { + const effective = effectiveLanguage(text, declared, resolverFor(languages)); + if (effective === declared) return undefined; + const language = languages.find((l) => l.id === effective); + const name = language?.displayName ?? effective; + const directive = /^\s*#!\s*([^\s]+)/m.exec(text.split(/\r?\n/).find((line) => /^\s*#!\s*(?!share\b)[^\s[/]/.test(line)) ?? '')?.[1]; + return { text: `$(arrow-right) ${name}`, tooltip: `This cell runs as ${name}${directive ? `: its first line says #!${directive}` : ''}, not as ${languages.find((l) => l.id === declared)?.displayName ?? declared}.` }; +} diff --git a/vscode-frysharp/src/notebook/cellStatusBar.ts b/vscode-frysharp/src/notebook/cellStatusBar.ts new file mode 100644 index 00000000..8c3d825b --- /dev/null +++ b/vscode-frysharp/src/notebook/cellStatusBar.ts @@ -0,0 +1,27 @@ +import * as vscode from 'vscode'; +import type { SidecarClient } from '../sidecar/sidecarClient'; +import { languageNote } from './cellStatus'; + +/** The strip under a code cell: the language it runs as, when a `#!directive` makes that differ from the editor's. */ +export class CellStatusBar implements vscode.NotebookCellStatusBarItemProvider, vscode.Disposable { + private readonly changed = new vscode.EventEmitter(); + readonly onDidChangeCellStatusBarItems = this.changed.event; + private readonly disposables: vscode.Disposable[]; + + constructor(private readonly sidecar: SidecarClient) { + this.disposables = [this.changed, this.sidecar.onDidInitialize(() => this.changed.fire())]; + } + + provideCellStatusBarItems(cell: vscode.NotebookCell): vscode.NotebookCellStatusBarItem | undefined { + if (cell.kind !== vscode.NotebookCellKind.Code) return undefined; + const note = languageNote(cell.document.getText(), cell.document.languageId, this.sidecar.languages); + if (!note) return undefined; + const item = new vscode.NotebookCellStatusBarItem(note.text, vscode.NotebookCellStatusBarAlignment.Right); + item.tooltip = note.tooltip; + return item; + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/vscode-frysharp/src/notebook/format/frynb.ts b/vscode-frysharp/src/notebook/format/frynb.ts new file mode 100644 index 00000000..683c2adc --- /dev/null +++ b/vscode-frysharp/src/notebook/format/frynb.ts @@ -0,0 +1,189 @@ +// FrySharp's own notebook file (.frynb, and the older .csnb): the JSON the desktop writes from NotebookDocumentItem and +// NotebookCellItem (FrySharp.Core/Models). Reading keeps every field it does not own, and writing puts the fields in the +// desktop's order, so a notebook saved here and one saved by the desktop differ only by what changed. + +import { CellModel, newId, NotebookModel } from './model'; +import { fingerprint, outputsFromSlots, Slots, slotsFromOutputs } from './outputs'; + +type Obj = Record; + +const isObject = (value: unknown): value is Obj => typeof value === 'object' && value !== null && !Array.isArray(value); +const str = (value: unknown): string => (typeof value === 'string' ? value : ''); + +// What the desktop shows about a cell besides its outputs. Kept as read, and written back as read (the desktop wrote +// PascalCase, other writers camelCase, so a name matches whichever case it has). +const DESKTOP_FLAGS = ['IsExecuting', 'ExecutionTimeText', 'IsMarkdownPreviewMode', 'IsInputCollapsed', 'IsOutputCollapsed', 'IsOutputScrolled', 'HadInteractiveControl']; + +/** Looks a field up by name in any case, and remembers which ones were taken. */ +class Fields { + private readonly taken = new Set(); + private readonly byLower = new Map(); + + constructor(private readonly source: Obj) { + for (const key of Object.keys(source)) this.byLower.set(key.toLowerCase(), key); + } + + take(name: string): unknown { + const key = this.byLower.get(name.toLowerCase()); + if (key === undefined) return undefined; + this.taken.add(key); + return this.source[key]; + } + + rest(): Obj { + const rest: Obj = {}; + for (const key of Object.keys(this.source)) { + if (!this.taken.has(key)) rest[key] = this.source[key]; + } + return rest; + } +} + +export function parseFrynb(json: string): NotebookModel { + return frynbFromJson(JSON.parse(json)); +} + +export function frynbFromJson(parsed: unknown): NotebookModel { + if (!isObject(parsed)) throw new Error('A .frynb notebook is a JSON object.'); + + const root = new Fields(parsed); + const cells = root.take('Cells'); + const kernel = str(root.take('Kernel')) || 'csharp'; + + return { + format: 'frynb', + kernel, + root: root.rest(), + cells: (Array.isArray(cells) ? cells : []).filter(isObject).map((cell, index) => parseCell(cell, index)) + }; +} + +function parseCell(source: Obj, index: number): CellModel { + const fields = new Fields(source); + const type = fields.take('Type'); + const markdown = type === 1 || type === '1' || (typeof type === 'string' && type.toLowerCase() === 'markdown'); + + const sourceText = fields.take('Source'); + const text = Array.isArray(sourceText) ? sourceText.join('') : str(sourceText); // a Jupyter-style list of lines is also read + const slots: Slots = {}; + for (const name of [ + 'OutputText', + 'HasError', + 'ImageBytes', + 'ImageFormat', + 'ImageWidth', + 'ImageHeight', + 'HtmlContent', + 'MarkdownContent', + 'TableSnapshot', + 'InspectorSnapshot', + 'Visuals', + 'OutputBundles' + ] as const) { + (slots as Obj)[name] = fields.take(name); + } + const language = str(fields.take('Language')); + const count = fields.take('ExecutionCount'); + const id = str(fields.take('Id')) || `cell-${index + 1}`; + for (const derived of ['HasImage', 'HasHtml', 'HasOutput', 'IsCodeCell', 'IsMarkdownCell']) fields.take(derived); + + const extra: Obj = {}; + for (const flag of DESKTOP_FLAGS) { + const value = fields.take(flag); + if (value !== undefined) extra[flag] = value; + } + Object.assign(extra, fields.rest()); + + const outputs = markdown ? [] : outputsFromSlots(slots); + return { + id, + kind: markdown ? 'markdown' : 'code', + language: markdown || !language ? undefined : language, + source: text, + executionCount: typeof count === 'number' ? count : undefined, + failed: slots.HasError === true, + outputs, + state: { + extra, + snapshots: { + table: isObject(slots.TableSnapshot) ? slots.TableSnapshot : undefined, + inspector: isObject(slots.InspectorSnapshot) ? slots.InspectorSnapshot : undefined + }, + outputsKey: fingerprint(outputs) + } + }; +} + +/** The text of a .frynb for this notebook. `now` stamps LastModified, as the desktop's save does. */ +export function writeFrynb(notebook: NotebookModel, now: Date = new Date()): string { + const root = notebook.root; + const cells = notebook.cells.map((cell) => writeCell(cell, notebook.kernel)); + const executionCount = Math.max(typeof root.ExecutionCount === 'number' ? root.ExecutionCount : 0, ...notebook.cells.map((cell) => cell.executionCount ?? 0)); + + const written: Obj = { + SchemaVersion: root.SchemaVersion ?? '1.0', + Type: root.Type ?? 'notebook', + Id: root.Id ?? newId(), + Title: root.Title ?? 'Untitled Notebook', + Description: root.Description ?? '', + Category: root.Category ?? 'Interactive', + Kernel: notebook.kernel, + Cells: cells, + Created: root.Created ?? now.toISOString(), + LastModified: now.toISOString(), + ExecutionCount: executionCount + }; + for (const key of Object.keys(root)) { + if (!(key in written)) written[key] = root[key]; + } + return JSON.stringify(written, null, 2); +} + +function writeCell(cell: CellModel, kernel: string): Obj { + const markdown = cell.kind === 'markdown'; + const unchanged = fingerprint(cell.outputs) === cell.state.outputsKey; + const slots = markdown ? undefined : slotsFromOutputs(cell.outputs, cell.failed, unchanged ? cell.state.snapshots : {}); + const extra = cell.state.extra; + + const hasImage = !!slots?.ImageBytes; + const hasHtml = !!slots?.HtmlContent; + const hasVisuals = !!slots?.Visuals && slots.Visuals.length > 0; + // A live control cannot be saved, so the desktop only records that there was one; a cell that ran again has none. + const hadControl = unchanged && extra.HadInteractiveControl === true; + + // In the order the desktop writes them (NotebookCellItem's properties). + const written: Obj = { Id: cell.id, Type: markdown ? 1 : 0 }; + if (cell.language && cell.language !== kernel) written.Language = cell.language; + written.Source = cell.source; + written.OutputText = slots?.OutputText ?? ''; + written.ExecutionCount = cell.executionCount ?? null; + written.IsExecuting = extra.IsExecuting ?? false; + written.ExecutionTimeText = cell.executionMs !== undefined ? `${Math.round(cell.executionMs).toLocaleString('en-US')} ms` : extra.ExecutionTimeText ?? ''; + written.HasError = slots?.HasError ?? false; + written.IsMarkdownPreviewMode = extra.IsMarkdownPreviewMode ?? markdown; + written.IsInputCollapsed = extra.IsInputCollapsed ?? false; + written.IsOutputCollapsed = extra.IsOutputCollapsed ?? false; + written.IsOutputScrolled = extra.IsOutputScrolled ?? false; + written.ImageBytes = slots?.ImageBytes ?? null; + written.ImageFormat = slots?.ImageFormat ?? null; + written.ImageWidth = slots?.ImageWidth ?? null; + written.ImageHeight = slots?.ImageHeight ?? null; + written.HtmlContent = slots?.HtmlContent ?? null; + if (slots?.MarkdownContent) written.MarkdownContent = slots.MarkdownContent; + written.TableSnapshot = slots?.TableSnapshot ?? null; + written.InspectorSnapshot = slots?.InspectorSnapshot ?? null; + if (hasVisuals) written.Visuals = slots?.Visuals; + written.HadInteractiveControl = hadControl; + written.HasImage = hasImage; + written.HasHtml = hasHtml; + written.HasOutput = !!(slots?.OutputText || hasImage || hasHtml || slots?.TableSnapshot || slots?.InspectorSnapshot || hadControl || hasVisuals); + written.IsCodeCell = !markdown; + written.IsMarkdownCell = markdown; + + // Whatever else the cell had, and the full list of outputs when the fields above cannot say it. + for (const key of Object.keys(extra)) { + if (!(key in written)) written[key] = extra[key]; + } + if (slots?.OutputBundles) written.OutputBundles = slots.OutputBundles; + return written; +} diff --git a/vscode-frysharp/src/notebook/format/index.ts b/vscode-frysharp/src/notebook/format/index.ts new file mode 100644 index 00000000..ce7a745d --- /dev/null +++ b/vscode-frysharp/src/notebook/format/index.ts @@ -0,0 +1,17 @@ +import { frynbFromJson, writeFrynb } from './frynb'; +import { ipynbFromJson, writeIpynb } from './ipynb'; +import { NotebookModel } from './model'; + +export * from './model'; + +/** Reads the text of a notebook file in either format: a Jupyter notebook has "nbformat" and "cells", a .frynb has "Cells". */ +export function parseNotebook(json: string): NotebookModel { + const parsed: unknown = JSON.parse(json); + const jupyter = typeof parsed === 'object' && parsed !== null && 'nbformat' in parsed && 'cells' in parsed; + return jupyter ? ipynbFromJson(parsed, json) : frynbFromJson(parsed); +} + +/** The text of a notebook file, in the format the notebook was read in. */ +export function writeNotebook(notebook: NotebookModel, now: Date = new Date()): string { + return notebook.format === 'ipynb' ? writeIpynb(notebook) : writeFrynb(notebook, now); +} diff --git a/vscode-frysharp/src/notebook/format/ipynb.ts b/vscode-frysharp/src/notebook/format/ipynb.ts new file mode 100644 index 00000000..c07cb883 --- /dev/null +++ b/vscode-frysharp/src/notebook/format/ipynb.ts @@ -0,0 +1,169 @@ +// A Jupyter notebook (.ipynb, nbformat 4), as Jupyter, VS Code and GitHub read it. Cells keep the fields this extension +// has no use for (tags, attachments, widget state), and a cell's outputs are written back exactly as read until they +// change. + +import { CellModel, CellOutput, ERROR, fromBase64, isBinaryMime, NotebookModel, OutputItem, sortItems, STDERR, STDOUT, text, textItem, toBase64, utf8 } from './model'; +import { fingerprint } from './outputs'; + +type Obj = Record; + +const isObject = (value: unknown): value is Obj => typeof value === 'object' && value !== null && !Array.isArray(value); +const str = (value: unknown): string => (typeof value === 'string' ? value : ''); +const lines = (value: unknown): string => (Array.isArray(value) ? value.join('') : str(value)); + +const DEFAULT_METADATA = { + kernelspec: { display_name: '.NET (C#)', language: 'C#', name: '.net-csharp' }, + language_info: { name: 'csharp' } +}; + +// The names Jupyter and Polyglot Notebooks use for a language, as the ids VS Code and FrySharp use. +const LANGUAGE_IDS: Record = { 'c#': 'csharp', 'f#': 'fsharp', 'c++': 'cpp', node: 'javascript', js: 'javascript', py: 'python' }; + +export function languageId(name: string): string { + const lower = name.toLowerCase(); + return LANGUAGE_IDS[lower] ?? lower; +} + +export function parseIpynb(json: string): NotebookModel { + return ipynbFromJson(JSON.parse(json), json); +} + +export function ipynbFromJson(parsed: unknown, json: string): NotebookModel { + if (!isObject(parsed) || !Array.isArray(parsed.cells)) throw new Error('A Jupyter notebook has a "cells" list.'); + + const metadata = isObject(parsed.metadata) ? parsed.metadata : {}; + const info = isObject(metadata.language_info) ? metadata.language_info : {}; + const kernel = languageId(str(info.name) || 'csharp'); + + const root: Obj = {}; + for (const key of Object.keys(parsed)) { + if (key !== 'cells') root[key] = parsed[key]; + } + const indent = /^\n?([ \t]+)"/m.exec(json.slice(0, 400))?.[1]; + + return { format: 'ipynb', kernel, root, indent, cells: parsed.cells.filter(isObject).map((cell, index) => parseCell(cell, index, kernel)) }; +} + +function parseCell(cell: Obj, index: number, kernel: string): CellModel { + const markdown = cell.cell_type !== 'code'; + const metadata = isObject(cell.metadata) ? cell.metadata : {}; + const tagged = isObject(metadata.polyglot_notebook) ? str(metadata.polyglot_notebook.kernelName) : isObject(metadata.dotnet_interactive) ? str(metadata.dotnet_interactive.language) : ''; + const language = markdown || !tagged ? undefined : languageId(tagged); + + const extra: Obj = {}; + for (const key of Object.keys(cell)) { + if (!['cell_type', 'id', 'source', 'outputs', 'execution_count'].includes(key)) extra[key] = cell[key]; + } + if (cell.cell_type === 'raw') extra.cell_type = 'raw'; // VS Code has code and markup cells; a raw one is written back as raw + + const rawOutputs = !markdown && Array.isArray(cell.outputs) ? cell.outputs : []; + const outputs = rawOutputs.filter(isObject).map(outputFromJupyter); + return { + id: str(cell.id) || `cell-${index + 1}`, + kind: markdown ? 'markdown' : 'code', + language: language === kernel ? undefined : language, + source: lines(cell.source), + executionCount: typeof cell.execution_count === 'number' ? cell.execution_count : undefined, + failed: rawOutputs.some((output) => isObject(output) && output.output_type === 'error'), + outputs, + state: { extra, snapshots: {}, outputsKey: fingerprint(outputs), rawOutputs } + }; +} + +export function writeIpynb(notebook: NotebookModel): string { + const cells = notebook.cells.map((cell) => writeCell(cell, notebook.kernel)); + const written: Obj = { cells }; + for (const key of Object.keys(notebook.root)) written[key] = notebook.root[key]; + written.metadata ??= DEFAULT_METADATA; + written.nbformat ??= 4; + written.nbformat_minor ??= 5; + return JSON.stringify(written, null, notebook.indent ?? 1) + '\n'; +} + +function writeCell(cell: CellModel, kernel: string): Obj { + const extra = cell.state.extra; + const metadata: Obj = isObject(extra.metadata) ? { ...extra.metadata } : {}; + const source = splitLines(cell.source); + + if (cell.kind === 'markdown') { + return { ...passthrough(extra), cell_type: extra.cell_type === 'raw' ? 'raw' : 'markdown', id: cell.id, metadata, source }; + } + + if (cell.language && cell.language !== kernel) { + metadata.polyglot_notebook = { ...(isObject(metadata.polyglot_notebook) ? metadata.polyglot_notebook : {}), kernelName: cell.language }; + } + const unchanged = cell.state.rawOutputs !== undefined && fingerprint(cell.outputs) === cell.state.outputsKey; + return { + ...passthrough(extra), + cell_type: 'code', + execution_count: cell.executionCount ?? null, + id: cell.id, + metadata, + outputs: unchanged ? cell.state.rawOutputs : cell.outputs.map(outputToJupyter), + source + }; +} + +function passthrough(extra: Obj): Obj { + const rest: Obj = {}; + for (const key of Object.keys(extra)) { + if (key !== 'metadata' && key !== 'cell_type') rest[key] = extra[key]; + } + return rest; +} + +// Jupyter's list of lines: each keeps its newline, and empty text is an empty list. +function splitLines(value: string): string[] { + return value.match(/[^\n]*\n|[^\n]+$/g) ?? []; +} + +// ─────────────────────────────────────────────────────────────────────────────────────────── outputs + +function outputFromJupyter(output: Obj): CellOutput { + switch (output.output_type) { + case 'stream': + return { items: [textItem(output.name === 'stderr' ? STDERR : STDOUT, lines(output.text))] }; + case 'error': { + const traceback = Array.isArray(output.traceback) ? output.traceback.join('\n') : str(output.traceback); + return { items: [textItem(ERROR, JSON.stringify({ name: str(output.ename) || 'Error', message: str(output.evalue), stack: traceback }))] }; + } + default: { + const data = isObject(output.data) ? output.data : {}; + const items: OutputItem[] = Object.entries(data).map(([mime, value]) => itemFromJupyter(mime, value)); + const sorted = sortItems(items); + return isObject(output.metadata) && Object.keys(output.metadata).length > 0 ? { items: sorted, metadata: output.metadata } : { items: sorted }; + } + } +} + +function itemFromJupyter(mime: string, value: unknown): OutputItem { + if (isBinaryMime(mime)) return { mime, data: fromBase64(lines(value).replace(/\s+/g, '')) }; + return { mime, data: utf8(typeof value === 'string' || Array.isArray(value) ? lines(value) : JSON.stringify(value)) }; +} + +function outputToJupyter(output: CellOutput): Obj { + const [first] = output.items; + if (output.items.length === 1 && (first.mime === STDOUT || first.mime === STDERR)) { + return { output_type: 'stream', name: first.mime === STDERR ? 'stderr' : 'stdout', text: splitLines(text(first)) }; + } + + const error = output.items.find((item) => item.mime === ERROR); + if (error) { + const parsed = safeParse(text(error)) as { name?: string; message?: string; stack?: string }; + return { output_type: 'error', ename: parsed.name ?? 'Error', evalue: parsed.message ?? '', traceback: (parsed.stack ?? '').split('\n') }; + } + + const data: Obj = {}; + for (const item of output.items) { + data[item.mime] = isBinaryMime(item.mime) ? toBase64(item.data) : item.mime.endsWith('json') ? safeParse(text(item), text(item)) : splitLines(text(item)); + } + return { output_type: 'display_data', data, metadata: output.metadata ?? {} }; +} + +function safeParse(value: string, fallback: unknown = {}): unknown { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} diff --git a/vscode-frysharp/src/notebook/format/model.ts b/vscode-frysharp/src/notebook/format/model.ts new file mode 100644 index 00000000..da66ece4 --- /dev/null +++ b/vscode-frysharp/src/notebook/format/model.ts @@ -0,0 +1,116 @@ +// The notebook as the extension holds it between a file and VS Code: plain data, so everything in this folder runs (and is +// tested) without VS Code. src/notebook/notebookSerializer.ts is the one place that turns it into vscode types. + +export const STDOUT = 'application/vnd.code.notebook.stdout'; +export const STDERR = 'application/vnd.code.notebook.stderr'; +export const ERROR = 'application/vnd.code.notebook.error'; +export const TABLE = 'application/vnd.fry.table+json'; +export const INSPECTOR = 'application/vnd.fry.inspector+json'; + +/** The metadata of the error output the extension adds when a cell fails: the engine's text is in the cell's output already, and the file does not keep this one's. */ +export const ENGINE_ERROR = 'fryEngineError'; + +/** The chart, 3D plot and visualizer specs (any major version: a newer one is kept as it is, not drawn). */ +export function isVisualMime(mime: string): boolean { + return /^application\/vnd\.fry\.(chart|plot3d|visualizer)\.v\d+\+json$/.test(mime); +} + +export interface OutputItem { + mime: string; + data: Uint8Array; +} + +/** One output of a cell, as VS Code has it: items that are the same thing in different formats, richest first. */ +export interface CellOutput { + items: OutputItem[]; + metadata?: Record; +} + +export type CellKind = 'code' | 'markdown'; + +/** What the file holds about a cell that the model does not own, and what is needed to write it back unchanged. */ +export interface CellState { + /** Fields of the cell's JSON that nothing here interprets (the desktop's collapse flags, anything newer), written back as they were. */ + extra: Record; + /** The desktop's own copies of a table and an inspector, which hold more than their MIME form does. Reused while the outputs are untouched. */ + snapshots: { table?: unknown; inspector?: unknown }; + /** Identifies the outputs as they were read, to tell later whether they changed. */ + outputsKey: string; + /** A Jupyter cell's outputs as they were read, written back while the outputs are untouched. */ + rawOutputs?: unknown[]; +} + +export interface CellModel { + id: string; + kind: CellKind; + /** The cell's language when it is not the notebook's; markdown cells have none. */ + language?: string; + source: string; + executionCount?: number; + /** The cell's last run failed. */ + failed: boolean; + outputs: CellOutput[]; + /** How long the last run in this session took, when there was one. */ + executionMs?: number; + state: CellState; +} + +export interface NotebookModel { + format: 'frynb' | 'ipynb'; + /** The notebook's default language id. */ + kernel: string; + /** The notebook's fields other than its cells and kernel (title, id, dates, or a Jupyter notebook's metadata), kept for writing back. */ + root: Record; + cells: CellModel[]; + /** How the file was indented, so a save does not change every line of it. */ + indent?: string; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8'); + +export function utf8(text: string): Uint8Array { + return encoder.encode(text); +} + +export function text(item: OutputItem): string { + return decoder.decode(item.data); +} + +export function textItem(mime: string, value: string): OutputItem { + return { mime, data: utf8(value) }; +} + +export function jsonItem(mime: string, value: unknown): OutputItem { + return { mime, data: utf8(JSON.stringify(value)) }; +} + +export function fromBase64(value: string): Uint8Array { + return Uint8Array.from(Buffer.from(value, 'base64')); +} + +export function toBase64(data: Uint8Array): string { + return Buffer.from(data).toString('base64'); +} + +/** True for an item whose bytes are not text (a PNG); SVG, JSON and the rest are text. */ +export function isBinaryMime(mime: string): boolean { + return mime.startsWith('image/') && !mime.endsWith('+xml'); +} + +export function newId(): string { + return crypto.randomUUID().replace(/-/g, ''); +} + +// The items of one output, richest first: the page, this extension's own types, SVG, images, markdown, the rest, the text. +const ORDER = ['text/html', 'application/vnd.fry.', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'text/markdown']; + +function rank(mime: string): number { + const index = ORDER.findIndex((entry) => (entry.endsWith('.') ? mime.startsWith(entry) : mime === entry)); + if (index >= 0) return index; + return mime === 'text/plain' ? ORDER.length + 1 : ORDER.length; +} + +export function sortItems(items: OutputItem[]): OutputItem[] { + return [...items].sort((a, b) => rank(a.mime) - rank(b.mime)); +} diff --git a/vscode-frysharp/src/notebook/format/outputs.ts b/vscode-frysharp/src/notebook/format/outputs.ts new file mode 100644 index 00000000..96c2154a --- /dev/null +++ b/vscode-frysharp/src/notebook/format/outputs.ts @@ -0,0 +1,452 @@ +// A cell's outputs in the two forms they take: the desktop's fields in a .frynb (OutputText, ImageBytes, HtmlContent, +// TableSnapshot, InspectorSnapshot, Visuals...: one slot for each kind of output) and VS Code's list of outputs made of +// MIME items. Reading turns the first into the second; writing turns it back, and when a cell has more than the slots +// can say (two tables, text between two charts) it also saves the list itself as OutputBundles, which the desktop +// ignores and this extension reads in preference. + +import { createHash } from 'node:crypto'; +import { + CellOutput, + ENGINE_ERROR, + ERROR, + fromBase64, + INSPECTOR, + isBinaryMime, + isVisualMime, + jsonItem, + OutputItem, + sortItems, + STDERR, + STDOUT, + TABLE, + text, + textItem, + toBase64, + utf8 +} from './model'; + +type Obj = Record; + +/** The fields of a cell that hold its outputs, as a .frynb has them. */ +export interface Slots { + OutputText?: unknown; + HasError?: unknown; + ImageBytes?: unknown; + ImageFormat?: unknown; + ImageWidth?: unknown; + ImageHeight?: unknown; + HtmlContent?: unknown; + MarkdownContent?: unknown; + TableSnapshot?: unknown; + InspectorSnapshot?: unknown; + Visuals?: unknown; + OutputBundles?: unknown; +} + +export interface WrittenSlots { + OutputText: string; + HasError: boolean; + ImageBytes: string | null; + ImageFormat: string | null; + ImageWidth: number | null; + ImageHeight: number | null; + HtmlContent: string | null; + MarkdownContent: string | null; + TableSnapshot: unknown; + InspectorSnapshot: unknown; + Visuals: unknown[] | null; + OutputBundles: Bundle[] | null; +} + +type RichKind = 'image' | 'table' | 'inspector' | 'visual' | 'html'; + +const isObject = (value: unknown): value is Obj => typeof value === 'object' && value !== null && !Array.isArray(value); +const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); +const str = (value: unknown): string => (typeof value === 'string' ? value : ''); +const num = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined); + +// ─────────────────────────────────────────────────────────────────────────────────────────── slots to outputs + +export function outputsFromSlots(slots: Slots): CellOutput[] { + if (Array.isArray(slots.OutputBundles)) { + return outputsFromBundles(slots.OutputBundles); + } + + const outputs: CellOutput[] = []; + const stream = str(slots.OutputText); + if (stream) { + outputs.push({ items: [textItem(slots.HasError === true ? STDERR : STDOUT, stream)] }); + } + + const rich: Array<{ output: CellOutput; kind: RichKind }> = []; + + const image = typeof slots.ImageBytes === 'string' ? fromBase64(slots.ImageBytes) : new Uint8Array(); + if (image.length > 0) { + const mime = imageMime(slots.ImageFormat); + const output: CellOutput = { items: [{ mime, data: image }, textItem('text/plain', '')] }; + const width = num(slots.ImageWidth); + const height = num(slots.ImageHeight); + if (width !== undefined && height !== undefined) { + output.metadata = { [mime]: { width, height } }; + } + rich.push({ output, kind: 'image' }); + } + + if (isObject(slots.TableSnapshot)) { + const table = tableToMime(slots.TableSnapshot); + rich.push({ output: { items: [jsonItem(TABLE, table), textItem('text/plain', table.title)] }, kind: 'table' }); + } + + if (isObject(slots.InspectorSnapshot)) { + const node = inspectorToMime(slots.InspectorSnapshot); + rich.push({ output: { items: [jsonItem(INSPECTOR, node), textItem('text/plain', inspectorText(node))] }, kind: 'inspector' }); + } + + for (const visual of array(slots.Visuals)) { + if (!isObject(visual)) continue; + const mime = str(visual.MimeType); + if (isObject(visual.Data) && isVisualMime(mime)) { + rich.push({ output: { items: [jsonItem(mime, visual.Data), textItem('text/plain', `<${visualFamily(mime)}>`)] }, kind: 'visual' }); + } else { + const title = str(visual.Title) || visualFamily(mime); + rich.push({ + output: { items: [textItem('text/plain', `${title}: too large to save with the notebook; run the cell again to redraw it.`)] }, + kind: 'visual' + }); + } + } + + const html = str(slots.HtmlContent); + const markdown = str(slots.MarkdownContent); + if (html || markdown) { + const last = rich[rich.length - 1]; + if (!markdown && last && isPageFor(last.kind, html)) { + last.output.items.unshift(textItem('text/html', html)); + } else { + const items: OutputItem[] = []; + if (markdown) items.push(textItem('text/markdown', markdown)); + if (html) items.push(textItem('text/html', html)); + rich.push({ output: { items }, kind: 'html' }); + } + } + + outputs.push(...rich.map((r) => r.output)); + return outputs; +} + +// The desktop saves the page it drew a table, chart or image with next to the data, as one HtmlContent for the last +// of them. It belongs to the output it was drawn for; one that is not that output's page is an output of its own. +function isPageFor(kind: RichKind, html: string): boolean { + switch (kind) { + case 'visual': + return html.includes(' output.metadata?.[ENGINE_ERROR] !== true); + const slots: WrittenSlots = { + OutputText: '', + HasError: failed, + ImageBytes: null, + ImageFormat: null, + ImageWidth: null, + ImageHeight: null, + HtmlContent: null, + MarkdownContent: null, + TableSnapshot: null, + InspectorSnapshot: null, + Visuals: null, + OutputBundles: null + }; + + for (const output of outputs) { + const rich = output.items.some((item) => item.mime !== 'text/plain' && item.mime !== STDOUT && item.mime !== STDERR && item.mime !== ERROR); + for (const item of output.items) { + const mime = item.mime; + if (mime === STDOUT || mime === STDERR) { + slots.OutputText += text(item); + } else if (mime === ERROR) { + slots.OutputText += errorText(item); + slots.HasError = true; + } else if (mime === 'text/plain') { + if (!rich) slots.OutputText += text(item); + } else if (mime === 'text/html') { + slots.HtmlContent = text(item); + } else if (mime === 'text/markdown') { + slots.MarkdownContent = text(item); + } else if (mime === 'image/svg+xml') { + slots.HtmlContent = `${text(item)}`; + } else if (isBinaryMime(mime)) { + slots.ImageBytes = toBase64(item.data); + slots.ImageFormat = imageFormat(mime); + const size = output.metadata?.[mime]; + slots.ImageWidth = isObject(size) ? num(size.width) ?? null : null; + slots.ImageHeight = isObject(size) ? num(size.height) ?? null : null; + } else if (mime === TABLE) { + slots.TableSnapshot = reuse.table ?? tableFromMime(parse(item)); + } else if (mime === INSPECTOR) { + slots.InspectorSnapshot = reuse.inspector ?? inspectorFromMime(parse(item)); + } else if (isVisualMime(mime)) { + const data = parse(item); + if (data !== undefined) { + (slots.Visuals ??= []).push({ MimeType: mime, Data: data, Title: isObject(data) && typeof data.title === 'string' ? data.title : null }); + } + } + } + } + + // More than the fields can say: the list itself, too. + if (fingerprint(outputsFromSlots({ ...slots, OutputBundles: undefined }), true) !== fingerprint(outputs, true)) { + slots.OutputBundles = bundlesFromOutputs(outputs); + } + return slots; +} + +function errorText(item: OutputItem): string { + try { + const error = JSON.parse(text(item)) as { name?: string; message?: string }; + return `${error.name ?? 'Error'}: ${error.message ?? ''}\n`; + } catch { + return text(item); + } +} + +function parse(item: OutputItem): unknown { + try { + return JSON.parse(text(item)); + } catch { + return undefined; + } +} + +// ─────────────────────────────────────────────────────────────────────────────────────────── table + +// The table of the studio's MIME type: {title, columns, numeric, rows, totalRows, totalColumns}. +function tableToMime(snapshot: Obj): { title: string; columns: string[]; numeric: boolean[]; rows: Array>; totalRows: number; totalColumns: number; label?: string } { + const columns = array(snapshot.Columns).filter(isObject); + const rows = array(snapshot.Rows).filter(isObject); + const label = typeof snapshot.Label === 'string' ? snapshot.Label : undefined; + // "showing the first 1,000 of 5,000 rows": the label says how many there are in all + const total = label ? /of ([\d,]+) rows/.exec(label) : null; + const table: ReturnType = { + title: str(snapshot.Title) || 'Table', + columns: columns.map((column) => str(column.Header)), + numeric: columns.map((column) => column.IsNumeric === true), + rows: rows.map((row) => array(row.Cells).map((cell) => (isObject(cell) && cell.IsNull !== true ? str(cell.DisplayText) : null))), + totalRows: total ? parseInt(total[1].replace(/,/g, ''), 10) : rows.length, + totalColumns: columns.length + }; + if (label && !total) table.label = label; + return table; +} + +function tableFromMime(table: unknown): Obj | null { + if (!isObject(table)) return null; + const columns = array(table.columns); + const numeric = array(table.numeric); + const rows = array(table.rows); + const totalRows = num(table.totalRows) ?? rows.length; + const label = + typeof table.label === 'string' + ? table.label + : totalRows > rows.length + ? `showing the first ${rows.length.toLocaleString('en-US')} of ${totalRows.toLocaleString('en-US')} rows` + : null; + return { + Title: str(table.title), + Label: label, + Columns: columns.map((header, index) => ({ Header: str(header), IsNumeric: numeric[index] === true })), + Rows: rows.map((row) => ({ + Cells: array(row).map((value) => + value === null || value === undefined + ? { DisplayText: '', IsNumeric: false, IsBoolean: false, IsNull: true } + : { DisplayText: String(value), IsNumeric: typeof value === 'number', IsBoolean: typeof value === 'boolean', IsNull: false } + ) + })) + }; +} + +// ─────────────────────────────────────────────────────────────────────────────────────────── inspector + +// The Object Explorer of the studio's MIME type: {title, circular?, properties: [{name, value, isNull, isString, child}]}. +interface InspectorNodeMime { + title: string; + circular?: boolean; + properties: Array<{ name: string; value: string; isNull: boolean; isString: boolean; child: InspectorNodeMime | null }>; +} + +function inspectorToMime(snapshot: Obj): InspectorNodeMime { + const node: InspectorNodeMime = { + title: str(snapshot.HeaderTitle), + properties: array(snapshot.Properties) + .filter(isObject) + .map((property) => ({ + name: str(property.Name), + value: str(property.SimpleValueText), + isNull: property.IsNull === true, + isString: false, + child: isObject(property.ChildNode) ? inspectorToMime(property.ChildNode) : null + })) + }; + if (snapshot.IsCircularReference === true) node.circular = true; + return node; +} + +function inspectorFromMime(node: unknown): Obj | null { + if (!isObject(node)) return null; + return { + HeaderTitle: str(node.title), + IsExpandable: true, + IsCircularReference: node.circular === true, + Properties: array(node.properties) + .filter(isObject) + .map((property) => ({ + Name: str(property.name), + SimpleValueText: property.isNull === true ? '' : str(property.value), + IsNull: property.isNull === true, + IsComplexChild: isObject(property.child), + ChildNode: inspectorFromMime(property.child) + })) + }; +} + +function inspectorText(node: InspectorNodeMime): string { + return `${node.title}\n${node.properties.map((p) => ` ${p.name} = ${p.value}`).join('\n')}`; +} + +// ─────────────────────────────────────────────────────────────────────────────────────────── bundles + +/** The list of outputs itself, as a .frynb saves it when its fields cannot say it all: an item is text, or base64 for an image. */ +export interface Bundle { + Items: Array<{ Mime: string; Text?: string; Base64?: string }>; + Metadata?: Record; +} + +export function bundlesFromOutputs(outputs: CellOutput[]): Bundle[] { + return outputs.map((output) => { + const bundle: Bundle = { + Items: output.items.map((item) => { + const asText = isBinaryMime(item.mime) ? undefined : text(item); + return asText !== undefined && Buffer.compare(Buffer.from(utf8(asText)), Buffer.from(item.data)) === 0 + ? { Mime: item.mime, Text: asText } + : { Mime: item.mime, Base64: toBase64(item.data) }; + }) + }; + if (output.metadata && Object.keys(output.metadata).length > 0) bundle.Metadata = output.metadata; + return bundle; + }); +} + +export function outputsFromBundles(bundles: unknown[]): CellOutput[] { + const outputs: CellOutput[] = []; + for (const bundle of bundles) { + if (!isObject(bundle)) continue; + const items: OutputItem[] = []; + for (const item of array(bundle.Items)) { + if (!isObject(item) || typeof item.Mime !== 'string') continue; + if (typeof item.Text === 'string') items.push(textItem(item.Mime, item.Text)); + else if (typeof item.Base64 === 'string') items.push({ mime: item.Mime, data: fromBase64(item.Base64) }); + } + if (items.length === 0) continue; + outputs.push(isObject(bundle.Metadata) ? { items, metadata: bundle.Metadata } : { items }); + } + return outputs; +} + +// ─────────────────────────────────────────────────────────────────────────────────────────── comparing outputs + +/** + * Identifies outputs by what they say. With `forSlots` it ignores what the fields cannot keep: which of stdout and + * stderr a line was, a text fallback next to something richer, and the order of an output's items. + */ +export function fingerprint(outputs: CellOutput[], forSlots = false): string { + const hash = createHash('sha1'); + for (const output of outputs) { + const items = output.items + .filter((item) => !(forSlots && item.mime === 'text/plain' && output.items.some((other) => other.mime !== 'text/plain'))) + .map((item) => ({ mime: forSlots && item.mime === STDERR ? STDOUT : item.mime, body: canonical(item, forSlots) })); + if (forSlots) items.sort((a, b) => (a.mime + a.body).localeCompare(b.mime + b.body)); + hash.update(JSON.stringify(items)); + hash.update('\u0000'); + } + return hash.digest('hex'); +} + +function canonical(item: OutputItem, forSlots: boolean): string { + if (isBinaryMime(item.mime)) return createHash('sha1').update(item.data).digest('hex'); + const value = text(item); + if (item.mime.endsWith('json')) { + try { + // the desktop's inspector snapshot has no "isString" (it only colours a value), so it is not a reason to save the list + return JSON.stringify(sortKeys(JSON.parse(value), forSlots && item.mime === INSPECTOR ? 'isString' : undefined)); + } catch { + return value; + } + } + return value; +} + +function sortKeys(value: unknown, without?: string): unknown { + if (Array.isArray(value)) return value.map((item) => sortKeys(item, without)); + if (isObject(value)) { + return Object.fromEntries( + Object.keys(value) + .filter((key) => key !== without) + .sort() + .map((key) => [key, sortKeys(value[key], without)]) + ); + } + return value; +} + +// ─────────────────────────────────────────────────────────────────────────────────────────── from the engine + +/** + * An output as the sidecar sends it while a cell runs: a MIME bundle (RichOutputMime), with an image as base64 and JSON + * as an object. The engine already put a page and a text fallback in every bundle, so nothing is drawn here. + */ +export function outputFromDisplay(data: Record, metadata?: Record): CellOutput { + const items: OutputItem[] = Object.entries(data).map(([mime, value]) => { + if (isBinaryMime(mime) && typeof value === 'string') return { mime, data: fromBase64(value) }; + return { mime, data: utf8(typeof value === 'string' ? value : JSON.stringify(value)) }; + }); + const output: CellOutput = { items: sortItems(items) }; + if (metadata && Object.keys(metadata).length > 0) output.metadata = metadata; + return output; +} diff --git a/vscode-frysharp/src/notebook/format/tabs.ts b/vscode-frysharp/src/notebook/format/tabs.ts new file mode 100644 index 00000000..82e9dec9 --- /dev/null +++ b/vscode-frysharp/src/notebook/format/tabs.ts @@ -0,0 +1,104 @@ +// A cell's outputs as the one output the notebook shows them in when there are several kinds (console text, a table, a chart ...), +// and back. The file never sees it: the serializer packs what it read, and unpacks what it is about to write, so a notebook saved +// from here is the notebook that was opened (the same outputs, in the same order, byte for byte). + +import { CellOutput, ERROR, fromBase64, isBinaryMime, jsonItem, OutputItem, STDERR, STDOUT, text, textItem, toBase64, utf8 } from './model'; +import { CELL_OUTPUT_MIME, kindOfMimes, lastLines, PackedCell, PackedItem, PackedOutput, summaryOf, TabKind } from '../../shared/outputTabs'; + +export { CELL_OUTPUT_MIME }; + +const strict = new TextDecoder('utf-8', { fatal: true }); + +// text when the bytes are text that writes back as the same bytes, else base64 +function packItem(item: OutputItem): PackedItem { + if (!isBinaryMime(item.mime)) { + try { + const value = strict.decode(item.data); + if (Buffer.compare(Buffer.from(utf8(value)), Buffer.from(item.data)) === 0) return { mime: item.mime, text: value }; + } catch { + // not UTF-8: kept as it is + } + } + return { mime: item.mime, base64: toBase64(item.data) }; +} + +function unpackItem(item: PackedItem): OutputItem { + return item.text !== undefined ? textItem(item.mime, item.text) : { mime: item.mime, data: fromBase64(item.base64 ?? '') }; +} + +const isError = (output: CellOutput): boolean => output.items.some((item) => item.mime === ERROR); + +// The outputs that are shown as tabs, and the errors after them, which are shown as VS Code shows an error. An error with anything +// after it, or anything of a kind the tabs cannot draw (a Jupyter widget, a Plotly figure), means the outputs are left as they are. +function parts(outputs: CellOutput[]): { head: CellOutput[]; errors: CellOutput[]; kinds: TabKind[] } | undefined { + const first = outputs.findIndex(isError); + const head = first < 0 ? outputs : outputs.slice(0, first); + const errors = first < 0 ? [] : outputs.slice(first); + if (!errors.every(isError)) return undefined; + + const kinds: TabKind[] = []; + for (const output of head) { + const kind = kindOfMimes(output.items.map((item) => item.mime)); + if (kind === undefined) return undefined; + kinds.push(kind); + } + return { head, errors, kinds }; +} + +/** Whether the outputs are more than one kind of thing, every one of which the tabs can draw: only then are they one output. */ +export function canPack(outputs: CellOutput[]): boolean { + const found = parts(outputs); + return found !== undefined && new Set(found.kinds).size > 1; +} + +export interface PackOptions { + /** Keep only this many lines at the end of each console output (the rest is counted): used while a cell runs, so a flood of lines is not sent again and again. */ + maxConsoleLines?: number; +} + +/** The outputs as the cell's one output: the data for the tabs, and a text that says what is in them for anywhere that cannot draw them. */ +export function pack(outputs: CellOutput[], options: PackOptions = {}): CellOutput { + const packed: PackedCell = { + v: 1, + outputs: outputs.map((output): PackedOutput => { + const result: PackedOutput = { items: output.items.map(packItem) }; + if (output.metadata && Object.keys(output.metadata).length > 0) result.metadata = output.metadata; + if (options.maxConsoleLines !== undefined && result.items.every((item) => item.mime === STDOUT || item.mime === STDERR)) { + let omitted = 0; + for (const item of result.items) { + const cut = lastLines(item.text ?? '', options.maxConsoleLines); + item.text = cut.text; + omitted += cut.omitted; + } + if (omitted > 0) result.omitted = omitted; + } + return result; + }) + }; + return { items: [jsonItem(CELL_OUTPUT_MIME, packed), textItem('text/plain', summaryOf(packed))] }; +} + +/** The outputs an output was made of; an output that is not one of these is itself. */ +export function unpack(output: CellOutput): CellOutput[] { + const item = output.items.find((i) => i.mime === CELL_OUTPUT_MIME); + if (!item) return [output]; + try { + const packed = JSON.parse(text(item)) as PackedCell; + if (packed?.v !== 1 || !Array.isArray(packed.outputs)) return [output]; + return packed.outputs.map((one) => { + const result: CellOutput = { items: one.items.map(unpackItem) }; + if (one.metadata) result.metadata = one.metadata; + return result; + }); + } catch { + return [output]; // damaged: left as it is rather than lost + } +} + +export const unpackAll = (outputs: CellOutput[]): CellOutput[] => outputs.flatMap(unpack); + +/** The outputs of a cell as they are shown: one output when there is more than one kind (an error at the end stays after it), else as they are. */ +export function forShowing(outputs: CellOutput[], enabled: boolean): CellOutput[] { + const found = enabled ? parts(outputs) : undefined; + return found && new Set(found.kinds).size > 1 ? [pack(found.head), ...found.errors] : outputs; +} diff --git a/vscode-frysharp/src/notebook/identity.ts b/vscode-frysharp/src/notebook/identity.ts new file mode 100644 index 00000000..40254050 --- /dev/null +++ b/vscode-frysharp/src/notebook/identity.ts @@ -0,0 +1,33 @@ +import * as path from 'path'; +import type { NotebookRef } from '../sidecar/protocol'; + +/** What identifies a notebook to the engine (type-only imports of vscode keep this runnable in plain Node). */ +export interface NotebookIdentity extends NotebookRef { + notebookId: string; + /** A name for it (its path from the workspace folder, or its file name): for messages and the engine's log. */ + path: string; + /** The folder its kernels run in. */ + workingDirectory?: string; +} + +export interface UriLike { + scheme: string; + fsPath: string; + path: string; + toString(): string; +} + +/** + * A notebook is its URI: the same file is the same notebook, an untitled one has its own, and two files named alike in + * different folders are two. Where it runs is its own folder (it need not be in the workspace), else the workspace folder. + */ +export function identityOf(uri: UriLike, workspaceFolder: (uri: UriLike) => string | undefined): NotebookIdentity { + const folder = workspaceFolder(uri); + if (uri.scheme !== 'file') { + return { notebookId: uri.toString(), path: uri.path.replace(/^\/+/, '') || uri.toString(), workingDirectory: folder }; + } + + const relative = folder ? path.relative(folder, uri.fsPath).replace(/\\/g, '/') : ''; + const inside = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); + return { notebookId: uri.toString(), path: inside ? relative : path.basename(uri.fsPath), workingDirectory: path.dirname(uri.fsPath) }; +} diff --git a/vscode-frysharp/src/notebook/liveOutputs.ts b/vscode-frysharp/src/notebook/liveOutputs.ts new file mode 100644 index 00000000..8bf3df0a --- /dev/null +++ b/vscode-frysharp/src/notebook/liveOutputs.ts @@ -0,0 +1,161 @@ +// The output of a cell while it runs. A cell that only prints (or only shows one kind of thing) has its output as VS Code +// shows it natively: text added to one stdout output, a table as an output of its own. The moment it makes a second kind (printed +// text and a table, a chart and text), everything it has made becomes the one output the tabs are drawn from (./format/tabs), and +// stays one; from then on it is rewritten as more arrives, at most a few times a second, with the end of a long console only until +// the cell has finished (then all of it). No VS Code in here: the places the output goes are an OutputSink. + +import { CellOutput, OutputItem, STDOUT, textItem } from './format/model'; +import { outputFromDisplay } from './format/outputs'; +import { canPack, pack } from './format/tabs'; + +/** Where the output goes: VS Code's execution for a cell, or a recording of what was asked in a test. A handle names one output. */ +export interface OutputSink { + append(output: CellOutput): Promise; + appendItems(handle: Handle, items: OutputItem[]): Promise; + replaceItems(handle: Handle, items: OutputItem[]): Promise; + /** Replaces every output of the cell with these. */ + replaceAll(outputs: CellOutput[]): Promise; +} + +/** What the engine said while the cell ran. */ +export type Said = + | { kind: 'stream'; text: string } + | { kind: 'display'; data: Record; metadata?: Record; displayId?: string; update?: boolean }; + +export interface LiveOptions { + /** Whether a cell with several kinds of output shows them as tabs. */ + tabs: boolean; + /** The fewest milliseconds between two rewrites of the tabbed output while the cell runs. */ + throttleMs?: number; + /** How many lines at the end of a console the tabbed output carries while the cell runs (all of it at the end). */ + liveConsoleLines?: number; + /** For a test: runs a function after a delay and says how to cancel it. */ + schedule?: (run: () => void, ms: number) => () => void; +} + +type Entry = { kind: 'stream'; chunks: string[] } | { kind: 'output'; output: CellOutput }; + +const stdoutOutput = (text: string): CellOutput => ({ items: [textItem(STDOUT, text)] }); + +export class LiveOutputs { + private readonly entries: Entry[] = []; + private readonly handles: Array = []; + private readonly displays = new Map(); + private packed: Handle | undefined; + private timer: (() => void) | undefined; + private chain: Promise = Promise.resolve(); + private dirty = false; + + constructor( + private readonly sink: OutputSink, + private readonly options: LiveOptions + ) {} + + /** Whether everything is one output now. */ + get isTabbed(): boolean { + return this.packed !== undefined; + } + + /** Everything the cell has made so far, as outputs. */ + outputs(): CellOutput[] { + return this.entries.map((entry) => (entry.kind === 'stream' ? stdoutOutput(entry.chunks.join('')) : entry.output)); + } + + /** Takes in what the engine said, in order: one at a time (the next waits for the last to be shown). */ + show(said: Said): Promise { + return this.serial(() => this.take(said)); + } + + /** The cell has ended: whatever was waiting to be shown is, and a console cut short for the run is whole. */ + finish(): Promise { + return this.serial(async () => { + this.cancelTimer(); + if (this.packed !== undefined) await this.rewrite(false); + }); + } + + // ── one thing at a time ───────────────────────────────────────────────────────────────────────────────────────── + + private serial(task: () => Promise): Promise { + const next = this.chain.then(task, task); + this.chain = next.catch(() => undefined); + return next; + } + + private async take(said: Said): Promise { + if (said.kind === 'stream' && !said.text) return; + // what the cell has made now, and what it takes to show that natively (not done if it is about to become tabs: nothing flickers) + const native = said.kind === 'stream' ? this.applyText(said.text) : this.applyDisplay(said); + + if (this.packed === undefined && this.options.tabs && canPack(this.outputs())) { + // the second kind of thing: all of it is one output from here on + this.handles.length = 0; + this.packed = (await this.sink.replaceAll([pack(this.outputs(), { maxConsoleLines: this.liveLines })]))[0]; + } else if (this.packed === undefined) { + await native(); + } else { + this.dirty = true; + this.schedule(); + } + } + + private get liveLines(): number { + return this.options.liveConsoleLines ?? 2000; + } + + private applyText(text: string): () => Promise { + const last = this.entries[this.entries.length - 1]; + if (last?.kind === 'stream') { + last.chunks.push(text); + const index = this.entries.length - 1; + return async () => this.sink.appendItems(this.handles[index] as Handle, [textItem(STDOUT, text)]); + } + + this.entries.push({ kind: 'stream', chunks: [text] }); + const index = this.entries.length - 1; + return async () => { + this.handles[index] = await this.sink.append(stdoutOutput(text)); + }; + } + + private applyDisplay(said: Extract): () => Promise { + const output = outputFromDisplay(said.data, said.metadata); + const earlier = said.update && said.displayId ? this.displays.get(said.displayId) : undefined; + if (earlier !== undefined) { + // a chart the program redraws is redrawn where it is + this.entries[earlier] = { kind: 'output', output }; + return async () => this.sink.replaceItems(this.handles[earlier] as Handle, output.items); + } + + this.entries.push({ kind: 'output', output }); + const index = this.entries.length - 1; + if (said.displayId) this.displays.set(said.displayId, index); + return async () => { + this.handles[index] = await this.sink.append(output); + }; + } + + // ── the tabbed output, rewritten as it grows ──────────────────────────────────────────────────────────────────── + + private schedule(): void { + if (this.timer) return; + const run = () => { + this.timer = undefined; + void this.serial(() => this.rewrite(true)); + }; + const ms = this.options.throttleMs ?? 200; + this.timer = this.options.schedule ? this.options.schedule(run, ms) : ((handle) => () => clearTimeout(handle))(setTimeout(run, ms)); + } + + private cancelTimer(): void { + this.timer?.(); + this.timer = undefined; + } + + private async rewrite(live: boolean): Promise { + if (this.packed === undefined || (!this.dirty && live)) return; + this.dirty = false; + const outputs = this.outputs(); + await this.sink.replaceItems(this.packed, pack(outputs, live ? { maxConsoleLines: this.liveLines } : {}).items); + } +} diff --git a/vscode-frysharp/src/notebook/notebookController.ts b/vscode-frysharp/src/notebook/notebookController.ts index d46b3da9..2ce46987 100644 --- a/vscode-frysharp/src/notebook/notebookController.ts +++ b/vscode-frysharp/src/notebook/notebookController.ts @@ -1,183 +1,123 @@ -import * as path from 'path'; import * as vscode from 'vscode'; +import { ENGINE_ERROR } from './format/model'; +import { identityOf, NotebookIdentity } from './identity'; +import { RunHub } from './runHub'; import { SidecarClient } from '../sidecar/sidecarClient'; -import { NotebookOutputNotification } from '../sidecar/protocol'; -import { FryNotebookSerializer } from './notebookSerializer'; -export class FryNotebookController implements vscode.Disposable { - private controller: vscode.NotebookController; - private disposables: vscode.Disposable[] = []; - private activeExecutions = new Map(); - private pendingOutputs = new Map[]>(); +/** The notebook types of the extension: its own file, and a Jupyter file opened with it. */ +export const NOTEBOOK_TYPES = ['frysharp-notebook', 'frysharp-jupyter'] as const; +export const isFryNotebook = (notebook: vscode.NotebookDocument): boolean => (NOTEBOOK_TYPES as readonly string[]).includes(notebook.notebookType); - constructor(private sidecar: SidecarClient) { - this.controller = vscode.notebooks.createNotebookController( - 'frysharp-kernel', - 'frysharp-notebook', - 'FrySharp Polyglot Kernel' - ); +export function notebookIdentity(notebook: vscode.NotebookDocument): NotebookIdentity { + return identityOf(notebook.uri, (uri) => vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(uri.toString()))?.uri.fsPath ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); +} - this.controller.supportedLanguages = ['csharp', 'python', 'javascript', 'java', 'sql', 'fsharp', 'rust', 'cpp']; +/** What the engine runs, as VS Code's controller for one notebook type: starts a cell's execution and ends it; the output is the hub's. */ +export class FryNotebookController implements vscode.Disposable { + private readonly controller: vscode.NotebookController; + private readonly disposables: vscode.Disposable[] = []; + + constructor( + private readonly sidecar: SidecarClient, + private readonly hub: RunHub, + private readonly notebookType: (typeof NOTEBOOK_TYPES)[number] + ) { + this.controller = vscode.notebooks.createNotebookController(`frysharp-kernel-${notebookType}`, notebookType, 'FrySharp Polyglot Kernel'); + this.controller.description = '.NET 10 · Python · JavaScript'; this.controller.supportsExecutionOrder = true; this.controller.executeHandler = this.executeHandler.bind(this); this.controller.interruptHandler = this.interruptHandler.bind(this); - // Listen to output streamed from sidecar this.disposables.push( - this.sidecar.onNotebookOutput(this.handleOutput.bind(this)) + // The languages are the engine's to say: a cell in one it cannot run is not offered a Run button. + this.sidecar.onDidInitialize((init) => { + const runnable = init.languages.filter((language) => language.canRunCells); + this.controller.supportedLanguages = runnable.map((language) => language.id); + this.controller.description = runnable.map((language) => language.displayName).join(' · '); + }), + // It is the kernel to use for these notebooks without asking. + vscode.workspace.onDidOpenNotebookDocument((notebook) => this.prefer(notebook)) ); + vscode.workspace.notebookDocuments.forEach((notebook) => this.prefer(notebook)); } - private normalizePath(p: string): string { - return p.replace(/\\/g, '/').replace(/^\/+/, ''); - } - - private getRelativePath(notebook: vscode.NotebookDocument): string { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - let rel = workspaceRoot ? path.relative(workspaceRoot, notebook.uri.fsPath).replace(/\\/g, '/') : ''; - if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { - rel = path.basename(notebook.uri.fsPath) || 'untitled.frynb'; + private prefer(notebook: vscode.NotebookDocument): void { + if (notebook.notebookType === this.notebookType) { + this.controller.updateNotebookAffinity(notebook, vscode.NotebookControllerAffinity.Preferred); } - return this.normalizePath(rel); } private async executeHandler(cells: vscode.NotebookCell[], notebook: vscode.NotebookDocument): Promise { - const relativePath = this.getRelativePath(notebook); + if (!vscode.workspace.isTrusted) { + void vscode.window.showWarningMessage('Running code is off until you trust this workspace.'); + return; + } + const stopOnError = vscode.workspace.getConfiguration('frysharp.notebook').get('stopOnError', true); + const identity = notebookIdentity(notebook); for (const cell of cells) { - await this.executeCell(cell, relativePath); + const succeeded = await this.executeCell(cell, identity); + // Run All stops at the cell that failed, as running them one by one would: what follows depends on it. + if (!succeeded && stopOnError) break; } } - private async executeCell(cell: vscode.NotebookCell, relativePath: string): Promise { + private async executeCell(cell: vscode.NotebookCell, identity: NotebookIdentity): Promise { const execution = this.controller.createNotebookCellExecution(cell); execution.executionOrder = (cell.executionSummary?.executionOrder || 0) + 1; execution.start(Date.now()); await execution.clearOutput(); + const run = this.hub.begin(execution, cell, identity); - const cellId = cell.metadata?.id || `cell-${cell.index + 1}`; - const key = `${relativePath}:${cellId}`; - this.activeExecutions.set(key, { execution, cell }); - this.pendingOutputs.set(key, []); - - execution.token.onCancellationRequested(async () => { - await this.sidecar.interruptNotebook(relativePath); - execution.end(false, Date.now()); - }); + // Stop asks the engine to stop the cell; the cell ends when the engine says it has, with what it printed before. + const stop = execution.token.onCancellationRequested(() => void this.sidecar.interruptNotebook({ notebookId: identity.notebookId })); try { - const source = cell.document.getText(); - const language = cell.kind === vscode.NotebookCellKind.Code ? cell.document.languageId : undefined; - const result = await this.sidecar.executeCell(relativePath, cellId, source, language); - - // Wait for all in-flight output notifications to flush into the notebook cell - const pending = this.pendingOutputs.get(key) || []; - await Promise.all(pending); - // Brief pause to allow trailing notifications in the pipe - await new Promise((r) => setTimeout(r, 60)); - const trailing = this.pendingOutputs.get(key) || []; - await Promise.all(trailing); + const cellId = cell.metadata?.id || `cell-${cell.index + 1}`; + const result = await this.sidecar.executeCell({ + path: identity.path, + cellId, + source: cell.document.getText(), + language: cell.kind === vscode.NotebookCellKind.Code ? cell.document.languageId : undefined, + notebookId: identity.notebookId, + workingDirectory: identity.workingDirectory, + executionId: run.id + }); + + // The reply can arrive before the last of the output: wait until everything the run sent is shown. + if (!(await run.queue.processed(result.lastSeq ?? 0))) { + console.warn(`[FrySharp] some output of ${identity.path} (${cellId}) did not arrive`); + } + await this.hub.finish(run); + // why it failed, as VS Code shows an error (what the cell printed is above it, whole) + if (!result.success && result.error) { + await execution.appendOutput( + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.error({ name: result.error.name, message: result.error.message })], { [ENGINE_ERROR]: true }) + ); + } + this.hub.markErrors(cell, result.diagnostics); execution.executionOrder = result.executionCount; execution.end(result.success, Date.now()); - } catch (err: any) { - const errorOutput = new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.error({ - name: 'ExecutionError', - message: err?.message || String(err) - }) - ]); - await execution.appendOutput(errorOutput); + return result.success; + } catch (error) { + await this.hub.finish(run).catch(() => undefined); + await execution.appendOutput( + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.error({ name: 'FrySharp engine', message: error instanceof Error ? error.message : String(error) })]) + ); execution.end(false, Date.now()); + return false; } finally { - this.activeExecutions.delete(key); - this.pendingOutputs.delete(key); + stop.dispose(); + this.hub.end(run); } } private async interruptHandler(notebook: vscode.NotebookDocument): Promise { - const relativePath = this.getRelativePath(notebook); - await this.sidecar.interruptNotebook(relativePath); - } - - private findExecution(notifPath: string, cellId: string): { execution?: vscode.NotebookCellExecution; cell?: vscode.NotebookCell; key: string } { - const norm = this.normalizePath(notifPath); - const exactKey = `${norm}:${cellId}`; - - if (this.activeExecutions.has(exactKey)) { - return { ...this.activeExecutions.get(exactKey)!, key: exactKey }; - } - - // Match by base name or cellId if single active - for (const [k, val] of this.activeExecutions.entries()) { - if (k.endsWith(`:${cellId}`) || k.includes(path.basename(notifPath))) { - return { ...val, key: k }; - } - } - - return { key: exactKey }; - } - - private async handleOutput(notification: NotebookOutputNotification): Promise { - const found = this.findExecution(notification.path, notification.cellId); - if (!found.execution) return; - - const execution = found.execution; - const key = found.key; - - const promise = (async () => { - try { - const out = notification.output; - if (out.kind === 'stream' && out.text) { - await execution.appendOutput(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(out.text, 'text/plain') - ])); - } else if (out.kind === 'display' && out.data) { - if (!out.data['text/html']) { - if (out.data['application/vnd.fry.table+json']) { - out.data['text/html'] = FryNotebookSerializer.formatTableJsonToHtml(out.data['application/vnd.fry.table+json']); - } else { - const imgKey = Object.keys(out.data).find(k => k.startsWith('image/') && k !== 'image/svg+xml'); - if (imgKey && typeof out.data[imgKey] === 'string') { - const b64 = out.data[imgKey]; - out.data['text/html'] = `
Image Output
`; - } - } - } - // Prioritize standard web types so VS Code renders text/html, image, and markdown first - const preferredOrder = ['text/html', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'text/markdown', 'text/plain']; - const entries = Object.entries(out.data).sort(([mimeA], [mimeB]) => { - const idxA = preferredOrder.indexOf(mimeA); - const idxB = preferredOrder.indexOf(mimeB); - const rankA = idxA >= 0 ? idxA : 100; - const rankB = idxB >= 0 ? idxB : 100; - return rankA - rankB; - }); - - const items = entries.map(([mime, val]) => { - let bytes: Uint8Array; - if (typeof val === 'string' && mime.startsWith('image/') && mime !== 'image/svg+xml') { - bytes = Buffer.from(val, 'base64'); - } else { - const text = typeof val === 'string' ? val : JSON.stringify(val); - bytes = new TextEncoder().encode(text); - } - return new vscode.NotebookCellOutputItem(bytes, mime); - }); - await execution.appendOutput(new vscode.NotebookCellOutput(items, out.metadata || undefined)); - } - } catch (err) { - console.error('[NotebookController] Error appending output:', err); - } - })(); - - const list = this.pendingOutputs.get(key) || []; - list.push(promise); - this.pendingOutputs.set(key, list); - await promise; + await this.sidecar.interruptNotebook({ notebookId: notebookIdentity(notebook).notebookId }); } - public dispose(): void { + dispose(): void { this.controller.dispose(); this.disposables.forEach((d) => d.dispose()); } diff --git a/vscode-frysharp/src/notebook/notebookSerializer.ts b/vscode-frysharp/src/notebook/notebookSerializer.ts index 6e76e4d5..9dcd2239 100644 --- a/vscode-frysharp/src/notebook/notebookSerializer.ts +++ b/vscode-frysharp/src/notebook/notebookSerializer.ts @@ -1,871 +1,100 @@ import * as vscode from 'vscode'; +import { CellModel, CellOutput, NotebookModel, newId, parseNotebook, writeNotebook } from './format'; +import { forShowing, unpackAll } from './format/tabs'; + +// The only place where a notebook file meets VS Code: the pure model (./format) in one direction, NotebookData in the other. + +const EMPTY_STATE = (): CellModel['state'] => ({ extra: {}, snapshots: {}, outputsKey: '' }); export class FryNotebookSerializer implements vscode.NotebookSerializer { - async deserializeNotebook(content: Uint8Array, token: vscode.CancellationToken): Promise { - const text = new TextDecoder().decode(content); - if (!text.trim()) { - return new vscode.NotebookData([]); + /** + * @param defaultFormat what a new, empty file becomes: the Jupyter type is for .ipynb files. + * @param outputTabs whether a cell with several kinds of output (text, a table, a chart ...) opens with them as one output, drawn as tabs. + * The file is the same either way: what is saved is always the outputs themselves. + */ + constructor( + private readonly defaultFormat: 'frynb' | 'ipynb' = 'frynb', + private readonly outputTabs: () => boolean = () => false + ) {} + + async deserializeNotebook(content: Uint8Array, _token: vscode.CancellationToken): Promise { + const json = new TextDecoder().decode(content); + if (!json.trim()) { + return this.toNotebookData({ format: this.defaultFormat, kernel: 'csharp', root: {}, cells: [] }); } - let parsed: any; + let model: NotebookModel; try { - parsed = JSON.parse(text); - } catch { - return new vscode.NotebookData([]); - } - - // 1. Check if standard Jupyter format (.ipynb) - if (parsed.cells && Array.isArray(parsed.cells) && parsed.nbformat) { - return this.deserializeJupyter(parsed); + model = parseNotebook(json); + } catch (error) { + // Not an empty notebook: saving that would replace the file. + throw new Error(`This file is not a FrySharp or Jupyter notebook: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } - - // 2. Native FrySharp (.frynb / .csnb) format - // Accommodate both PascalCase (from C# System.Text.Json) and camelCase - const rawCells: any[] = parsed.Cells || parsed.cells || []; - const defaultLanguage: string = parsed.Kernel || parsed.kernel || 'csharp'; - const notebookTitle: string = parsed.Title || parsed.title || 'Untitled'; - const notebookId: string = parsed.Id || parsed.id || ''; - - const cells: vscode.NotebookCellData[] = rawCells.map((cell: any, idx: number) => { - const rawType = cell.Type ?? cell.type; - const isMarkdown = rawType === 1 || rawType === '1' || String(rawType).toLowerCase() === 'markdown'; - const kind = isMarkdown ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code; - - let source = ''; - if (typeof cell.Source === 'string') { - source = cell.Source; - } else if (typeof cell.source === 'string') { - source = cell.source; - } else if (Array.isArray(cell.Source)) { - source = cell.Source.join(''); - } else if (Array.isArray(cell.source)) { - source = cell.source.join(''); - } - - const explicitLanguage = cell.Language ?? cell.language; - const languageId = isMarkdown ? 'markdown' : (explicitLanguage || defaultLanguage); - - const cellData = new vscode.NotebookCellData(kind, source, languageId); - const cellId = cell.Id ?? cell.id ?? `cell-${idx + 1}`; - cellData.metadata = { id: cellId }; - - const execCount = cell.ExecutionCount ?? cell.executionCount; - if (execCount != null) { - cellData.executionSummary = { executionOrder: execCount }; - } - - // Restore cell outputs - const outputs: vscode.NotebookCellOutput[] = []; - - // 2a. OutputText (console stream) - const outputText = cell.OutputText ?? cell.outputText; - if (outputText && typeof outputText === 'string' && outputText.length > 0) { - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(outputText, 'text/plain') - ])); - } - - // 2b. HtmlContent - const htmlContent = cell.HtmlContent ?? cell.htmlContent; - if (htmlContent && typeof htmlContent === 'string' && htmlContent.length > 0) { - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(htmlContent, 'text/html') - ])); - } else { - // Fallback: Check if TableSnapshot is saved - const tableSnapshot = cell.TableSnapshot ?? cell.tableSnapshot; - if (tableSnapshot && Array.isArray(tableSnapshot.Columns || tableSnapshot.columns)) { - const tableHtml = FryNotebookSerializer.formatTableSnapshotToHtml(tableSnapshot); - if (tableHtml) { - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(tableHtml, 'text/html') - ])); - } - } - } - - // 2b-2. Visuals array (saved charts & 3D plots) - const visuals = cell.Visuals ?? cell.visuals; - if (Array.isArray(visuals)) { - for (const viz of visuals) { - const vizHtml = this.formatVisualToHtml(viz); - if (vizHtml) { - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(vizHtml, 'text/html') - ])); - } - } - } - - // 2c. ImageBytes (base64 string or byte array) - const rawImage = cell.ImageBytes ?? cell.imageBytes; - if (rawImage) { - try { - let bytes: Uint8Array; - if (typeof rawImage === 'string') { - bytes = Uint8Array.from(Buffer.from(rawImage, 'base64')); - } else if (Array.isArray(rawImage)) { - bytes = new Uint8Array(rawImage); - } else { - bytes = new Uint8Array(); - } - - if (bytes.length > 0) { - const format = (cell.ImageFormat ?? cell.imageFormat ?? 'png').toLowerCase(); - const mime = format.startsWith('image/') ? format : `image/${format}`; - const b64 = typeof rawImage === 'string' ? rawImage : Buffer.from(bytes).toString('base64'); - const html = `
Synthesized Image
`; - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(html, 'text/html'), - new vscode.NotebookCellOutputItem(bytes, mime) - ])); - } - } catch { - // ignore corrupted image decode - } - } - - // 2d. Explicit outputs array (if present from mixed format) - const explicitOutputs = cell.outputs ?? cell.Outputs; - if (Array.isArray(explicitOutputs)) { - for (const out of explicitOutputs) { - if (out.kind === 'stream' && out.text) { - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(out.text, 'text/plain') - ])); - } else if (out.kind === 'display' && out.data) { - if (!out.data['text/html']) { - if (out.data['application/vnd.fry.table+json']) { - out.data['text/html'] = FryNotebookSerializer.formatTableJsonToHtml(out.data['application/vnd.fry.table+json']); - } else { - const imgKey = Object.keys(out.data).find(k => k.startsWith('image/') && k !== 'image/svg+xml'); - if (imgKey && typeof out.data[imgKey] === 'string') { - const b64 = out.data[imgKey]; - out.data['text/html'] = `
Synthesized Image
`; - } - } - } - const preferredOrder = ['text/html', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'text/markdown', 'text/plain']; - const entries = Object.entries(out.data).sort(([mimeA], [mimeB]) => { - const idxA = preferredOrder.indexOf(mimeA); - const idxB = preferredOrder.indexOf(mimeB); - const rankA = idxA >= 0 ? idxA : 100; - const rankB = idxB >= 0 ? idxB : 100; - return rankA - rankB; - }); - - const items = entries.map(([mime, val]) => { - let bytes: Uint8Array; - const str = Array.isArray(val) ? val.join('') : (typeof val === 'string' ? val : JSON.stringify(val)); - if (typeof str === 'string' && mime.startsWith('image/') && mime !== 'image/svg+xml') { - bytes = Buffer.from(str, 'base64'); - } else { - bytes = new TextEncoder().encode(str); - } - return new vscode.NotebookCellOutputItem(bytes, mime); - }); - outputs.push(new vscode.NotebookCellOutput(items, out.metadata || undefined)); - } - } - } - - if (outputs.length > 0) { - cellData.outputs = outputs; - } - - return cellData; - }); - - const notebookData = new vscode.NotebookData(cells); - notebookData.metadata = { - isJupyter: false, - id: notebookId, - title: notebookTitle, - kernel: defaultLanguage, - schemaVersion: parsed.SchemaVersion || parsed.schemaVersion || '1.0' - }; - return notebookData; + return this.toNotebookData(model); } - async serializeNotebook(data: vscode.NotebookData, token: vscode.CancellationToken): Promise { - // 1. If original was Jupyter (.ipynb), serialize back as Jupyter notebook - if (data.metadata?.isJupyter) { - const cells = data.cells.map((cell, idx) => { - const isMarkdown = cell.kind === vscode.NotebookCellKind.Markup; - const sourceLines = cell.value.split('\n').map((line, i, arr) => i < arr.length - 1 ? line + '\n' : line); - - if (isMarkdown) { - return { - cell_type: 'markdown', - id: cell.metadata?.id || `cell-${idx + 1}`, - metadata: {}, - source: sourceLines - }; - } - - const outputs: any[] = []; - for (const out of cell.outputs || []) { - for (const item of out.items || []) { - if (item.mime === 'text/plain') { - outputs.push({ - output_type: 'stream', - name: 'stdout', - text: new TextDecoder().decode(item.data).split('\n').map((l, i, a) => i < a.length - 1 ? l + '\n' : l) - }); - } else if (item.mime.startsWith('image/')) { - outputs.push({ - output_type: 'display_data', - data: { - [item.mime]: Buffer.from(item.data).toString('base64'), - 'text/plain': ['
'] - }, - metadata: out.metadata || {} - }); - } else { - outputs.push({ - output_type: 'display_data', - data: { - [item.mime]: new TextDecoder().decode(item.data) - }, - metadata: out.metadata || {} - }); - } - } - } - - return { - cell_type: 'code', - execution_count: cell.executionSummary?.executionOrder ?? null, - id: cell.metadata?.id || `cell-${idx + 1}`, - metadata: { - polyglot_notebook: { kernelName: cell.languageId } - }, - outputs, - source: sourceLines - }; - }); - - const ipynb = { - cells, - metadata: data.metadata?.rawMetadata || { - kernelspec: { display_name: '.NET (C#)', language: 'C#', name: '.net-csharp' }, - language_info: { name: 'csharp' } - }, - nbformat: data.metadata?.nbformat || 4, - nbformat_minor: data.metadata?.nbformat_minor || 5 - }; - - return new TextEncoder().encode(JSON.stringify(ipynb, null, 2)); - } - - // 2. Native FrySharp (.frynb / .csnb) format matching C# NotebookDocumentItem & NotebookCellItem - const defaultLanguage = data.metadata?.kernel || 'csharp'; - const cells = data.cells.map((cell, idx) => { - const isMarkdown = cell.kind === vscode.NotebookCellKind.Markup; - let outputText = ''; - let imageBytes: string | null = null; - let imageFormat: string | null = null; - let htmlContent: string | null = null; - - for (const out of cell.outputs || []) { - for (const item of out.items || []) { - if (item.mime === 'text/plain') { - outputText += new TextDecoder().decode(item.data); - } else if (item.mime.startsWith('image/')) { - imageFormat = item.mime.replace('image/', '').toUpperCase(); - imageBytes = Buffer.from(item.data).toString('base64'); - } else if (item.mime === 'text/html') { - htmlContent = new TextDecoder().decode(item.data); - } - } - } - - return { - Id: cell.metadata?.id || `cell-${idx + 1}`, - Type: isMarkdown ? 1 : 0, - Language: isMarkdown ? undefined : (cell.languageId !== defaultLanguage ? cell.languageId : undefined), - Source: cell.value, - OutputText: outputText, - ExecutionCount: cell.executionSummary?.executionOrder ?? null, - IsExecuting: false, - ExecutionTimeText: '', - HasError: false, - IsMarkdownPreviewMode: isMarkdown, - IsInputCollapsed: false, - IsOutputCollapsed: false, - IsOutputScrolled: false, - ImageBytes: imageBytes, - ImageFormat: imageFormat, - ImageWidth: null, - ImageHeight: null, - HtmlContent: htmlContent, - TableSnapshot: null, - InspectorSnapshot: null, - HadInteractiveControl: false, - HasImage: !!imageBytes, - HasHtml: !!htmlContent, - HasOutput: !!(outputText || imageBytes || htmlContent), - IsCodeCell: !isMarkdown, - IsMarkdownCell: isMarkdown - }; - }); - - const frynb = { - SchemaVersion: data.metadata?.schemaVersion || '1.0', - Type: 'notebook', - Id: data.metadata?.id || 'notebook-1', - Title: data.metadata?.title || 'Notebook', - Description: data.metadata?.description || '', - Category: data.metadata?.category || 'Interactive', - Kernel: defaultLanguage, - Cells: cells - }; - - return new TextEncoder().encode(JSON.stringify(frynb, null, 2)); + async serializeNotebook(data: vscode.NotebookData, _token: vscode.CancellationToken): Promise { + return new TextEncoder().encode(writeNotebook(this.toModel(data))); } - private deserializeJupyter(ipynb: any): vscode.NotebookData { - const defaultLanguage = ipynb.metadata?.language_info?.name || 'csharp'; - const cells: vscode.NotebookCellData[] = (ipynb.cells || []).map((cell: any, idx: number) => { - const isMarkdown = cell.cell_type === 'markdown'; - const kind = isMarkdown ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code; - const source = Array.isArray(cell.source) ? cell.source.join('') : (cell.source || ''); - const cellLang = cell.metadata?.polyglot_notebook?.kernelName || cell.metadata?.dotnet_interactive?.language || defaultLanguage; - const languageId = isMarkdown ? 'markdown' : cellLang; - - const cellData = new vscode.NotebookCellData(kind, source, languageId); - cellData.metadata = { id: cell.id || `cell-${idx + 1}`, ...cell.metadata }; - - if (cell.execution_count != null) { - cellData.executionSummary = { executionOrder: cell.execution_count }; - } - - if (cell.outputs && Array.isArray(cell.outputs)) { - const outputs: vscode.NotebookCellOutput[] = []; - for (const out of cell.outputs) { - if (out.output_type === 'stream') { - const text = Array.isArray(out.text) ? out.text.join('') : (out.text || ''); - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.text(text, 'text/plain') - ])); - } else if (out.output_type === 'error') { - const traceback = Array.isArray(out.traceback) ? out.traceback.join('\n') : (out.traceback || out.evalue || ''); - outputs.push(new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.error({ - name: out.ename || 'Error', - message: out.evalue || '', - stack: traceback - }) - ])); - } else if (out.data && typeof out.data === 'object') { - const items = Object.entries(out.data).map(([mime, val]) => { - let bytes: Uint8Array; - const str = Array.isArray(val) ? val.join('') : (typeof val === 'string' ? val : JSON.stringify(val)); - if (typeof str === 'string' && mime.startsWith('image/') && mime !== 'image/svg+xml') { - bytes = Buffer.from(str, 'base64'); - } else { - bytes = new TextEncoder().encode(str); - } - return new vscode.NotebookCellOutputItem(bytes, mime); - }); - outputs.push(new vscode.NotebookCellOutput(items, out.metadata)); - } - } - if (outputs.length > 0) { - cellData.outputs = outputs; - } - } - - return cellData; + private toNotebookData(model: NotebookModel): vscode.NotebookData { + const showTabs = this.outputTabs(); + const cells = model.cells.map((cell) => { + const markup = cell.kind === 'markdown'; + const data = new vscode.NotebookCellData(markup ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code, cell.source, markup ? 'markdown' : (cell.language ?? model.kernel)); + data.metadata = { id: cell.id, fry: cell.state }; + if (cell.executionCount !== undefined) { + data.executionSummary = { executionOrder: cell.executionCount, success: !cell.failed }; + } + data.outputs = forShowing(cell.outputs, showTabs).map(toVsCodeOutput); + return data; }); - const notebookData = new vscode.NotebookData(cells); - notebookData.metadata = { - isJupyter: true, - rawMetadata: ipynb.metadata, - nbformat: ipynb.nbformat || 4, - nbformat_minor: ipynb.nbformat_minor || 5 - }; - return notebookData; - } - - public static formatTableJsonToHtml(tableData: any): string { - if (!tableData) return ''; - const cols = (tableData.columns || tableData.Columns || []).map((c: any, i: number) => ({ - Header: typeof c === 'string' ? c : (c.Header || c.header || `Col ${i + 1}`), - IsNumeric: !!(tableData.numeric?.[i] || c.IsNumeric || c.isNumeric) - })); - const rows = (tableData.rows || tableData.Rows || []).map((r: any) => ({ - Cells: Array.isArray(r) - ? r.map((cellVal: any) => ({ DisplayText: cellVal == null ? '' : String(cellVal), IsNull: cellVal == null })) - : (r.Cells || r.cells || []) - })); - return FryNotebookSerializer.formatTableSnapshotToHtml({ - Title: tableData.title || tableData.Title, - Columns: cols, - Rows: rows - }); + const data = new vscode.NotebookData(cells); + data.metadata = { format: model.format, kernel: model.kernel, title: model.root.Title, fry: { root: model.root, indent: model.indent } }; + return data; } - public static formatTableSnapshotToHtml(table: any): string { - const cols = table.Columns || table.columns; - const rows = table.Rows || table.rows; - if (!Array.isArray(cols) || !Array.isArray(rows)) return ''; - - const uid = 'fry_tbl_' + Math.random().toString(36).slice(2, 9); - const title = table.Title || table.title || table.Label || table.label || 'Data Table'; - const escape = (s: string) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - const rowCount = rows.length; - const colCount = cols.length; - - const formatCell = (val: any, isNum: boolean): { html: string; css: string } => { - if (val == null || val?.IsNull || val?.isNull) { - return { html: 'null', css: '' }; - } - const text = String(val?.DisplayText ?? val?.displayText ?? val); - const lower = text.trim().toLowerCase(); - if (['online', 'active', 'healthy', 'optimal', 'passed', 'success', 'true', 'running', 'ready', 'connected'].includes(lower)) { - return { html: `${escape(text)}`, css: '' }; - } - if (['warning', 'degraded', 'under load', 'pending', 'slow', 'paused', 'retrying'].includes(lower)) { - return { html: `${escape(text)}`, css: '' }; - } - if (['error', 'failed', 'critical', 'offline', 'down', 'false', 'stopped'].includes(lower)) { - return { html: `${escape(text)}`, css: '' }; - } - if (isNum) { - return { html: escape(text), css: 'fry-num' }; - } - return { html: escape(text), css: '' }; - }; - - let headerHtml = ''; - cols.forEach((col: any, cIdx: number) => { - const isNum = !!(col.IsNumeric ?? col.isNumeric); - const align = isNum ? 'right' : 'left'; - const colHeader = escape(col.Header || col.header || `Col ${cIdx + 1}`); - headerHtml += `${colHeader} ↕`; - }); - - let rowsHtml = ''; - rows.forEach((row: any, rIdx: number) => { - const bg = (rIdx % 2 === 0) ? 'background:rgba(255,255,255,0.015);' : 'background:transparent;'; - const cells = row.Cells || row.cells || []; - let cellsHtml = ''; - let propCardsHtml = ''; - - cols.forEach((col: any, cIdx: number) => { - const isNum = !!(col.IsNumeric ?? col.isNumeric); - const align = isNum ? 'right' : 'left'; - const cell = cells[cIdx]; - const { html, css } = formatCell(cell, isNum); - cellsHtml += `${html}`; - propCardsHtml += `
${escape(col.Header || col.header || '')}
${html}
`; - }); - - rowsHtml += ` - - - - ${cellsHtml} - - - -
-
-
🔍 Row #${rIdx + 1} Inspector
-
${colCount} properties
-
-
${propCardsHtml}
-
- - `; + private toModel(data: vscode.NotebookData): NotebookModel { + const metadata = data.metadata ?? {}; + const kernel = typeof metadata.kernel === 'string' ? metadata.kernel : 'csharp'; + const root: Record = { ...(metadata.fry?.root ?? {}) }; + if (root.Title === undefined && typeof metadata.title === 'string') root.Title = metadata.title; + + const seen = new Set(); + const cells = data.cells.map((cell): CellModel => { + // A copied cell brings its id with it, and a new one has none: a cell's id is its own. + let id = typeof cell.metadata?.id === 'string' && cell.metadata.id ? cell.metadata.id : newId(); + if (seen.has(id)) id = newId(); + seen.add(id); + + const markup = cell.kind === vscode.NotebookCellKind.Markup; + const timing = cell.executionSummary?.timing; + return { + id, + kind: markup ? 'markdown' : 'code', + language: markup || cell.languageId === kernel ? undefined : cell.languageId, + source: cell.value, + executionCount: cell.executionSummary?.executionOrder, + failed: cell.executionSummary?.success === false, + outputs: unpackAll((cell.outputs ?? []).map(fromVsCodeOutput)), + executionMs: timing ? timing.endTime - timing.startTime : undefined, + state: cell.metadata?.fry ?? EMPTY_STATE() + }; }); - return `
- -
- -
-
- 🔍 - -
- - -
-
-
- - ${headerHtml} - ${rowsHtml} -
-
- - -
`; + return { format: metadata.format === 'ipynb' || metadata.format === 'frynb' ? metadata.format : this.defaultFormat, kernel, root, indent: metadata.fry?.indent, cells }; } +} - private formatVisualToHtml(visual: any): string { - const data = visual?.Data || visual?.data; - if (!data) return ''; - const mime = visual?.MimeType || visual?.mimeType || ''; - const escape = (s: string) => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - const title = escape(data.title || data.Title || (mime.includes('plot3d') ? '3D Plot' : 'Chart')); - const palette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#2dd4bf']; - - if (mime.includes('chart') && Array.isArray(data.series)) { - const isBar = data.kind === 'bar'; - const isPie = data.kind === 'pie' || data.kind === 'donut'; - if (isPie) { - const s = data.series[0] || {}; - const yVals = (s.y || []).filter((v: any) => typeof v === 'number' && v > 0); - const total = yVals.reduce((a: number, b: number) => a + b, 0) || 1; - let angle = -Math.PI / 2; - let paths = ''; - let leg = ''; - (s.y || []).forEach((v: number, idx: number) => { - if (typeof v !== 'number' || v <= 0) return; - const slice = (v / total) * 2 * Math.PI; - const next = angle + slice; - const col = palette[idx % palette.length]; - const x1 = 260 + 110 * Math.cos(angle); - const y1 = 150 + 110 * Math.sin(angle); - const x2 = 260 + 110 * Math.cos(next); - const y2 = 150 + 110 * Math.sin(next); - const lbl = s.labels?.[idx] || `Item ${idx + 1}`; - const pct = ((v / total) * 100).toFixed(1); - const large = slice > Math.PI ? 1 : 0; - paths += `${escape(lbl)}: ${v} (${pct}%)`; - leg += `
${escape(lbl)} (${pct}%)
`; - angle = next; - }); - const uid = 'fry_pie_' + Math.random().toString(36).slice(2, 9); - return `
- -
📊 ${title}
-
- ${paths} -
${leg}
-
-
`; - } - - let minY = Infinity, maxY = -Infinity; - let maxLen = 0; - data.series.forEach((s: any) => { - const y = s.y || []; - maxLen = Math.max(maxLen, y.length); - y.forEach((v: any) => { - if (typeof v === 'number' && Number.isFinite(v)) { - minY = Math.min(minY, v); - maxY = Math.max(maxY, v); - } - }); - }); - if (minY > maxY) { minY = 0; maxY = 100; } - if (Math.abs(maxY - minY) < 1e-9) { maxY += 10; minY -= 10; } - if (minY > 0 && isBar) minY = 0; - - let seriesSvg = ''; - if (isBar && maxLen > 0) { - const gw = 640 / maxLen; - const bw = Math.max(4, (gw * 0.7) / data.series.length); - data.series.forEach((s: any, sIdx: number) => { - const col = s.color || palette[sIdx % palette.length]; - (s.y || []).forEach((y: any, pIdx: number) => { - if (typeof y !== 'number') return; - const bx = 60 + pIdx * gw + (gw * 0.15) + sIdx * bw; - const zy = 30 + ((maxY - 0) / (maxY - minY)) * 260; - const by = 30 + ((maxY - y) / (maxY - minY)) * 260; - const top = Math.min(zy, by); - const h = Math.abs(zy - by); - seriesSvg += `${escape(s.name || '')}: ${y}`; - }); - }); - } else if (maxLen > 1) { - data.series.forEach((s: any, sIdx: number) => { - const col = s.color || palette[sIdx % palette.length]; - const y = s.y || []; - const pts: string[] = []; - y.forEach((val: any, pIdx: number) => { - if (typeof val !== 'number') return; - const px = 60 + (pIdx / (maxLen - 1)) * 640; - const py = 30 + ((maxY - val) / (maxY - minY)) * 260; - pts.push(`${px.toFixed(1)},${py.toFixed(1)}`); - }); - if (pts.length > 1) { - seriesSvg += ``; - } - }); - } - - return `
-
📊 ${title}
- - - ${seriesSvg} - -
`; - } - - if (mime.includes('plot3d') && data.surface?.z) { - const zGrid = data.surface.z; - const rows = zGrid.length; - const cols = zGrid[0]?.length || 0; - let minZ = Infinity, maxZ = -Infinity; - zGrid.forEach((row: any[]) => row.forEach(v => { - if (typeof v === 'number') { minZ = Math.min(minZ, v); maxZ = Math.max(maxZ, v); } - })); - if (minZ >= maxZ) { minZ = -1; maxZ = 1; } - - const uid = 'fry_p3d_' + Math.random().toString(36).slice(2, 9); - const gridData = JSON.stringify(zGrid.map((row: any[], r: number) => - row.map((zv: any, c: number) => { - const u = (c / (cols - 1)) * 2.0 - 1.0; - const v = (r / (rows - 1)) * 2.0 - 1.0; - const zNorm = ((typeof zv === 'number' ? zv : 0) - minZ) / (maxZ - minZ); - return [Number(u.toFixed(3)), Number(v.toFixed(3)), Number(zNorm.toFixed(3))]; - }) - )); - - return `
-
-
🏔️ ${title}
-
-
- 🔄 Rotate - -
-
- 📐 Tilt - -
-
-
- - - - -
`; - } - - return ''; - } +export function toVsCodeOutput(output: CellOutput): vscode.NotebookCellOutput { + return new vscode.NotebookCellOutput( + output.items.map((item) => new vscode.NotebookCellOutputItem(item.data, item.mime)), + output.metadata + ); } +function fromVsCodeOutput(output: vscode.NotebookCellOutput): CellOutput { + const items = output.items.map((item) => ({ mime: item.mime, data: item.data })); + return output.metadata && Object.keys(output.metadata).length > 0 ? { items, metadata: output.metadata } : { items }; +} diff --git a/vscode-frysharp/src/notebook/outputSettings.ts b/vscode-frysharp/src/notebook/outputSettings.ts new file mode 100644 index 00000000..413a74f4 --- /dev/null +++ b/vscode-frysharp/src/notebook/outputSettings.ts @@ -0,0 +1,4 @@ +import * as vscode from 'vscode'; + +/** Whether a cell with several kinds of output shows them as tabs in one output (the setting `frysharp.notebook.outputTabs`, on unless turned off). */ +export const outputTabsEnabled = (): boolean => vscode.workspace.getConfiguration('frysharp.notebook').get('outputTabs', true); diff --git a/vscode-frysharp/src/notebook/runHub.ts b/vscode-frysharp/src/notebook/runHub.ts new file mode 100644 index 00000000..e06fda2a --- /dev/null +++ b/vscode-frysharp/src/notebook/runHub.ts @@ -0,0 +1,174 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { newId } from './format/model'; +import { placeDiagnostics } from './cellDiagnostics'; +import type { CellProblems } from '../language/cellProblems'; +import { NotebookIdentity } from './identity'; +import { LiveOutputs, OutputSink } from './liveOutputs'; +import { outputTabsEnabled } from './outputSettings'; +import { CellOutput, OutputItem } from './format/model'; +import { toVsCodeOutput } from './notebookSerializer'; +import { SequencedQueue } from './sequencedQueue'; +import { DiagnosticDto, InputRequest, NotebookOutputNotification } from '../sidecar/protocol'; +import { SidecarClient } from '../sidecar/sidecarClient'; + +type Heard = { kind: 'output'; notification: NotebookOutputNotification } | { kind: 'state' }; + +/** One run of one cell, from the start of its execution to the end of it. */ +export interface Run { + id: string; + execution: vscode.NotebookCellExecution; + cell: vscode.NotebookCell; + identity: NotebookIdentity; + /** What the cell has made, and how it is shown: as outputs one after another, or (when there are several kinds) as one output drawn as tabs. */ + live: LiveOutputs; + queue: SequencedQueue; +} + +/** + * What the engine says about the cells that are running, and what comes back from the person using them: the output + * (in order, however it arrives), the input a cell asks for, and the places in a cell's code where it went wrong. Shared + * by the controllers of every notebook type, so a run is found by its id wherever it began. + */ +export class RunHub implements vscode.Disposable { + private readonly runs = new Map(); + private readonly runningEmitter = new vscode.EventEmitter(); + private readonly endedEmitter = new vscode.EventEmitter(); + private readonly disposables: vscode.Disposable[]; + + /** The number of cells running (in all notebooks), when it changes. */ + readonly onDidChangeRunning = this.runningEmitter.event; + + /** A cell finished (or failed): the notebook it ran in, whose variables may have changed. */ + readonly onDidEndRun = this.endedEmitter.event; + + constructor( + sidecar: SidecarClient, + private readonly problems: CellProblems + ) { + this.disposables = [ + sidecar.onNotebookOutput((n) => this.heard(n.executionId, n.seq, { kind: 'output', notification: n })), + sidecar.onCellState((n) => this.heard(n.executionId, n.seq, { kind: 'state' })), + // an error marked when a cell ran is for the code as it was run: gone when the code changes (the live check marks what is wrong now) + vscode.workspace.onDidChangeTextDocument((e) => this.problems.clearRun(e.document.uri)), + this.runningEmitter, + this.endedEmitter + ]; + sidecar.setInputHandler((request) => this.askForInput(request)); + } + + get running(): number { + return this.runs.size; + } + + begin(execution: vscode.NotebookCellExecution, cell: vscode.NotebookCell, identity: NotebookIdentity): Run { + this.problems.clearRun(cell.document.uri); + const run: Run = { + id: newId(), + execution, + cell, + identity, + live: new LiveOutputs(new ExecutionSink(execution), { tabs: outputTabsEnabled() }), + queue: new SequencedQueue((heard) => this.show(run, heard)) + }; + this.runs.set(run.id, run); + this.runningEmitter.fire(this.runs.size); + return run; + } + + end(run: Run): void { + this.runs.delete(run.id); + this.runningEmitter.fire(this.runs.size); + this.endedEmitter.fire(run.identity); + } + + // ── What the engine says while a cell runs ───────────────────────────────────────────────────────────────────── + + private heard(executionId: string | undefined, seq: number | undefined, what: Heard): void { + const run = executionId ? this.runs.get(executionId) : undefined; + run?.queue.push(seq, what); + } + + private async show(run: Run, heard: Heard): Promise { + if (heard.kind !== 'output') return; + const output = heard.notification.output; + + if (output.kind === 'stream' && output.text) { + await run.live.show({ kind: 'stream', text: output.text }); + } else if (output.kind === 'display' && output.data) { + await run.live.show({ kind: 'display', data: output.data as Record, metadata: output.metadata as Record | undefined, displayId: output.displayId, update: output.update }); + } + } + + /** The cell has ended: what was waiting to be shown is, and a console cut short while it ran is whole. */ + finish(run: Run): Promise { + return run.live.finish(); + } + + // ── Input ────────────────────────────────────────────────────────────────────────────────────────────────────── + + private async askForInput(request: InputRequest): Promise { + const run = request.executionId ? this.runs.get(request.executionId) : undefined; + const where = run ? `${path.basename(run.identity.path)}, cell ${run.cell.index + 1}` : 'a notebook cell'; + const answer = await vscode.window.showInputBox( + { + title: `FrySharp: input for ${where}`, + prompt: request.prompt || 'The cell is waiting for a line of input.', + placeHolder: 'Enter sends the line; Escape is the end of input', + password: request.password, + ignoreFocusOut: true + }, + run?.execution.token + ); + return answer === undefined ? null : answer; // Escape, or Stop: the cell reads the end of input + } + + // ── Where a cell went wrong ──────────────────────────────────────────────────────────────────────────────────── + + markErrors(cell: vscode.NotebookCell, diagnostics: DiagnosticDto[] | undefined): void { + const placed = placeDiagnostics(diagnostics, cell.document.getText()); + if (placed.length === 0) return; + + const severity = { error: vscode.DiagnosticSeverity.Error, warning: vscode.DiagnosticSeverity.Warning, information: vscode.DiagnosticSeverity.Information }; + this.problems.setRun( + cell.document.uri, + placed.map((p) => { + const diagnostic = new vscode.Diagnostic(new vscode.Range(p.startLine, p.startCharacter, p.endLine, p.endCharacter), p.message, severity[p.severity]); + diagnostic.code = p.code; + diagnostic.source = 'FrySharp'; + return diagnostic; + }) + ); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} + +/** Where a run's output goes: the cell's execution in VS Code. */ +class ExecutionSink implements OutputSink { + constructor(private readonly execution: vscode.NotebookCellExecution) {} + + async append(output: CellOutput): Promise { + const made = toVsCodeOutput(output); + await this.execution.appendOutput(made); + return made; + } + + async appendItems(handle: vscode.NotebookCellOutput, items: OutputItem[]): Promise { + await this.execution.appendOutputItems(items.map(toItem), handle); + } + + async replaceItems(handle: vscode.NotebookCellOutput, items: OutputItem[]): Promise { + await this.execution.replaceOutputItems(items.map(toItem), handle); + } + + async replaceAll(outputs: CellOutput[]): Promise { + const made = outputs.map(toVsCodeOutput); + await this.execution.replaceOutput(made); + return made; + } +} + +const toItem = (item: OutputItem): vscode.NotebookCellOutputItem => new vscode.NotebookCellOutputItem(item.data, item.mime); diff --git a/vscode-frysharp/src/notebook/scratchpad.ts b/vscode-frysharp/src/notebook/scratchpad.ts new file mode 100644 index 00000000..fd696d27 --- /dev/null +++ b/vscode-frysharp/src/notebook/scratchpad.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode'; +import { NOTEBOOK_TYPES } from './notebookController'; + +/** The language a cell gets for an editor's language: only the ones the engine runs in a notebook. */ +const CELL_LANGUAGES = new Set(['csharp', 'python', 'javascript']); + +/** + * The scratchpad: one untitled notebook that keeps its kernels, to try code in without a file (the desktop's Scratchpad + * and REPL). "Send selection" puts the selected code in a new cell at its end and runs it. + */ +export class Scratchpad implements vscode.Disposable { + private notebook?: vscode.NotebookDocument; + private readonly disposables: vscode.Disposable[] = []; + + constructor() { + this.disposables.push( + vscode.workspace.onDidCloseNotebookDocument((closed) => { + if (closed === this.notebook) this.notebook = undefined; + }) + ); + } + + /** The scratchpad, shown: made the first time, and when it was closed. */ + async open(): Promise { + if (!this.notebook || this.notebook.isClosed) { + const first = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '', 'csharp'); + this.notebook = await vscode.workspace.openNotebookDocument(NOTEBOOK_TYPES[0], new vscode.NotebookData([first])); + } + await vscode.window.showNotebookDocument(this.notebook, { preview: false, preserveFocus: false }); + return this.notebook; + } + + /** Runs the selected code (the whole line when nothing is selected) in the scratchpad. */ + async send(editor: vscode.TextEditor): Promise { + const language = CELL_LANGUAGES.has(editor.document.languageId) ? editor.document.languageId : undefined; + if (!language) { + void vscode.window.showWarningMessage('The scratchpad runs C#, Python and JavaScript.'); + return; + } + const selection = editor.selection; + const code = selection.isEmpty ? editor.document.lineAt(selection.active.line).text : editor.document.getText(selection); + if (!code.trim()) return; + + const notebook = await this.open(); + // an empty last cell is where the code goes; else a new one after it + const last = notebook.cellCount > 0 ? notebook.cellAt(notebook.cellCount - 1) : undefined; + const reuse = last !== undefined && last.kind === vscode.NotebookCellKind.Code && last.document.getText().trim() === '' && last.outputs.length === 0; + const index = reuse ? notebook.cellCount - 1 : notebook.cellCount; + + const edit = new vscode.WorkspaceEdit(); + const cell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, code, language); + edit.set(notebook.uri, [vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(index, reuse ? index + 1 : index), [cell])]); + await vscode.workspace.applyEdit(edit); + void vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 1 }], document: notebook.uri }); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/vscode-frysharp/src/notebook/sequencedQueue.ts b/vscode-frysharp/src/notebook/sequencedQueue.ts new file mode 100644 index 00000000..82a40994 --- /dev/null +++ b/vscode-frysharp/src/notebook/sequencedQueue.ts @@ -0,0 +1,69 @@ +/** + * Handles the notifications of one run in the order they were made, one at a time, whatever order they arrive in, and + * says when everything up to a number has been handled: the engine's reply names the last notification (`lastSeq`) and + * may arrive before some of them. A notification with no number (an older engine) is handled in the order it arrived. + */ +export class SequencedQueue { + private next = 1; + private readonly waiting = new Map(); + private draining = false; + private tail: Promise = Promise.resolve(); + private waiters: Array<{ seq: number; resolve: (done: boolean) => void; timer: NodeJS.Timeout }> = []; + + constructor(private readonly handle: (item: T) => Promise) {} + + push(seq: number | undefined, item: T): void { + if (!seq || seq < 1) { + this.tail = this.tail.then(() => this.safely(item)); + return; + } + if (seq < this.next) return; // already handled: a repeat + this.waiting.set(seq, item); + void this.drain(); + } + + /** Resolves true once every notification up to `seq` has been handled, or false if that has not happened in `timeoutMs`. */ + processed(seq: number, timeoutMs = 10_000): Promise { + if (this.next > seq) return Promise.resolve(true); + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w.resolve !== resolve); + resolve(false); + }, timeoutMs); + this.waiters.push({ seq, resolve, timer }); + }); + } + + private async drain(): Promise { + if (this.draining) return; + this.draining = true; + try { + while (this.waiting.has(this.next)) { + const item = this.waiting.get(this.next) as T; + this.waiting.delete(this.next); + await this.safely(item); + this.next++; + this.release(); + } + } finally { + this.draining = false; + } + } + + private release(): void { + const done = this.waiters.filter((w) => this.next > w.seq); + this.waiters = this.waiters.filter((w) => this.next <= w.seq); + for (const waiter of done) { + clearTimeout(waiter.timer); + waiter.resolve(true); + } + } + + private async safely(item: T): Promise { + try { + await this.handle(item); + } catch { + // one notification that cannot be shown must not stop the ones after it + } + } +} diff --git a/vscode-frysharp/src/notebook/statusBar.ts b/vscode-frysharp/src/notebook/statusBar.ts new file mode 100644 index 00000000..f2e84f85 --- /dev/null +++ b/vscode-frysharp/src/notebook/statusBar.ts @@ -0,0 +1,64 @@ +import * as vscode from 'vscode'; +import { isFryNotebook } from './notebookController'; +import { RunHub } from './runHub'; +import { SidecarClient } from '../sidecar/sidecarClient'; + +/** What the status bar says about the engine, for the state it is in. */ +export function describeEngine(state: 'stopped' | 'starting' | 'ready' | 'crashed', running: number): { text: string; tooltip: string } { + switch (state) { + case 'starting': + return { text: '$(sync~spin) FrySharp: starting', tooltip: 'The FrySharp engine is starting.' }; + case 'crashed': + return { text: '$(error) FrySharp: stopped', tooltip: 'The FrySharp engine stopped. It starts again when you run a cell; variables are gone.' }; + case 'ready': + return running > 0 + ? { text: `$(loading~spin) FrySharp: running${running > 1 ? ` (${running})` : ''}`, tooltip: 'A cell is running.' } + : { text: '$(check) FrySharp: ready', tooltip: 'The FrySharp engine is ready.' }; + default: + return { text: '$(circle-large-outline) FrySharp', tooltip: 'The FrySharp engine starts when a notebook opens or a cell runs.' }; + } +} + +/** One item that says whether the engine is ready, starting, busy or gone, while a FrySharp notebook is in front; a click opens its menu. */ +export class EngineStatusBar implements vscode.Disposable { + private readonly item = vscode.window.createStatusBarItem('frysharp.engine', vscode.StatusBarAlignment.Left, 100); + private readonly disposables: vscode.Disposable[] = []; + + constructor( + private readonly sidecar: SidecarClient, + private readonly hub: RunHub + ) { + this.item.name = 'FrySharp Engine'; + this.item.command = 'frysharp.engineMenu'; + this.disposables.push( + this.item, + sidecar.onDidChangeState(() => this.update()), + sidecar.onDidInitialize(() => this.update()), + hub.onDidChangeRunning(() => this.update()), + vscode.window.onDidChangeActiveNotebookEditor(() => this.update()) + ); + this.update(); + } + + /** The text on show: for tests, and nothing else. */ + get text(): string { + return this.item.text; + } + + private update(): void { + const notebook = vscode.window.activeNotebookEditor?.notebook; + if (!notebook || !isFryNotebook(notebook)) { + this.item.hide(); + return; + } + const { text, tooltip } = describeEngine(this.sidecar.state, this.hub.running); + this.item.text = text; + this.item.tooltip = this.sidecar.serverVersion ? `${tooltip}\nEngine ${this.sidecar.serverVersion}: ${this.sidecar.languages.filter((l) => l.canRunCells).map((l) => l.displayName).join(', ')}` : tooltip; + this.item.backgroundColor = this.sidecar.state === 'crashed' ? new vscode.ThemeColor('statusBarItem.errorBackground') : undefined; + this.item.show(); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/vscode-frysharp/src/notebook/variablesTree.ts b/vscode-frysharp/src/notebook/variablesTree.ts new file mode 100644 index 00000000..ea13d9b5 --- /dev/null +++ b/vscode-frysharp/src/notebook/variablesTree.ts @@ -0,0 +1,112 @@ +import * as vscode from 'vscode'; +import { isFryNotebook, notebookIdentity } from './notebookController'; +import { RunHub } from './runHub'; +import { VariableDto } from '../sidecar/protocol'; +import { SidecarClient } from '../sidecar/sidecarClient'; + +type Node = { kind: 'kernel'; kernel: string; variables: VariableDto[] } | { kind: 'variable'; variable: VariableDto } | { kind: 'message'; text: string }; + +/** What a variable looks like in a line: its value on one line, cut where a line has no more room. */ +export function preview(value: string, max = 80): string { + const line = value.replace(/\s+/g, ' ').trim(); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +/** + * The variables of the notebook in front, by the language each lives in. It is asked again whenever a cell ends in that + * notebook, or another notebook comes to the front: never by a timer, never for a notebook nobody is looking at. + */ +export class VariablesTree implements vscode.TreeDataProvider, vscode.Disposable { + private readonly changed = new vscode.EventEmitter(); + private readonly disposables: vscode.Disposable[] = []; + private pending?: NodeJS.Timeout; + + readonly onDidChangeTreeData = this.changed.event; + + constructor( + private readonly sidecar: SidecarClient, + hub: RunHub + ) { + this.disposables.push( + this.changed, + hub.onDidEndRun((identity) => { + if (identity.notebookId === this.active()?.notebookId) this.refresh(); + }), + vscode.window.onDidChangeActiveNotebookEditor(() => { + void vscode.commands.executeCommand('setContext', 'frysharp.notebookActive', this.active() !== undefined); + this.refresh(); + }), + this.sidecar.onDidChangeState(() => this.refresh()) + ); + void vscode.commands.executeCommand('setContext', 'frysharp.notebookActive', this.active() !== undefined); + } + + private active(): { notebookId: string; name: string } | undefined { + const notebook = vscode.window.activeNotebookEditor?.notebook; + if (!notebook || !isFryNotebook(notebook)) return undefined; + const identity = notebookIdentity(notebook); + return { notebookId: identity.notebookId, name: identity.path }; + } + + /** Asks again (once, however many reasons come at the same time). */ + refresh(): void { + clearTimeout(this.pending); + this.pending = setTimeout(() => this.changed.fire(undefined), 150); + } + + async getChildren(node?: Node): Promise { + if (node?.kind === 'kernel') return node.variables.map((variable) => ({ kind: 'variable', variable })); + if (node) return []; + + const notebook = this.active(); + if (!notebook) return [{ kind: 'message', text: 'Open a FrySharp notebook to see its variables.' }]; + if (this.sidecar.state !== 'ready') return [{ kind: 'message', text: 'The engine has not started: run a cell.' }]; + + let variables: VariableDto[]; + try { + variables = await this.sidecar.getVariables({ notebookId: notebook.notebookId }); + } catch { + return [{ kind: 'message', text: 'The notebook has no kernel yet: run a cell.' }]; + } + if (variables.length === 0) return [{ kind: 'message', text: 'No variables yet: run a cell that defines one.' }]; + + const byKernel = new Map(); + for (const variable of variables) { + byKernel.set(variable.kernel, [...(byKernel.get(variable.kernel) ?? []), variable]); + } + return [...byKernel.entries()].map(([kernel, list]) => ({ kind: 'kernel', kernel, variables: list })); + } + + getTreeItem(node: Node): vscode.TreeItem { + if (node.kind === 'message') { + const item = new vscode.TreeItem(node.text); + item.iconPath = new vscode.ThemeIcon('info'); + return item; + } + if (node.kind === 'kernel') { + const item = new vscode.TreeItem(node.kernel, vscode.TreeItemCollapsibleState.Expanded); + item.description = `${node.variables.length}`; + item.iconPath = new vscode.ThemeIcon('symbol-namespace'); + return item; + } + + const { variable } = node; + const item = new vscode.TreeItem(variable.name); + item.description = `${variable.type} ${preview(variable.value)}`; + item.tooltip = new vscode.MarkdownString().appendMarkdown(`**${variable.name}** \`${variable.type}\` (${variable.kernel})\n\n`).appendCodeblock(variable.value, 'text'); + item.iconPath = new vscode.ThemeIcon('symbol-variable'); + item.contextValue = 'frysharp.variable'; + return item; + } + + /** The variable a tree item stands for, for the commands on it. */ + static variableOf(node: unknown): VariableDto | undefined { + const candidate = node as Node | undefined; + return candidate?.kind === 'variable' ? candidate.variable : undefined; + } + + dispose(): void { + clearTimeout(this.pending); + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/vscode-frysharp/src/renderer/assets.ts b/vscode-frysharp/src/renderer/assets.ts new file mode 100644 index 00000000..89bb64c3 --- /dev/null +++ b/vscode-frysharp/src/renderer/assets.ts @@ -0,0 +1,16 @@ +/** The libraries the engine's pages name (`data-fry-asset`), shipped with the extension so they work offline. */ +const KNOWN = new Set(['echarts.min.js', 'echarts-gl.min.js']); +const loading = new Map>(); + +/** The text of a library, fetched once from beside this module (dist/assets); undefined if it is not one of ours or cannot be read. */ +export function loadAsset(name: string): Promise { + if (!KNOWN.has(name)) return Promise.resolve(undefined); + let text = loading.get(name); + if (!text) { + text = fetch(new URL(`./assets/${name}`, import.meta.url)) + .then((response) => (response.ok ? response.text() : undefined)) + .catch(() => undefined); + loading.set(name, text); + } + return text; +} diff --git a/vscode-frysharp/src/renderer/chooseMime.ts b/vscode-frysharp/src/renderer/chooseMime.ts new file mode 100644 index 00000000..4e42b045 --- /dev/null +++ b/vscode-frysharp/src/renderer/chooseMime.ts @@ -0,0 +1,21 @@ +export const TABLE_MIME = 'application/vnd.fry.table+json'; +export const INSPECTOR_MIME = 'application/vnd.fry.inspector+json'; + +export type Drawing = 'table' | 'inspector' | 'html' | 'markdown' | 'image' | 'text'; + +/** What the renderer draws a display bundle (MIME type → content) as: the richest of what it holds. */ +export function chooseDrawing(mimes: readonly string[]): { drawing: Drawing; mime: string } | undefined { + const order: Array<[string, Drawing]> = [ + [TABLE_MIME, 'table'], + [INSPECTOR_MIME, 'inspector'], + ['text/html', 'html'], + ['image/svg+xml', 'html'], + ['image/png', 'image'], + ['image/jpeg', 'image'], + ['image/gif', 'image'], + ['text/markdown', 'markdown'], + ['text/plain', 'text'] + ]; + for (const [mime, drawing] of order) if (mimes.includes(mime)) return { drawing, mime }; + return undefined; +} diff --git a/vscode-frysharp/src/renderer/console.ts b/vscode-frysharp/src/renderer/console.ts new file mode 100644 index 00000000..a23fb60e --- /dev/null +++ b/vscode-frysharp/src/renderer/console.ts @@ -0,0 +1,59 @@ +import { AnsiSpan, applyCarriageReturns, countLines, parseAnsi } from '../shared/outputTabs'; +import { el } from './dom'; + +/** A console longer than this shows its last lines, with a button for the rest: a flood of lines would make the page slow for nothing. */ +export const MAX_CONSOLE_LINES = 5000; + +function spanElement(span: AnsiSpan): HTMLElement | Text { + if (!span.fg && !span.bg && !span.bold && !span.dim && !span.italic && !span.underline) return document.createTextNode(span.text); + const element = el('span', undefined, span.text); + if (span.fg) element.style.color = span.fg; + if (span.bg) element.style.backgroundColor = span.bg; + if (span.bold) element.style.fontWeight = '700'; + if (span.dim) element.style.opacity = '0.7'; + if (span.italic) element.style.fontStyle = 'italic'; + if (span.underline) element.style.textDecoration = 'underline'; + return element; +} + +export interface ConsoleState { + /** The person asked for every line of a long console. */ + showAll: boolean; +} + +/** + * What a cell printed, as a terminal shows it: colours and styles from ANSI codes, a progress bar redrawn in place. `omitted` is the + * number of lines left off the start by the sender (while a cell runs), `state` remembers a click on "Show all lines". + */ +export function renderConsole(text: string, omitted: number, root: HTMLElement, state: ConsoleState, redraw: () => void): void { + const box = el('div', 'fry-console fry-mono'); + const screen = applyCarriageReturns(text); + const total = countLines(screen); + let shown = screen; + let hidden = 0; + + if (!state.showAll && total > MAX_CONSOLE_LINES) { + const lines = screen.split('\n'); + const trailing = screen.endsWith('\n'); + const count = trailing ? lines.length - 1 : lines.length; + shown = lines.slice(count - MAX_CONSOLE_LINES, trailing ? count : undefined).join('\n') + (trailing ? '\n' : ''); + hidden = count - MAX_CONSOLE_LINES; + } + + if (omitted > 0) box.append(el('div', 'fry-note', `… ${omitted.toLocaleString('en-US')} earlier lines are shown when the cell has finished`)); + if (hidden > 0) { + const more = el('button', 'fry-button fry-more', `Show all ${total.toLocaleString('en-US')} lines (${hidden.toLocaleString('en-US')} more at the start)`, { type: 'button' }); + more.addEventListener('click', () => { + state.showAll = true; + redraw(); + }); + box.append(more); + } + + const pre = el('pre', 'fry-pre'); + const spans = parseAnsi(shown); + if (spans.length === 0) pre.append(document.createTextNode('')); + for (const span of spans) pre.append(spanElement(span)); + box.append(pre); + root.replaceChildren(box); +} diff --git a/vscode-frysharp/src/renderer/dom.ts b/vscode-frysharp/src/renderer/dom.ts new file mode 100644 index 00000000..bf2d028f --- /dev/null +++ b/vscode-frysharp/src/renderer/dom.ts @@ -0,0 +1,49 @@ +import { CSS, STYLE_ID } from './styles'; + +/** Makes an element: its class, text and attributes. */ +export function el(tag: K, className?: string, text?: string, attributes?: Record): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + if (attributes) for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value); + return element; +} + +/** Puts the renderer's styles in the page, once. */ +export function ensureStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = el('style'); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); +} + +/** Puts text on the clipboard: the clipboard API where the page may use it, else the old way. Says whether it worked. */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const area = el('textarea'); + area.value = text; + area.style.cssText = 'position:fixed;opacity:0;pointer-events:none'; + document.body.appendChild(area); + area.select(); + try { + return document.execCommand('copy'); + } finally { + area.remove(); + } + } +} + +/** A button that copies something, and says so for a moment. */ +export function copyButton(label: string, text: () => string): HTMLButtonElement { + const button = el('button', 'fry-button', label, { type: 'button', title: `Copy as ${label}` }); + button.addEventListener('click', async () => { + const worked = await copyText(text()); + button.textContent = worked ? '✓ Copied' : 'Could not copy'; + setTimeout(() => (button.textContent = label), 1500); + }); + return button; +} diff --git a/vscode-frysharp/src/renderer/html.ts b/vscode-frysharp/src/renderer/html.ts new file mode 100644 index 00000000..05d425f2 --- /dev/null +++ b/vscode-frysharp/src/renderer/html.ts @@ -0,0 +1,80 @@ +import { loadAsset } from './assets'; +import { el } from './dom'; +import { assetNames, hexColour, HostTheme, inlineAssets, isFullDocument, themeOf, withHost, withProbe } from './htmlPrepare'; + +function hostTheme(): HostTheme { + const kind = document.body?.dataset.vscodeThemeKind; + const background = hexColour(getComputedStyle(document.documentElement).getPropertyValue('--vscode-editor-background')); + return { theme: themeOf(kind), background }; +} + +/** + * HTML an engine wrote. A whole page (a chart is one, built to fill its own window) is drawn in a frame with no access to + * the notebook, its libraries written into it and told what the editor looks like; a piece of a page (a visual's SVG and its + * script) is put where it goes, with its scripts run, in order. + */ +export async function renderHtml(html: string, root: HTMLElement): Promise<{ chartDrawn?: boolean }> { + const texts = new Map(); + await Promise.all( + assetNames(html).map(async (name) => { + const text = await loadAsset(name); + if (text !== undefined) texts.set(name, text); + }) + ); + + const host = hostTheme(); + if (isFullDocument(html)) { + const frame = el('iframe', 'fry-frame', undefined, { sandbox: 'allow-scripts', title: 'Chart' }); + const token = Math.random().toString(36).slice(2); + const drawn = chartDrawn(frame, token); + frame.srcdoc = withProbe(withHost(inlineAssets(html, texts), host), token); + root.replaceChildren(frame); + return { chartDrawn: await drawn }; + } + + (window as unknown as { fryHost?: HostTheme }).fryHost = host; + const template = document.createElement('template'); + template.innerHTML = html; + const wrapper = el('div', 'fry'); + wrapper.append(template.content); + root.replaceChildren(wrapper); + await runScripts(wrapper, texts); + return {}; +} + +// The page in the frame says, once it has run, whether it drew a chart; false if it says nothing in a few seconds. +function chartDrawn(frame: HTMLIFrameElement, token: string): Promise { + return new Promise((resolve) => { + const finish = (result: boolean) => { + window.removeEventListener('message', onMessage); + clearTimeout(timer); + resolve(result); + }; + const onMessage = (event: MessageEvent) => { + if (event.source === frame.contentWindow && event.data?.fryProbe === token) finish(event.data.drawn === true); + }; + const timer = setTimeout(() => finish(false), 5000); + window.addEventListener('message', onMessage); + }); +} + +// Scripts put in a page by innerHTML do not run: make each one again, in order, waiting for those that load a file. +async function runScripts(container: HTMLElement, texts: ReadonlyMap): Promise { + for (const old of Array.from(container.querySelectorAll('script'))) { + const asset = old.getAttribute('data-fry-asset'); + const made = document.createElement('script'); + for (const attribute of Array.from(old.attributes)) made.setAttribute(attribute.name, attribute.value); + + if (asset !== null) { + const text = texts.get(asset); + if (text === undefined) continue; // the page says the library did not load + made.textContent = text; + } else { + made.textContent = old.textContent; + } + + const loaded = made.src ? new Promise((resolve) => ((made.onload = () => resolve()), (made.onerror = () => resolve()))) : undefined; + old.replaceWith(made); + if (loaded) await loaded; + } +} diff --git a/vscode-frysharp/src/renderer/htmlPrepare.ts b/vscode-frysharp/src/renderer/htmlPrepare.ts new file mode 100644 index 00000000..be93d68b --- /dev/null +++ b/vscode-frysharp/src/renderer/htmlPrepare.ts @@ -0,0 +1,67 @@ +// Getting an engine page ready to draw in a notebook: the pieces of text work, apart from the DOM that uses them. + +/** The libraries the engine's pages name with `` instead of carrying them. */ +export const ASSET_TAG = /`; + }); +} + +export interface HostTheme { + theme: 'dark' | 'light'; + background?: string; +} + +/** The script that tells a page what the editor looks like (`window.fryHost`), as the studio does before a page's own scripts run. */ +export function hostScript(host: HostTheme): string { + const background = host.background && /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(host.background) ? `,background:"${host.background}"` : ''; + return ``; +} + +/** The page with `window.fryHost` set first, whether it is a whole document (after ) or a piece. */ +export function withHost(html: string, host: HostTheme): string { + const script = hostScript(host); + const head = /]*>/i.exec(html); + return head ? html.slice(0, head.index + head[0].length) + script + html.slice(head.index + head[0].length) : script + html; +} + +/** The theme VS Code says it has (the output's `data-vscode-theme-kind`), as dark or light. */ +export function themeOf(kind: string | undefined): 'dark' | 'light' { + return kind === 'vscode-light' || kind === 'vscode-high-contrast-light' ? 'light' : 'dark'; +} + +/** A colour from the editor in the form a page can read (`#rrggbb`), or nothing when it is not a plain hex or rgb colour. */ +export function hexColour(value: string | undefined): string | undefined { + if (!value) return undefined; + const text = value.trim(); + if (/^#[0-9a-fA-F]{6}$/.test(text)) return text; + if (/^#[0-9a-fA-F]{3}$/.test(text)) return '#' + [...text.slice(1)].map((c) => c + c).join(''); + const rgb = /^rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/.exec(text); + return rgb ? '#' + [rgb[1], rgb[2], rgb[3]].map((n) => Math.min(255, +n).toString(16).padStart(2, '0')).join('') : undefined; +} + +/** + * Adds, at the end of a whole page, a script that tells the frame's parent whether a chart was drawn (the engine's chart pages + * set `window.__fryChart`): the renderer waits for it, and says so to the extension. `token` says which frame it came from. + */ +export function withProbe(html: string, token: string): string { + const name = JSON.stringify(token).replace(/try{parent.postMessage({fryProbe:${name},drawn:!!window.__fryChart},"*")}catch(e){}`; + const end = html.search(/<\/body\s*>(?![\s\S]*<\/body\s*>)/i); + return end >= 0 ? html.slice(0, end) + probe + html.slice(end) : html + probe; +} diff --git a/vscode-frysharp/src/renderer/index.ts b/vscode-frysharp/src/renderer/index.ts new file mode 100644 index 00000000..db5d5152 --- /dev/null +++ b/vscode-frysharp/src/renderer/index.ts @@ -0,0 +1,60 @@ +import { ensureStyles, el } from './dom'; +import { renderHtml } from './html'; +import { renderInspector } from './inspector'; +import { InspectorNode } from './inspectorModel'; +import { INSPECTOR_MIME, TABLE_MIME } from './chooseMime'; +import { renderTable } from './table'; +import { TableData } from './tableModel'; +import { CELL_OUTPUT_MIME, renderTabs } from './tabs'; +import { PackedCell } from '../shared/outputTabs'; + +export { renderBundle } from './render'; + +// What VS Code gives a notebook renderer (the parts used: types/vscode-notebook-renderer is not needed for these). +interface OutputItem { + id: string; + mime: string; + text(): string; + json(): unknown; +} + +interface RendererContext { + postMessage?(message: unknown): void; +} + +/** + * The FrySharp output renderer: the Object Explorer and the table as interactive pieces of the notebook, and the engine's + * HTML (charts, visuals) with its libraries and the editor's theme. It tells the extension each time it has drawn something, + * so a test (or a bug report) can see that VS Code really ran it. + */ +export function activate(context?: RendererContext) { + return { + async renderOutputItem(item: OutputItem, element: HTMLElement): Promise { + ensureStyles(); + try { + let chartDrawn: boolean | undefined; + let tabs: { tabs: string[]; selected: string } | undefined; + switch (item.mime) { + case CELL_OUTPUT_MIME: { + const drawn = await renderTabs(item.json() as PackedCell, element); + chartDrawn = drawn.chartDrawn; + tabs = drawn; + break; + } + case TABLE_MIME: + renderTable(item.json() as TableData, element); + break; + case INSPECTOR_MIME: + renderInspector(item.json() as InspectorNode, element); + break; + default: + chartDrawn = (await renderHtml(item.text(), element)).chartDrawn; + } + context?.postMessage?.({ type: 'rendered', mime: item.mime, id: item.id, chartDrawn, tabs: tabs?.tabs, selected: tabs?.selected }); + } catch (error) { + element.replaceChildren(el('div', 'fry fry-error', `FrySharp could not draw this output: ${error instanceof Error ? error.message : String(error)}`)); + context?.postMessage?.({ type: 'error', mime: item.mime, id: item.id, message: error instanceof Error ? error.message : String(error) }); + } + } + }; +} diff --git a/vscode-frysharp/src/renderer/inspector.ts b/vscode-frysharp/src/renderer/inspector.ts new file mode 100644 index 00000000..3aedf44e --- /dev/null +++ b/vscode-frysharp/src/renderer/inspector.ts @@ -0,0 +1,64 @@ +import { copyButton, el } from './dom'; +import { canOpen, InspectorNode, kindOf, startsOpen, toText } from './inspectorModel'; + +/** The Object Explorer: an object as a tree of its members, each nested object opening where it stands. */ +export function renderInspector(node: InspectorNode, root: HTMLElement): void { + const card = el('div', 'fry fry-card fry-inspector'); + + const head = el('div', 'fry-head'); + head.append(el('span', 'fry-mono', '{..}'), el('span', 'fry-title', 'Object Inspector'), el('span', 'fry-spacer'), copyButton('Text', () => toText(node).join('\n'))); + + const tree = el('div', 'fry-tree fry-mono', undefined, { role: 'tree' }); + tree.append(nodeHead(node, 0, undefined)); + card.append(head, tree); + root.replaceChildren(card); +} + +// A node's own line (its chip, and a button to open it when it has members) and, under it, its members: made when it first opens. +function nodeHead(node: InspectorNode, depth: number, name: string | undefined): HTMLElement { + const wrapper = el('div', undefined, undefined, { role: 'none' }); + const line = el('div', depth === 0 ? 'fry-node-head' : 'fry-row', undefined, { role: 'treeitem' }); + const openable = canOpen(node); + const children = el('div', 'fry-children', undefined, { role: 'group' }); + let built = false; + let open = openable && startsOpen(depth, node); + + const toggle = openable ? el('button', 'fry-toggle', undefined, { type: 'button' }) : el('span', 'fry-spacer-toggle'); + const chip = el('span', node.circular ? 'fry-chip fry-circular' : 'fry-chip', `${node.circular ? '↻' : '●'} ${node.title}`); + if (node.circular) chip.title = 'A reference back to an object above: its members are listed again'; + + if (name !== undefined) line.append(toggle, el('span', 'fry-name', name), el('span', 'fry-colon', ':'), chip); + else line.append(toggle, chip); + wrapper.append(line, children); + + const show = () => { + if (open && !built) { + built = true; + for (const row of node.properties) children.append(member(row, depth + 1)); + } + children.hidden = !open; + if (toggle instanceof HTMLButtonElement) { + toggle.textContent = open ? '▼' : '▶'; + toggle.setAttribute('aria-expanded', String(open)); + toggle.title = open ? 'Collapse' : 'Expand'; + } + line.setAttribute('aria-expanded', String(open)); + }; + if (toggle instanceof HTMLButtonElement) { + toggle.addEventListener('click', () => { + open = !open; + show(); + }); + } + show(); + return wrapper; +} + +function member(row: { name: string; value?: string; isNull?: boolean; isString?: boolean; child?: InspectorNode | null }, depth: number): HTMLElement { + if (row.child) return nodeHead(row.child, depth, row.name); + + const line = el('div', 'fry-row', undefined, { role: 'treeitem' }); + const kind = kindOf(row); + line.append(el('span', 'fry-spacer-toggle'), el('span', 'fry-name', row.name), el('span', 'fry-colon', ':'), el('span', `fry-value fry-v-${kind}`, row.isNull ? '' : (row.value ?? ''))); + return line; +} diff --git a/vscode-frysharp/src/renderer/inspectorModel.ts b/vscode-frysharp/src/renderer/inspectorModel.ts new file mode 100644 index 00000000..2a8ca8b4 --- /dev/null +++ b/vscode-frysharp/src/renderer/inspectorModel.ts @@ -0,0 +1,52 @@ +// The Object Explorer apart from drawing it: what kind of value a row has (it is coloured by kind), and which nodes start open. + +/** An Object Explorer node as the engine sends it (application/vnd.fry.inspector+json). */ +export interface InspectorNode { + title: string; + circular?: boolean; + properties: InspectorRow[]; +} + +export interface InspectorRow { + name: string; + value?: string; + isNull?: boolean; + isString?: boolean; + child?: InspectorNode | null; +} + +export type ValueKind = 'null' | 'string' | 'number' | 'boolean' | 'list' | 'plain'; + +/** The kind of a row's value, from what the engine says about it and what the text is. */ +export function kindOf(row: Pick): ValueKind { + if (row.isNull) return 'null'; + if (row.isString) return 'string'; + const text = row.value ?? ''; + if (/^(true|false)$/i.test(text)) return 'boolean'; + if (/^[-+]?(\d[\d,]*\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(text) || text === 'NaN' || text === '∞' || text === '-∞') return 'number'; + if (text.startsWith('[') && text.endsWith(']')) return 'list'; + return 'plain'; +} + +/** The top node is open, and the ones just below it unless they close a cycle; deeper ones start closed (as in the desktop). */ +export function startsOpen(depth: number, node: Pick): boolean { + return depth === 0 || (!node.circular && depth < 2); +} + +/** Whether a node has anything to open. A reference back to an object above lists its members again (down to the engine's depth limit), as the desktop does. */ +export function canOpen(node: Pick): boolean { + return node.properties.length > 0; +} + +/** The text of a node and what is under it, as lines (for Copy). */ +export function toText(node: InspectorNode, indent = 0): string[] { + const lines = [`${' '.repeat(indent)}${node.title}${node.circular ? ' (circular reference)' : ''}`]; + for (const row of node.properties) { + if (row.child) { + lines.push(`${' '.repeat(indent + 2)}${row.name}:`, ...toText(row.child, indent + 4)); + } else { + lines.push(`${' '.repeat(indent + 2)}${row.name} = ${row.isNull ? '' : (row.value ?? '')}`); + } + } + return lines; +} diff --git a/vscode-frysharp/src/renderer/markdown.ts b/vscode-frysharp/src/renderer/markdown.ts new file mode 100644 index 00000000..79b5d28f --- /dev/null +++ b/vscode-frysharp/src/renderer/markdown.ts @@ -0,0 +1,75 @@ +import { el } from './dom'; + +// The Markdown an engine sends (Display.Markdown): headings, paragraphs, lists, quotes, code, emphasis, links. It is built with DOM +// calls and never as HTML text, and a link goes only where http, https and mailto do, so nothing a program prints can run in the page. + +const SAFE_LINK = /^(https?:|mailto:)/i; + +// `code`, **bold**, *italic* / _italic_, [text](url) inside a line +function inline(text: string, into: HTMLElement): void { + const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*)\*|(? last) into.append(text.slice(last, match.index)); + if (match[1] !== undefined) into.append(el('code', 'fry-mono', match[1])); + else if (match[2] !== undefined || match[3] !== undefined) into.append(el('strong', undefined, match[2] ?? match[3])); + else if (match[4] !== undefined || match[5] !== undefined) into.append(el('em', undefined, match[4] ?? match[5])); + else if (match[6] !== undefined) { + if (SAFE_LINK.test(match[7])) into.append(el('a', undefined, match[6], { href: match[7], target: '_blank', rel: 'noreferrer noopener' })); + else into.append(`${match[6]} (${match[7]})`); + } + last = match.index + match[0].length; + } + if (last < text.length) into.append(text.slice(last)); +} + +/** Draws Markdown into an element. */ +export function renderMarkdown(source: string, root: HTMLElement): void { + const page = el('div', 'fry fry-markdown'); + const lines = source.replace(/\r\n/g, '\n').split('\n'); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === '') { + i++; + } else if (/^```/.test(line)) { + const code: string[] = []; + for (i++; i < lines.length && !/^```/.test(lines[i]); i++) code.push(lines[i]); + i++; + page.append(el('pre', 'fry-pre fry-mono', code.join('\n'))); + } else if (/^#{1,6}\s/.test(line)) { + const level = /^#+/.exec(line)![0].length; + const heading = el(`h${level}` as 'h1'); + inline(line.replace(/^#+\s+/, '').trim(), heading); + page.append(heading); + i++; + } else if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + page.append(el('hr')); + i++; + } else if (/^>\s?/.test(line)) { + const quote = el('blockquote'); + const text: string[] = []; + for (; i < lines.length && /^>\s?/.test(lines[i]); i++) text.push(lines[i].replace(/^>\s?/, '')); + inline(text.join(' '), quote); + page.append(quote); + } else if (/^\s*([-*+]|\d+\.)\s+/.test(line)) { + const ordered = /^\s*\d+\./.test(line); + const list = el(ordered ? 'ol' : 'ul'); + for (; i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i]); i++) { + const item = el('li'); + inline(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, ''), item); + list.append(item); + } + page.append(list); + } else { + const paragraph = el('p'); + const text: string[] = []; + for (; i < lines.length && lines[i].trim() !== '' && !/^(```|#{1,6}\s|>\s?|\s*([-*+]|\d+\.)\s+)/.test(lines[i]); i++) text.push(lines[i].trim()); + inline(text.join(' '), paragraph); + page.append(paragraph); + } + } + + root.replaceChildren(page); +} diff --git a/vscode-frysharp/src/renderer/render.ts b/vscode-frysharp/src/renderer/render.ts new file mode 100644 index 00000000..5a82ee9d --- /dev/null +++ b/vscode-frysharp/src/renderer/render.ts @@ -0,0 +1,51 @@ +import { chooseDrawing, INSPECTOR_MIME, TABLE_MIME } from './chooseMime'; +import { ensureStyles, el } from './dom'; +import { renderHtml } from './html'; +import { renderInspector } from './inspector'; +import { renderMarkdown } from './markdown'; +import { InspectorNode } from './inspectorModel'; +import { renderTable } from './table'; +import { TableData } from './tableModel'; + +/** A value of a display bundle as an object: the engine sends the structured types as JSON text or as objects. */ +function asObject(value: unknown): T { + return (typeof value === 'string' ? JSON.parse(value) : value) as T; +} + +/** Draws a display bundle (MIME type → content) in an element, as its richest type. Used for a cell's output, and for the Results panel. */ +export async function renderBundle(data: Record, root: HTMLElement): Promise { + ensureStyles(); + const chosen = chooseDrawing(Object.keys(data)); + if (!chosen) return undefined; + const value = data[chosen.mime]; + + switch (chosen.drawing) { + case 'table': + renderTable(asObject(value), root); + break; + case 'inspector': + renderInspector(asObject(value), root); + break; + case 'html': + await renderHtml(chosen.mime === 'image/svg+xml' ? `
${String(value)}
` : String(value), root); + break; + case 'markdown': + renderMarkdown(String(value), root); + break; + case 'image': { + const image = el('img', 'fry', undefined, { alt: 'Output image' }); + image.src = `data:${chosen.mime};base64,${String(value)}`; + image.style.maxWidth = '100%'; + root.replaceChildren(image); + break; + } + default: { + const text = el('pre', 'fry fry-mono', String(value)); + text.style.whiteSpace = 'pre-wrap'; + root.replaceChildren(text); + } + } + return chosen.mime; +} + +export { INSPECTOR_MIME, TABLE_MIME }; diff --git a/vscode-frysharp/src/renderer/styles.ts b/vscode-frysharp/src/renderer/styles.ts new file mode 100644 index 00000000..53651bbf --- /dev/null +++ b/vscode-frysharp/src/renderer/styles.ts @@ -0,0 +1,95 @@ +// The look of everything the renderer draws: built from the editor's own colours (--vscode-*) so it follows the theme, light, +// dark or high contrast; the fallbacks are Dark+ for where there are none (a page opened outside VS Code). + +export const STYLE_ID = 'fry-renderer-styles'; + +export const CSS = ` +.fry { font-family: var(--vscode-font-family, system-ui, sans-serif); font-size: var(--vscode-font-size, 13px); color: var(--vscode-foreground, #cccccc); line-height: 1.45; } +.fry *, .fry *::before, .fry *::after { box-sizing: border-box; } +.fry-mono { font-family: var(--vscode-editor-font-family, Menlo, Consolas, monospace); font-size: var(--vscode-editor-font-size, 13px); } +.fry-card { margin: 6px 0; border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #454545)); border-radius: 6px; background: var(--vscode-editor-background, #1e1e1e); overflow: hidden; } +.fry-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--vscode-sideBar-background, var(--vscode-editorWidget-background, #252526)); border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #454545)); } +.fry-head .fry-title { font-weight: 600; flex: none; } +.fry-head .fry-spacer { flex: 1; } +.fry-pill { font-size: 11px; padding: 1px 8px; border-radius: 10px; background: var(--vscode-badge-background, #4d4d4d); color: var(--vscode-badge-foreground, #ffffff); white-space: nowrap; } +.fry-button { font: inherit; font-size: 12px; padding: 2px 8px; border-radius: 3px; cursor: pointer; border: 1px solid var(--vscode-button-border, transparent); background: var(--vscode-button-secondaryBackground, #3a3d41); color: var(--vscode-button-secondaryForeground, #cccccc); } +.fry-button:hover { background: var(--vscode-button-secondaryHoverBackground, #45494e); } +.fry-button:focus-visible, .fry-toggle:focus-visible, .fry-sortable:focus-visible, .fry-filter:focus-visible { outline: 1px solid var(--vscode-focusBorder, #007fd4); outline-offset: -1px; } +.fry-filter { font: inherit; font-size: 12px; width: 150px; padding: 2px 8px; border-radius: 3px; border: 1px solid var(--vscode-input-border, var(--vscode-widget-border, #454545)); background: var(--vscode-input-background, #3c3c3c); color: var(--vscode-input-foreground, #cccccc); } +.fry-foot { padding: 4px 10px; font-size: 11px; color: var(--vscode-descriptionForeground, #9d9d9d); border-top: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #454545)); background: var(--vscode-sideBar-background, #252526); } +.fry-error { margin: 6px 0; padding: 8px 10px; white-space: pre-wrap; color: var(--vscode-errorForeground, #f48771); border: 1px solid var(--vscode-inputValidation-errorBorder, #be1100); border-radius: 4px; } + +/* values, by kind */ +.fry-v-string { color: var(--vscode-debugTokenExpression-string, #ce9178); } +.fry-v-number { color: var(--vscode-debugTokenExpression-number, #b5cea8); } +.fry-v-boolean { color: var(--vscode-debugTokenExpression-boolean, #569cd6); } +.fry-v-list { color: var(--vscode-symbolIcon-classForeground, #4ec9b0); } +.fry-v-null { color: var(--vscode-descriptionForeground, #9d9d9d); font-style: italic; } +.fry-v-plain { color: var(--vscode-foreground, #cccccc); } + +/* the Object Explorer */ +.fry-tree { padding: 6px 10px 8px; } +.fry-node-head, .fry-row { display: flex; align-items: baseline; gap: 4px; min-height: 22px; } +.fry-children { margin-left: 11px; padding-left: 12px; border-left: 1px solid var(--vscode-tree-indentGuidesStroke, rgba(128,128,128,.35)); } +.fry-toggle { width: 18px; flex: none; padding: 0; border: 0; background: none; color: var(--vscode-foreground, #cccccc); cursor: pointer; font-size: 10px; line-height: 18px; text-align: center; } +.fry-toggle[disabled] { cursor: default; opacity: .35; } +.fry-spacer-toggle { width: 18px; flex: none; } +.fry-chip { display: inline-flex; align-items: center; gap: 5px; padding: 0 8px; border-radius: 10px; background: var(--vscode-badge-background, #4d4d4d); color: var(--vscode-badge-foreground, #ffffff); font-size: 12px; cursor: default; } +.fry-chip.fry-circular { opacity: .75; } +.fry-name { color: var(--vscode-debugTokenExpression-name, #c586c0); white-space: nowrap; } +.fry-colon { opacity: .7; } +.fry-value { white-space: pre-wrap; word-break: break-word; min-width: 0; } + +/* the table */ +.fry-scroll { max-height: 420px; overflow: auto; } +.fry-table table { width: 100%; border-collapse: collapse; font-size: 12px; } +.fry-table th { position: sticky; top: 0; z-index: 1; text-align: left; font-weight: 600; padding: 6px 12px; white-space: nowrap; user-select: none; background: var(--vscode-sideBar-background, #252526); border-bottom: 2px solid var(--vscode-widget-border, var(--vscode-panel-border, #454545)); } +.fry-table th.fry-sortable { cursor: pointer; } +.fry-table th.fry-sortable:hover { background: var(--vscode-list-hoverBackground, #2a2d2e); } +.fry-table th .fry-arrow { display: inline-block; width: 12px; margin-left: 4px; font-size: 10px; opacity: .5; } +.fry-table th[aria-sort] .fry-arrow { opacity: 1; color: var(--vscode-textLink-foreground, #3794ff); } +.fry-table td { padding: 5px 12px; vertical-align: middle; border-bottom: 1px solid var(--vscode-widget-border, rgba(128,128,128,.2)); } +.fry-table td.fry-num, .fry-table th.fry-num { text-align: right; font-variant-numeric: tabular-nums; } +.fry-table tbody tr.fry-data:hover { background: var(--vscode-list-hoverBackground, #2a2d2e); } +.fry-table tbody tr.fry-data:nth-child(4n+1) { background: var(--vscode-notebook-cellEditorBackground, rgba(128,128,128,.05)); } +.fry-table .fry-expander { width: 28px; padding: 5px 4px; text-align: center; } +.fry-table .fry-detail td { padding: 8px 14px 12px 40px; background: var(--vscode-editor-background, #1e1e1e); } +.fry-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 6px; } +.fry-grid > div { padding: 4px 8px; border: 1px solid var(--vscode-widget-border, rgba(128,128,128,.3)); border-radius: 4px; } +.fry-grid .fry-key { font-size: 10px; text-transform: uppercase; letter-spacing: .4px; color: var(--vscode-descriptionForeground, #9d9d9d); } +.fry-empty { padding: 12px; color: var(--vscode-descriptionForeground, #9d9d9d); } + +/* a cell's output as tabs */ +.fry-tabs { margin: 6px 0; } +.fry-tabbar { padding: 4px 6px 4px 6px; flex-wrap: wrap; row-gap: 4px; } +.fry-tabstrip { display: flex; gap: 4px; flex-wrap: wrap; } +.fry-tab { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 3px 10px; border-radius: 4px; cursor: pointer; border: 1px solid transparent; background: transparent; color: var(--vscode-foreground, #cccccc); } +.fry-tab:hover { background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground, #2a2d2e)); } +.fry-tab:focus-visible { outline: 1px solid var(--vscode-focusBorder, #007fd4); outline-offset: -1px; } +.fry-tab-selected { border-color: var(--vscode-focusBorder, #007fd4); background: var(--vscode-list-activeSelectionBackground, rgba(0, 127, 212, .18)); color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground, #ffffff)); } +.fry-tab-icon { font-family: var(--vscode-editor-font-family, Menlo, Consolas, monospace); font-size: 11px; opacity: .85; } +.fry-tab-count { background: var(--vscode-badge-background, #4d4d4d); } +.fry-tab-selected .fry-tab-count { background: var(--vscode-focusBorder, #007fd4); color: #ffffff; } +.fry-tabactions { display: flex; gap: 4px; align-items: center; } +.fry-fold { min-width: 24px; } +.fry-tabbody { padding: 0; } +.fry-tabbody .fry-card { margin: 0; border: 0; border-radius: 0; } +.fry-tabbody .fry-slot + .fry-slot { border-top: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #454545)); } +.fry-tabbody .fry-frame { border: 0; border-radius: 0; } + +/* printed text */ +.fry-console { max-height: 420px; overflow: auto; padding: 6px 10px; } +.fry-pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: inherit; } +.fry-note { margin-bottom: 4px; font-size: 11px; color: var(--vscode-descriptionForeground, #9d9d9d); } +.fry-more { margin: 0 0 6px; } +.fry-markdown { padding: 6px 12px; } +.fry-markdown h1, .fry-markdown h2, .fry-markdown h3 { margin: 10px 0 6px; font-weight: 600; } +.fry-markdown p, .fry-markdown ul, .fry-markdown ol, .fry-markdown blockquote { margin: 6px 0; } +.fry-markdown blockquote { padding-left: 10px; border-left: 3px solid var(--vscode-textBlockQuote-border, rgba(128,128,128,.5)); color: var(--vscode-descriptionForeground, #9d9d9d); } +.fry-markdown code { padding: 0 4px; border-radius: 3px; background: var(--vscode-textCodeBlock-background, rgba(128,128,128,.2)); } +.fry-markdown a { color: var(--vscode-textLink-foreground, #3794ff); } +.fry-markdown hr { border: 0; border-top: 1px solid var(--vscode-widget-border, rgba(128,128,128,.4)); } + +/* an engine page (a chart) in a frame of its own */ +.fry-frame { display: block; width: 100%; height: 360px; min-height: 160px; border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #454545)); border-radius: 6px; resize: vertical; background: transparent; } +`; diff --git a/vscode-frysharp/src/renderer/table.ts b/vscode-frysharp/src/renderer/table.ts new file mode 100644 index 00000000..181d1bcf --- /dev/null +++ b/vscode-frysharp/src/renderer/table.ts @@ -0,0 +1,126 @@ +import { copyButton, el } from './dom'; +import { footer, nextSort, Sort, TableData, textOf, toCsv, toJson, visibleRows } from './tableModel'; + +/** The table: sorted by a click on a column, filtered as you type, a row opening to all its columns, copied as CSV or JSON. */ +export function renderTable(table: TableData, root: HTMLElement): void { + const card = el('div', 'fry fry-card fry-table'); + let filter = ''; + let sort: Sort | undefined; + let shown: number[] = []; + const open = new Set(); // rows opened to their detail, by row index: kept through a sort or a filter + + // the bar: title, size, filter, copy + const head = el('div', 'fry-head'); + const columns = table.columns.length; + const search = el('input', 'fry-filter', undefined, { type: 'search', placeholder: 'Filter rows…', 'aria-label': 'Filter rows' }); + head.append( + el('span', 'fry-title', table.title || 'Table'), + el('span', 'fry-pill', `${(table.totalRows ?? table.rows.length).toLocaleString('en-US')} rows × ${(table.totalColumns ?? columns).toLocaleString('en-US')} cols`), + el('span', 'fry-spacer'), + search, + copyButton('CSV', () => toCsv(table, shown)), + copyButton('JSON', () => toJson(table, shown)) + ); + + // the grid + const scroll = el('div', 'fry-scroll'); + const grid = el('table', undefined, undefined, { role: 'grid' }); + const headRow = el('tr'); + headRow.append(el('th', 'fry-expander')); + const arrows: HTMLElement[] = []; + table.columns.forEach((name, column) => { + const th = el('th', `fry-sortable${table.numeric?.[column] ? ' fry-num' : ''}`, undefined, { tabindex: '0', title: `Sort by ${name}` }); + const arrow = el('span', 'fry-arrow', '↕'); + arrows.push(arrow); + th.append(name, arrow); + const sortBy = () => { + sort = nextSort(sort, column); + draw(); + }; + th.addEventListener('click', sortBy); + th.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + sortBy(); + } + }); + headRow.append(th); + }); + const thead = el('thead'); + thead.append(headRow); + const tbody = el('tbody'); + grid.append(thead, tbody); + scroll.append(grid); + + const foot = el('div', 'fry-foot'); + card.append(head, scroll, foot); + root.replaceChildren(card); + + function cell(value: string | number | boolean | null, numeric: boolean): HTMLElement { + const td = el('td'); + if (value === null || value === undefined) { + td.className = 'fry-v-null'; + td.textContent = 'null'; + } else { + const text = textOf(value); + td.textContent = text; + if (numeric) td.className = 'fry-num fry-v-number'; + else if (/^(true|false)$/i.test(text)) td.className = 'fry-v-boolean'; + } + return td; + } + + function draw(): void { + shown = visibleRows(table, filter, sort); + const rows = document.createDocumentFragment(); + for (const index of shown) { + const row = table.rows[index]; + const tr = el('tr', 'fry-data'); + const expander = el('td', 'fry-expander'); + const toggle = el('button', 'fry-toggle', open.has(index) ? '▼' : '▶', { type: 'button', 'aria-expanded': String(open.has(index)), title: 'Show every column of this row' }); + toggle.addEventListener('click', () => { + if (!open.delete(index)) open.add(index); + draw(); + }); + expander.append(toggle); + tr.append(expander); + table.columns.forEach((_, column) => tr.append(cell(row[column] ?? null, table.numeric?.[column] === true))); + rows.append(tr); + + if (open.has(index)) { + const detail = el('tr', 'fry-detail'); + const td = el('td', undefined, undefined, { colspan: String(columns + 1) }); + const gridOfValues = el('div', 'fry-grid'); + table.columns.forEach((name, column) => { + const item = el('div'); + const value = row[column] ?? null; + item.append(el('div', 'fry-key', name), el('div', value === null ? 'fry-v-null' : 'fry-value', value === null ? 'null' : textOf(value))); + gridOfValues.append(item); + }); + td.append(gridOfValues); + detail.append(td); + rows.append(detail); + } + } + tbody.replaceChildren(rows); + if (shown.length === 0) { + const none = el('tr'); + none.append(el('td', 'fry-empty', filter ? 'No rows match the filter.' : 'No rows.', { colspan: String(columns + 1) })); + tbody.append(none); + } + + headRow.querySelectorAll('th.fry-sortable').forEach((th, column) => { + const active = sort?.column === column; + if (active) th.setAttribute('aria-sort', sort!.direction === 'asc' ? 'ascending' : 'descending'); + else th.removeAttribute('aria-sort'); + arrows[column].textContent = active ? (sort!.direction === 'asc' ? '▲' : '▼') : '↕'; + }); + foot.textContent = footer(table, shown.length, filter.trim() !== ''); + } + + search.addEventListener('input', () => { + filter = search.value; + draw(); + }); + draw(); +} diff --git a/vscode-frysharp/src/renderer/tableModel.ts b/vscode-frysharp/src/renderer/tableModel.ts new file mode 100644 index 00000000..b26e1635 --- /dev/null +++ b/vscode-frysharp/src/renderer/tableModel.ts @@ -0,0 +1,100 @@ +// What a table does, apart from drawing it: which rows show, in what order, and the text of the table as CSV or JSON. + +/** A table as the engine sends it (application/vnd.fry.table+json). */ +export interface TableData { + title?: string; + columns: string[]; + numeric?: boolean[]; + rows: Array>; + totalRows?: number; + totalColumns?: number; + label?: string; +} + +export type SortDirection = 'asc' | 'desc'; + +export interface Sort { + column: number; + direction: SortDirection; +} + +/** A number in a cell's text, as the engine formats it: "1,234.5", "-3", "12 %" are numbers; "n/a" is not. */ +export function numberOf(cell: string | number | boolean | null): number | undefined { + if (typeof cell === 'number') return Number.isFinite(cell) ? cell : undefined; + if (typeof cell !== 'string') return undefined; + const cleaned = cell.trim().replace(/[,\s%$€£]/g, ''); + if (cleaned === '' || !/^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(cleaned)) return undefined; + return parseFloat(cleaned); +} + +export const textOf = (cell: string | number | boolean | null): string => (cell === null || cell === undefined ? '' : String(cell)); + +/** The rows to show, as indexes into the table's rows: those with the filter's text in any cell, in the order asked for. */ +export function visibleRows(table: TableData, filter: string, sort?: Sort): number[] { + const needle = filter.trim().toLowerCase(); + let indexes = table.rows.map((_, index) => index); + if (needle) { + indexes = indexes.filter((index) => table.rows[index].some((cell) => textOf(cell).toLowerCase().includes(needle))); + } + if (!sort) return indexes; + + const numeric = table.numeric?.[sort.column] === true; + const sign = sort.direction === 'asc' ? 1 : -1; + const keyed = indexes.map((index) => ({ index, cell: table.rows[index][sort.column] ?? null })); + keyed.sort((a, b) => { + // an empty cell goes last, whichever way the column is sorted + const aEmpty = a.cell === null || textOf(a.cell) === ''; + const bEmpty = b.cell === null || textOf(b.cell) === ''; + if (aEmpty || bEmpty) return aEmpty === bEmpty ? a.index - b.index : aEmpty ? 1 : -1; + + if (numeric) { + const an = numberOf(a.cell); + const bn = numberOf(b.cell); + // in a column of numbers, what is not one ("n/a") comes after the numbers, whichever way they are sorted + if (an !== undefined && bn !== undefined) { + return an === bn ? a.index - b.index : sign * (an - bn); + } + if (an !== undefined || bn !== undefined) return an !== undefined ? -1 : 1; + } + const order = textOf(a.cell).localeCompare(textOf(b.cell), undefined, { numeric: true, sensitivity: 'base' }); + return order === 0 ? a.index - b.index : sign * order; + }); + return keyed.map((entry) => entry.index); +} + +/** Clicking a column's header: ascending, then descending, then as it came. */ +export function nextSort(current: Sort | undefined, column: number): Sort | undefined { + if (!current || current.column !== column) return { column, direction: 'asc' }; + return current.direction === 'asc' ? { column, direction: 'desc' } : undefined; +} + +const csvCell = (cell: string | number | boolean | null): string => { + const text = textOf(cell); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +}; + +/** The rows (those shown, in the order shown) as CSV, with the column names first. */ +export function toCsv(table: TableData, indexes: number[]): string { + return [table.columns.map(csvCell).join(','), ...indexes.map((index) => table.rows[index].map(csvCell).join(','))].join('\n'); +} + +/** The rows as JSON: a list of objects named by the columns (a number stays a number where the column says it is one). */ +export function toJson(table: TableData, indexes: number[]): string { + const records = indexes.map((index) => { + const record: Record = {}; + table.columns.forEach((column, i) => { + const cell = table.rows[index][i] ?? null; + record[column] = table.numeric?.[i] === true && numberOf(cell) !== undefined ? numberOf(cell) : cell; + }); + return record; + }); + return JSON.stringify(records, null, 2); +} + +/** What the footer says about the rows: all of them, those a filter leaves, or the first rows of a longer table. */ +export function footer(table: TableData, shown: number, filtered: boolean): string { + const total = table.totalRows ?? table.rows.length; + if (filtered) return `Showing ${shown.toLocaleString('en-US')} of ${table.rows.length.toLocaleString('en-US')} rows (filtered)`; + if (total > table.rows.length) return `Showing the first ${table.rows.length.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} rows`; + return `Showing ${shown.toLocaleString('en-US')} of ${table.rows.length.toLocaleString('en-US')} rows`; +} diff --git a/vscode-frysharp/src/renderer/tabs.ts b/vscode-frysharp/src/renderer/tabs.ts new file mode 100644 index 00000000..f4440e55 --- /dev/null +++ b/vscode-frysharp/src/renderer/tabs.ts @@ -0,0 +1,249 @@ +import { + CELL_OUTPUT_MIME, + consoleTextOf, + kindOf, + PackedCell, + PackedOutput, + TabInfo, + tabsOf +} from '../shared/outputTabs'; +import { ConsoleState, renderConsole } from './console'; +import { copyText, el } from './dom'; +import { renderBundle } from './render'; +import { TableData, toCsv } from './tableModel'; + +export { CELL_OUTPUT_MIME }; + +// A glyph for each tab, as the desktop has an icon for each (the notebook's web view has no icon font to draw from). +const ICONS: Record = { table: '⊞', console: '›_', inspector: '{ }', image: '▨', chart: '∿', visualizer: '◫', plot3d: '◈', html: '', markdown: 'M↓', all: '☰' }; + +/** What the tabs of one output remember between drawings: the output is drawn again each time more of the cell's output arrives. */ +interface TabsState { + selected?: string; + collapsed: boolean; + console: ConsoleState; + /** The tab the slots below belong to. */ + slotsFor?: string; + slots: Map; + /** Draws that are still going: a newer one replaces them. */ + generation: number; +} + +const states = new WeakMap(); + +function stateOf(root: HTMLElement): TabsState { + let state = states.get(root); + if (!state) { + state = { collapsed: false, console: { showAll: false }, slots: new Map(), generation: 0 }; + states.set(root, state); + } + return state; +} + +// What changed in an output, cheaply: a page that did not change is not drawn again (a chart in a frame would start over). +function fingerprint(output: PackedOutput): string { + let hash = 2166136261; + const add = (text: string) => { + for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); + }; + for (const item of output.items) { + add(item.mime); + add(item.text ?? item.base64 ?? ''); + } + return `${output.items.length}:${(hash >>> 0).toString(36)}`; +} + +function dataOf(output: PackedOutput): Record { + return Object.fromEntries(output.items.map((item) => [item.mime, item.text ?? item.base64 ?? ''])); +} + +// What the copy button of a tab copies: a console's text, a table as CSV; nothing for a chart. +function copyTextOf(tab: TabInfo, cell: PackedCell): string | undefined { + if (tab.id === 'console' || tab.id === 'all') { + const text = cell.outputs.filter((output) => kindOf(output) === 'console').map(consoleTextOf).join(''); + if (text || tab.id === 'console') return text; + } + if (tab.id === 'table' || tab.id === 'all') { + for (const index of tab.outputs) { + const item = cell.outputs[index].items.find((i) => i.mime === 'application/vnd.fry.table+json'); + if (item?.text && kindOf(cell.outputs[index]) === 'table') { + try { + const table = JSON.parse(item.text) as TableData; + return toCsv(table, table.rows.map((_, row) => row)); + } catch { + return undefined; + } + } + } + } + return undefined; +} + +/** + * A cell's output drawn as tabs, as the desktop app draws it: Table, Console, Chart ... and All, each with how much is in it. What is + * drawn again while the cell runs keeps the tab, the scrolling and the pages (a chart is not redrawn unless it changed). + * Returns whether every chart on the selected tab drew. + */ +export async function renderTabs(cell: PackedCell, root: HTMLElement): Promise<{ tabs: string[]; selected: string; chartDrawn?: boolean }> { + const state = stateOf(root); + const generation = ++state.generation; + const tabs = tabsOf(cell); + if (tabs.length === 0) { + root.replaceChildren(); + return { tabs: [], selected: '' }; + } + + if (!tabs.some((tab) => tab.id === state.selected)) state.selected = tabs[0].id; + const selected = tabs.find((tab) => tab.id === state.selected)!; + + // the strip and the bar are made again each time (they hold counts); the body is kept where it can be + let shell = root.querySelector(':scope > .fry-tabs'); + const consoleBox = root.querySelector('.fry-console'); + const stuck = consoleBox ? consoleBox.scrollHeight - consoleBox.scrollTop - consoleBox.clientHeight < 8 : true; + const scrollTop = consoleBox?.scrollTop ?? 0; + + let body = shell?.querySelector(':scope > .fry-tabbody') ?? undefined; + if (!shell || !body) { + shell = el('div', 'fry fry-card fry-tabs'); + body = el('div', 'fry-tabbody', undefined, { role: 'tabpanel' }); + shell.append(el('div', 'fry-head fry-tabbar'), body); + root.replaceChildren(shell); + } + + const bar = shell.querySelector(':scope > .fry-tabbar')!; + bar.replaceChildren(...barOf(tabs, selected, cell, state, root)); + body.hidden = state.collapsed; + body.setAttribute('aria-labelledby', `fry-tab-${selected.id}`); + + const drawn = await drawBody(body, selected, cell, state, () => void renderTabs(cell, root), generation); + + const after = body.querySelector('.fry-console'); + if (after && generation === state.generation) after.scrollTop = stuck ? after.scrollHeight : scrollTop; + return { tabs: tabs.map((tab) => tab.label), selected: selected.label, chartDrawn: drawn }; +} + +function barOf(tabs: TabInfo[], selected: TabInfo, cell: PackedCell, state: TabsState, root: HTMLElement): HTMLElement[] { + const strip = el('div', 'fry-tabstrip', undefined, { role: 'tablist' }); + const choose = (tab: TabInfo) => { + state.selected = tab.id; + state.collapsed = false; + void renderTabs(cell, root).then(() => strip.ownerDocument.getElementById(`fry-tab-${tab.id}`)?.focus()); + }; + + tabs.forEach((tab, position) => { + const button = el('button', `fry-tab${tab === selected ? ' fry-tab-selected' : ''}`, undefined, { + type: 'button', + role: 'tab', + id: `fry-tab-${tab.id}`, + 'aria-selected': String(tab === selected), + tabindex: tab === selected ? '0' : '-1' + }); + button.append(el('span', 'fry-tab-icon', ICONS[tab.id] ?? ''), el('span', undefined, tab.label)); + if (tab.count !== undefined) button.append(el('span', 'fry-pill fry-tab-count', tab.count.toLocaleString('en-US'))); + button.addEventListener('click', () => choose(tab)); + button.addEventListener('keydown', (event) => { + const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; + if (step === 0) return; + event.preventDefault(); + choose(tabs[(position + step + tabs.length) % tabs.length]); + }); + strip.append(button); + }); + + const actions = el('div', 'fry-tabactions'); + const text = copyTextOf(selected, cell); + if (text !== undefined) { + const copy = el('button', 'fry-button', 'Copy', { type: 'button', title: selected.id === 'table' ? 'Copy the table as CSV' : 'Copy the text' }); + copy.addEventListener('click', async () => { + copy.textContent = (await copyText(text)) ? '✓ Copied' : 'Could not copy'; + setTimeout(() => (copy.textContent = 'Copy'), 1500); + }); + actions.append(copy); + } + + const fold = el('button', 'fry-button fry-fold', state.collapsed ? '▸' : '▾', { type: 'button', title: state.collapsed ? 'Show the output' : 'Hide the output', 'aria-expanded': String(!state.collapsed) }); + fold.addEventListener('click', () => { + state.collapsed = !state.collapsed; + void renderTabs(cell, root); + }); + actions.append(fold); + + return [strip, el('span', 'fry-spacer'), actions]; +} + +// The body is a list of slots, one for each thing the tab shows; a slot whose content did not change is left alone. +async function drawBody(body: HTMLElement, tab: TabInfo, cell: PackedCell, state: TabsState, redraw: () => void, generation: number): Promise { + if (state.slotsFor !== tab.id) { + state.slots.clear(); + state.slotsFor = tab.id; + body.replaceChildren(); + } + + interface Wanted { + key: string; + draw: (slot: HTMLElement) => Promise | boolean | undefined; + } + const wanted: Wanted[] = []; + + if (tab.id === 'console') { + const outputs = tab.outputs.map((index) => cell.outputs[index]); + wanted.push({ + key: 'console', + draw: (slot) => { + renderConsole(outputs.map(consoleTextOf).join(''), outputs.reduce((sum, output) => sum + (output.omitted ?? 0), 0), slot, state.console, redraw); + return undefined; + } + }); + } else { + for (const index of tab.outputs) { + const output = cell.outputs[index]; + if (kindOf(output) === 'console') { + wanted.push({ + key: `console:${index}`, + draw: (slot) => { + renderConsole(consoleTextOf(output), output.omitted ?? 0, slot, state.console, redraw); + return undefined; + } + }); + } else { + wanted.push({ + key: `${index}:${fingerprint(output)}`, + draw: async (slot) => { + const mime = await renderBundle(dataOf(output), slot); + return mime === 'text/html' && kindOf(output) !== 'html' ? slot.querySelector('iframe') !== null : undefined; + } + }); + } + } + } + + let chartDrawn: boolean | undefined; + const keep = new Set(wanted.map((w) => w.key)); + for (const [key, slot] of [...state.slots]) { + if (!keep.has(key)) { + slot.remove(); + state.slots.delete(key); + } + } + + for (let position = 0; position < wanted.length; position++) { + const { key, draw } = wanted[position]; + let slot = state.slots.get(key); + const fresh = !slot; + if (!slot) { + slot = el('div', 'fry-slot'); + state.slots.set(key, slot); + } + + // placed first, drawn after: the order of the tab does not wait for a slow page + if (body.children[position] !== slot) body.insertBefore(slot, body.children[position] ?? null); + // a console is drawn again each time (it grew); a page only when it is new + if (fresh || key.startsWith('console')) { + const result = await draw(slot); + if (result !== undefined) chartDrawn = (chartDrawn ?? true) && result; + } + if (generation !== state.generation) return chartDrawn; // a newer draw has taken over + } + + return chartDrawn; +} diff --git a/vscode-frysharp/src/renderer/tsconfig.json b/vscode-frysharp/src/renderer/tsconfig.json new file mode 100644 index 00000000..20e8243f --- /dev/null +++ b/vscode-frysharp/src/renderer/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": [], + "noEmit": true + }, + "include": [ + "./**/*.ts" + ], + "exclude": [] +} diff --git a/vscode-frysharp/src/run/lensRules.ts b/vscode-frysharp/src/run/lensRules.ts new file mode 100644 index 00000000..3b70beff --- /dev/null +++ b/vscode-frysharp/src/run/lensRules.ts @@ -0,0 +1,9 @@ +/** + * Whether a file gets the "Run | Stop" lens at its top. Never a plain `.cs` file (the C# extension owns those lenses), never a notebook + * cell, and only a language the engine runs. + */ +export function offersLens(languageId: string, fileName: string, scheme: string, runnable: readonly string[]): boolean { + if (scheme === 'vscode-notebook-cell' || scheme === 'output' || scheme === 'vscode-userdata') return false; + if (!runnable.includes(languageId)) return false; + return !(languageId === 'csharp' && /\.cs$/i.test(fileName)); +} diff --git a/vscode-frysharp/src/run/lineEditor.ts b/vscode-frysharp/src/run/lineEditor.ts new file mode 100644 index 00000000..f4b187b4 --- /dev/null +++ b/vscode-frysharp/src/run/lineEditor.ts @@ -0,0 +1,66 @@ +/** What typing into a run's terminal did: what to show, the lines to send to the program, whether Ctrl+C was pressed. */ +export interface Typed { + /** Text to write to the terminal now. */ + echo: string; + /** Lines the person finished (Enter), for the program to read. */ + lines: string[]; + interrupt: boolean; +} + +const ERASE = '\b \b'; + +/** + * The line a person types to a running program. The program's input is not a terminal, so nothing edits the line for + * them: this shows what is typed, takes back a character on Backspace, and on Enter takes back the whole of what it + * showed, because the engine writes the line it was sent into the output (as a terminal would have), so that the line + * is not on the screen twice. Arrow keys and other escape sequences do nothing. + */ +export class LineEditor { + private buffer = ''; + + /** The text typed and not yet sent. */ + get pending(): string { + return this.buffer; + } + + reset(): void { + this.buffer = ''; + } + + feed(data: string): Typed { + const typed: Typed = { echo: '', lines: [], interrupt: false }; + const characters = [...data]; + + for (let i = 0; i < characters.length; i++) { + const character = characters[i]; + if (character === '\x1b') { + // an escape sequence (an arrow key, a paste marker): skip it, final byte included + if (characters[i + 1] === '[' || characters[i + 1] === 'O') { + i += 2; + while (i < characters.length && !/[A-Za-z~]/.test(characters[i])) i++; + } else { + i++; + } + } else if (character === '\x03') { + typed.interrupt = true; + this.buffer = ''; + } else if (character === '\r' || character === '\n') { + if (character === '\n' && characters[i - 1] === '\r') continue; // CR LF is one Enter + typed.echo += ERASE.repeat([...this.buffer].length); + typed.lines.push(this.buffer); + this.buffer = ''; + } else if (character === '\x7f' || character === '\b') { + const chars = [...this.buffer]; + if (chars.length > 0) { + chars.pop(); + this.buffer = chars.join(''); + typed.echo += ERASE; + } + } else if (character >= ' ') { + this.buffer += character; + typed.echo += character; + } + } + return typed; + } +} diff --git a/vscode-frysharp/src/run/resultsPage.ts b/vscode-frysharp/src/run/resultsPage.ts new file mode 100644 index 00000000..adc71fd0 --- /dev/null +++ b/vscode-frysharp/src/run/resultsPage.ts @@ -0,0 +1,82 @@ +/** Something a program showed that is not text: a table, a chart, an image (a display bundle, as a cell's output is). */ +export interface ResultItem { + id: number; + /** What ran, and when: "hello.py, 12:03:41". */ + title: string; + /** MIME type → content: the renderer draws the richest. */ + data: Record; +} + +export function itemFromDisplay(id: number, title: string, data: Record): ResultItem { + return { id, title, data }; +} + +/** JSON for the inside of a script element: a `<` can never close it. */ +export function jsonForScript(value: unknown): string { + return JSON.stringify(value).replace(/ + + + + + + + + +
What a program shows (a table, an object, a chart, an image) appears here. Text is in the terminal.
+
+ + +`; +} diff --git a/vscode-frysharp/src/run/resultsView.ts b/vscode-frysharp/src/run/resultsView.ts new file mode 100644 index 00000000..4da6b773 --- /dev/null +++ b/vscode-frysharp/src/run/resultsView.ts @@ -0,0 +1,62 @@ +import * as vscode from 'vscode'; +import { ResultItem, resultsHtml } from './resultsPage'; + +const KEPT = 50; + +/** What the Results panel's page says each time it has drawn an item (or could not). */ +export interface ResultsReport { + type: 'rendered' | 'error'; + id: number; + mime?: string; + message?: string; +} + +/** The Results panel (the desktop's .Dump deck): what programs show that is not text, newest last, until cleared. */ +export class ResultsView implements vscode.WebviewViewProvider, vscode.Disposable { + static readonly viewType = 'frysharp.results'; + private view?: vscode.WebviewView; + private items: ResultItem[] = []; + + /** What the page reported drawing, for tests. */ + readonly reports: ResultsReport[] = []; + + constructor(private readonly extensionUri: vscode.Uri) {} + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + const dist = vscode.Uri.joinPath(this.extensionUri, 'dist'); + view.webview.options = { enableScripts: true, localResourceRoots: [dist] }; + view.webview.html = resultsHtml(this.items, { + rendererUri: view.webview.asWebviewUri(vscode.Uri.joinPath(dist, 'renderer.js')).toString(), + cspSource: view.webview.cspSource + }); + view.webview.onDidReceiveMessage((message: ResultsReport) => this.reports.push(message)); + view.onDidDispose(() => (this.view = undefined)); + } + + /** Shows an item, opening the panel if it is not open. */ + async add(item: ResultItem): Promise { + this.items.push(item); + if (this.items.length > KEPT) this.items.splice(0, this.items.length - KEPT); + + if (!this.view) { + await vscode.commands.executeCommand(`${ResultsView.viewType}.focus`, { preserveFocus: true }); + return; // the view draws what has been added when it opens + } + this.view.show?.(true); + await this.view.webview.postMessage({ type: 'add', item }); + } + + clear(): void { + this.items = []; + void this.view?.webview.postMessage({ type: 'clear' }); + } + + get count(): number { + return this.items.length; + } + + dispose(): void { + this.view = undefined; + } +} diff --git a/vscode-frysharp/src/run/runCodeLens.ts b/vscode-frysharp/src/run/runCodeLens.ts new file mode 100644 index 00000000..5c7d1b00 --- /dev/null +++ b/vscode-frysharp/src/run/runCodeLens.ts @@ -0,0 +1,43 @@ +import * as vscode from 'vscode'; +import { offersLens } from './lensRules'; +import type { ScriptRuns } from './scriptRuns'; + +/** "Run with FrySharp" (and "Stop" while it runs) above the first line of a script, when the setting `frysharp.codeLens.enabled` is on. */ +export class RunCodeLens implements vscode.CodeLensProvider, vscode.Disposable { + private readonly changed = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this.changed.event; + private readonly disposables: vscode.Disposable[]; + + constructor( + private readonly scripts: ScriptRuns, + private readonly runnable: () => readonly string[] + ) { + this.disposables = [ + this.changed, + this.scripts.onDidChangeRunning(() => this.changed.fire()), + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('frysharp.codeLens')) this.changed.fire(); + }) + ]; + } + + /** Tells the lenses the languages the engine runs have been listed. */ + refresh(): void { + this.changed.fire(); + } + + provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + if (!vscode.workspace.getConfiguration('frysharp.codeLens').get('enabled', false)) return []; + if (!offersLens(document.languageId, document.fileName, document.uri.scheme, this.runnable())) return []; + + const top = new vscode.Range(0, 0, 0, 0); + const running = this.scripts.terminalFor(document.uri)?.running === true; + const lenses = [new vscode.CodeLens(top, { title: running ? '$(sync~spin) Running…' : '$(play) Run with FrySharp', command: running ? '' : 'frysharp.runScript', arguments: [document.uri] })]; + if (running) lenses.push(new vscode.CodeLens(top, { title: '$(debug-stop) Stop', command: 'frysharp.stopRun' })); + return lenses; + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/vscode-frysharp/src/run/runLinks.ts b/vscode-frysharp/src/run/runLinks.ts new file mode 100644 index 00000000..70332a44 --- /dev/null +++ b/vscode-frysharp/src/run/runLinks.ts @@ -0,0 +1,42 @@ +/** A place in a file that a line of a program's output names. */ +export interface OutputLink { + /** Where in the line the text of the link starts, and how long it is. */ + start: number; + length: number; + file: string; + line: number; + column: number; +} + +// What the compilers and runtimes FrySharp runs write when they point at code: +// Program.cs(12,5): error CS1002 .NET File "tool.py", line 3 Python +// ./main.go:5:2: undefined: x Go, Node --> src/main.rs:3:5 Rust +const PATTERNS: Array<{ expression: RegExp; read: (match: RegExpExecArray) => { file: string; line: number; column: number } }> = [ + { + expression: /(?(?:[A-Za-z]:[\\/])?[^\s:()"'<>|*?]+\.[A-Za-z0-9]+)\((?\d+),(?\d+)\)/g, + read: (m) => ({ file: m.groups!.file, line: +m.groups!.line, column: +m.groups!.column }) + }, + { + expression: /File "(?[^"]+)", line (?\d+)/g, + read: (m) => ({ file: m.groups!.file, line: +m.groups!.line, column: 1 }) + }, + { + expression: /(?(?:[A-Za-z]:[\\/])?[^\s:()"'<>|*?]+\.[A-Za-z0-9]+):(?\d+)(?::(?\d+))?/g, + read: (m) => ({ file: m.groups!.file, line: +m.groups!.line, column: m.groups!.column ? +m.groups!.column : 1 }) + } +]; + +/** The places a line of output names, left to right, none overlapping another. */ +export function findLinks(line: string): OutputLink[] { + const found: OutputLink[] = []; + for (const { expression, read } of PATTERNS) { + expression.lastIndex = 0; + for (let match = expression.exec(line); match; match = expression.exec(line)) { + const link = { start: match.index, length: match[0].length, ...read(match) }; + // a URL (https://host:8080) is not a file and a line + if (link.line < 1 || link.file.startsWith('//') || found.some((other) => link.start < other.start + other.length && other.start < link.start + link.length)) continue; + found.push(link); + } + } + return found.sort((a, b) => a.start - b.start); +} diff --git a/vscode-frysharp/src/run/runTerminal.ts b/vscode-frysharp/src/run/runTerminal.ts new file mode 100644 index 00000000..90f4c230 --- /dev/null +++ b/vscode-frysharp/src/run/runTerminal.ts @@ -0,0 +1,138 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { LineEditor } from './lineEditor'; +import type { ScriptRuns } from './scriptRuns'; + +const DIM = '\x1b[90m'; +const GREEN = '\x1b[32m'; +const RED = '\x1b[31m'; +const CYAN = '\x1b[36m'; +const RESET = '\x1b[0m'; + +/** The terminal of one script: its output as it comes, with the program's own colours; a line typed goes to the program. */ +export class RunTerminal implements vscode.Pseudoterminal { + private readonly writeEmitter = new vscode.EventEmitter(); + private readonly closeEmitter = new vscode.EventEmitter(); + private readonly editor = new LineEditor(); + private current?: { runId: string; started: number }; + private finished = true; + private everRan = false; + + readonly onDidWrite = this.writeEmitter.event; + readonly onDidClose = this.closeEmitter.event; + + /** The URIs the last run marked in the Problems panel. */ + marked: vscode.Uri[] = []; + + /** Everything written to the terminal (the last megabyte): what a test, or a bug report, can read. */ + transcript = ''; + + constructor( + private readonly runs: ScriptRuns, + readonly file: vscode.Uri, + private arguments_: string[] + ) {} + + get running(): boolean { + return !this.finished; + } + + get runId(): string | undefined { + return this.current?.runId; + } + + // ── Pseudoterminal ──────────────────────────────────────────────────────────────────────────────────────────── + + open(): void { + void this.start(); + } + + close(): void { + this.runs.closed(this); + if (this.current && !this.finished) void this.runs.stop(this.current.runId); + } + + handleInput(data: string): void { + if (this.finished) { + // a finished program: any key runs it again + if (this.everRan) void this.restart(); + return; + } + const typed = this.editor.feed(data); + if (typed.echo) this.write(typed.echo); + for (const line of typed.lines) void this.send(line); + if (typed.interrupt && this.current) { + this.write(`${DIM}^C${RESET}\r\n`); + void this.runs.stop(this.current.runId); + } + } + + // ── Runs ────────────────────────────────────────────────────────────────────────────────────────────────────── + + /** Runs the script again, in this terminal, from a clean screen. */ + async restart(arguments_?: string[]): Promise { + if (arguments_) this.arguments_ = arguments_; + if (this.current && !this.finished) { + await this.runs.stop(this.current.runId); + await this.waitUntilFinished(); + } + this.write('\x1b[2J\x1b[3J\x1b[H'); // clear the screen and what scrolled off it + await this.start(); + } + + private async start(): Promise { + this.editor.reset(); + this.everRan = true; + this.finished = false; + const label = [path.basename(this.file.fsPath), ...this.arguments_].join(' '); + this.write(`${DIM}▶ ${label}${RESET}\r\n`); + try { + const runId = await this.runs.begin(this, this.arguments_); + this.current = { runId, started: Date.now() }; + } catch (error) { + this.write(`${RED}${error instanceof Error ? error.message : String(error)}${RESET}\r\n`); + this.end(); + } + } + + private async send(line: string): Promise { + if (!this.current) return; + try { + await this.runs.sendInput(this.current.runId, line); + } catch (error) { + this.write(`${RED}${error instanceof Error ? error.message : String(error)}${RESET}\r\n`); + } + } + + private waitUntilFinished(): Promise { + return new Promise((resolve) => { + const started = Date.now(); + const check = () => (this.finished || Date.now() - started > 10_000 ? resolve() : setTimeout(check, 50)); + check(); + }); + } + + // ── What the engine says about the run ──────────────────────────────────────────────────────────────────────── + + write(text: string): void { + this.transcript = (this.transcript + text).slice(-1_000_000); + this.writeEmitter.fire(text); + } + + shown(summary: string): void { + this.write(`${CYAN}▸ Shown in FrySharp Results: ${summary}${RESET}\r\n`); + } + + exited(exitCode: number, durationMs: number): void { + const seconds = durationMs < 1000 ? `${durationMs} ms` : `${(durationMs / 1000).toFixed(1)} s`; + const colour = exitCode === 0 ? GREEN : RED; + this.write(`\r\n${colour}${exitCode === 0 ? '✓' : '✗'} exited with code ${exitCode}${RESET}${DIM} in ${seconds} · press any key to run again${RESET}\r\n`); + this.end(); + } + + private end(): void { + this.finished = true; + this.current = undefined; + this.runs.changed(); + } +} diff --git a/vscode-frysharp/src/run/scriptRuns.ts b/vscode-frysharp/src/run/scriptRuns.ts new file mode 100644 index 00000000..595199d5 --- /dev/null +++ b/vscode-frysharp/src/run/scriptRuns.ts @@ -0,0 +1,228 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { RunTerminal } from './runTerminal'; +import { findLinks } from './runLinks'; +import { itemFromDisplay } from './resultsPage'; +import { ResultsView } from './resultsView'; +import { SidecarClient } from '../sidecar/sidecarClient'; + +/** + * Running a script is a terminal (the program's text, colours, typed input, Stop), problems in the Problems panel, and a + * Results panel for what is not text, as the desktop's bottom deck has them. One terminal for each file, used again each + * time it runs. + */ +export class ScriptRuns implements vscode.Disposable { + private readonly terminals = new Map(); + private readonly byRunId = new Map(); + private readonly problems = vscode.languages.createDiagnosticCollection('frysharp-run'); + private readonly disposables: vscode.Disposable[] = []; + private counter = 0; + private readonly changedEmitter = new vscode.EventEmitter(); + + /** A run started or ended. */ + readonly onDidChangeRunning = this.changedEmitter.event; + + constructor( + private readonly sidecar: SidecarClient, + readonly results: ResultsView + ) { + this.disposables.push( + this.problems, + this.changedEmitter, + sidecar.onRunOutput((n) => this.byRunId.get(n.runId)?.write(n.text)), + sidecar.onRunDisplay((n) => this.display(n.runId, n.data)), + sidecar.onRunExited((n) => this.exited(n.runId, n.exitCode, n.durationMs, n.problems)), + vscode.window.registerTerminalLinkProvider({ + provideTerminalLinks: (context) => this.links(context), + handleTerminalLink: (link) => this.open(link as OutputTerminalLink) + }), + vscode.window.onDidCloseTerminal((closed) => { + for (const [file, entry] of this.terminals) if (entry.terminal === closed) this.terminals.delete(file); + }) + ); + } + + /** The terminal a file's runs are in, if it has run. */ + terminalFor(uri: vscode.Uri): RunTerminal | undefined { + return this.terminals.get(uri.fsPath)?.run; + } + + /** The number of programs running. */ + get running(): number { + return [...this.terminals.values()].filter((entry) => entry.run.running).length; + } + + // ── Starting ────────────────────────────────────────────────────────────────────────────────────────────────── + + /** Runs a file in its terminal (a new one the first time, the same one after), asking nothing. */ + async run(uri: vscode.Uri, extraArguments?: string[]): Promise { + if (!vscode.workspace.isTrusted) { + void vscode.window.showWarningMessage('Running code is off until you trust this workspace.'); + return; + } + + const config = vscode.workspace.getConfiguration('frysharp.run'); + if (config.get('saveBeforeRun', true)) { + const open = vscode.workspace.textDocuments.find((document) => document.uri.toString() === uri.toString()); + if (open?.isDirty) await open.save(); + } + + const arguments_ = extraArguments ?? config.get('arguments', []); + const existing = this.terminals.get(uri.fsPath); + if (existing) { + existing.terminal.show(true); + await existing.run.restart(arguments_); + return; + } + + const run = new RunTerminal(this, uri, arguments_); + const terminal = vscode.window.createTerminal({ name: `FrySharp: ${path.basename(uri.fsPath)}`, pty: run, iconPath: new vscode.ThemeIcon('play') }); + this.terminals.set(uri.fsPath, { terminal, run }); + terminal.show(true); + } + + /** What `RunTerminal` calls to start its run: the engine's answer is the run's id. */ + async begin(run: RunTerminal, arguments_: string[]): Promise { + const init = await this.sidecar.ensureStarted(); + const extension = path.extname(run.file.fsPath).toLowerCase(); + const language = init.languages.find((l) => l.canRun && l.extensions.some((e) => e.toLowerCase() === extension)); + if (!language) { + const runnable = init.languages.filter((l) => l.canRun).flatMap((l) => l.extensions).join(' '); + throw new Error(`FrySharp does not run ${extension || 'this'} files (it runs ${runnable}).`); + } + + this.clearProblems(run); + const runId = `run-${++this.counter}-${Date.now().toString(36)}`; + this.byRunId.set(runId, run); + this.changed(); + + const relative = path.relative(init.workspaceRoot, run.file.fsPath).replace(/\\/g, '/'); + const inside = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); + try { + if (inside) { + await this.sidecar.startRun({ runId, path: relative, arguments: arguments_ }); + } else if (language.id === 'csharp') { + // outside the folder the engine works in: C# can still run as text + const document = await vscode.workspace.openTextDocument(run.file); + await this.sidecar.startRun({ runId, code: document.getText(), languageId: 'csharp', arguments: arguments_ }); + } else { + throw new Error(`FrySharp runs ${language.displayName} files inside the folder it works in (${init.workspaceRoot}); this one is outside it.`); + } + } catch (error) { + this.byRunId.delete(runId); + throw error; + } + return runId; + } + + sendInput(runId: string, line: string): Promise { + return this.sidecar.runInput(runId, line); + } + + async stop(runId: string): Promise { + await this.sidecar.stopRun(runId).catch(() => false); + } + + /** Stops the program of the terminal in front, or every one that runs when none is. */ + async stopActive(): Promise { + const active = vscode.window.activeTerminal; + const entries = [...this.terminals.values()]; + const targets = entries.filter((entry) => entry.run.running && entry.terminal === active); + for (const entry of targets.length > 0 ? targets : entries.filter((e) => e.run.running)) { + if (entry.run.runId) await this.stop(entry.run.runId); + } + } + + /** Runs the program of the terminal in front again; or the last one that ran. */ + async rerunActive(): Promise { + const active = vscode.window.activeTerminal; + const entry = [...this.terminals.values()].find((e) => e.terminal === active) ?? [...this.terminals.values()].pop(); + if (entry) await entry.run.restart(); + } + + closed(run: RunTerminal): void { + this.terminals.delete(run.file.fsPath); + this.changed(); + } + + /** Tells the rest of VS Code whether something is running (the Stop key only works while it is). */ + changed(): void { + void vscode.commands.executeCommand('setContext', 'frysharp.runActive', this.running > 0); + this.changedEmitter.fire(); + } + + // ── What the engine says ────────────────────────────────────────────────────────────────────────────────────── + + private display(runId: string, data: Record): void { + const run = this.byRunId.get(runId); + if (!run) return; + const title = `${path.basename(run.file.fsPath)}, ${new Date().toLocaleTimeString()}`; + const item = itemFromDisplay(this.counter * 1000 + this.results.count, title, data); + void this.results.add(item); + run.shown(typeof data['text/plain'] === 'string' ? data['text/plain'].split('\n')[0] : 'a display'); + } + + private exited(runId: string, exitCode: number, durationMs: number, problems: Array<{ path?: string; severity: string; code: string; message: string; line: number; column: number }>): void { + const run = this.byRunId.get(runId); + if (!run) return; + this.byRunId.delete(runId); + + const root = this.sidecar.workspaceRoot; + const byFile = new Map(); + for (const problem of problems) { + const file = problem.path && root ? path.resolve(root, problem.path) : run.file.fsPath; + const line = Math.max(0, problem.line - 1); + const column = Math.max(0, problem.column - 1); + const severity = problem.severity === 'warning' ? vscode.DiagnosticSeverity.Warning : problem.severity === 'info' ? vscode.DiagnosticSeverity.Information : vscode.DiagnosticSeverity.Error; + const diagnostic = new vscode.Diagnostic(new vscode.Range(line, column, line, column + 1), problem.message, severity); + diagnostic.code = problem.code; + diagnostic.source = 'FrySharp'; + byFile.set(file, [...(byFile.get(file) ?? []), diagnostic]); + } + for (const [file, diagnostics] of byFile) { + const uri = vscode.Uri.file(file); + this.problems.set(uri, diagnostics); + run.marked.push(uri); + } + run.exited(exitCode, durationMs); + } + + private clearProblems(run: RunTerminal): void { + for (const uri of run.marked) this.problems.delete(uri); + run.marked = []; + } + + // ── Places named in the output ──────────────────────────────────────────────────────────────────────────────── + + private links(context: vscode.TerminalLinkContext): OutputTerminalLink[] { + const owner = [...this.terminals.values()].find((entry) => entry.terminal === context.terminal); + if (!owner) return []; + + const folders = [path.dirname(owner.run.file.fsPath), this.sidecar.workspaceRoot, ...(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath)]; + const links: OutputTerminalLink[] = []; + for (const place of findLinks(context.line)) { + const candidates = path.isAbsolute(place.file) ? [place.file] : folders.filter((f): f is string => !!f).map((folder) => path.resolve(folder, place.file)); + const found = candidates.find((candidate) => fs.existsSync(candidate)); + if (found) links.push({ startIndex: place.start, length: place.length, tooltip: 'Open in the editor', file: found, line: place.line, column: place.column }); + } + return links; + } + + private async open(link: OutputTerminalLink): Promise { + const position = new vscode.Position(link.line - 1, link.column - 1); + await vscode.window.showTextDocument(vscode.Uri.file(link.file), { selection: new vscode.Range(position, position) }); + } + + dispose(): void { + for (const { terminal } of this.terminals.values()) terminal.dispose(); + this.terminals.clear(); + this.disposables.forEach((d) => d.dispose()); + } +} + +interface OutputTerminalLink extends vscode.TerminalLink { + file: string; + line: number; + column: number; +} diff --git a/vscode-frysharp/src/shared/outputTabs.ts b/vscode-frysharp/src/shared/outputTabs.ts new file mode 100644 index 00000000..eb16e3a5 --- /dev/null +++ b/vscode-frysharp/src/shared/outputTabs.ts @@ -0,0 +1,240 @@ +// What a cell's output is when it is shown as tabs (Table, Console, Charts ... All), as the desktop app shows it: the plain data +// the extension puts in the one output it makes for such a cell, and the questions both sides ask of it (which tabs, how many +// lines, which text). No DOM and no Node here: the notebook renderer (a browser) and the extension (Node) both import it. + +/** The one output of a cell with several kinds of output: every output the cell made, kept as it was, for the renderer to draw as tabs. */ +export const CELL_OUTPUT_MIME = 'application/vnd.fry.cell-output+json'; + +export const TABLE_MIME = 'application/vnd.fry.table+json'; +export const INSPECTOR_MIME = 'application/vnd.fry.inspector+json'; +export const STDOUT_MIME = 'application/vnd.code.notebook.stdout'; +export const STDERR_MIME = 'application/vnd.code.notebook.stderr'; +export const ERROR_MIME = 'application/vnd.code.notebook.error'; + +/** One item of an output: text, or base64 for what is not text (an image). */ +export interface PackedItem { + mime: string; + text?: string; + base64?: string; +} + +export interface PackedOutput { + items: PackedItem[]; + metadata?: Record; + /** Text output only: how many lines were left off its start (shown while a cell runs, so a flood of lines is not sent again and again). */ + omitted?: number; +} + +export interface PackedCell { + v: 1; + outputs: PackedOutput[]; +} + +/** The kinds of output a cell has, in the order the desktop lists them (its tabs). */ +export const TAB_KINDS = ['table', 'console', 'inspector', 'image', 'chart', 'visualizer', 'plot3d', 'html', 'markdown'] as const; +export type TabKind = (typeof TAB_KINDS)[number]; + +export const TAB_LABELS: Record = { + table: 'Table', + console: 'Console', + inspector: 'Inspector', + image: 'Image', + chart: 'Chart', + visualizer: 'Visualizer', + plot3d: '3D', + html: 'HTML', + markdown: 'Markdown' +}; + +/** What one output is, from the MIME types it holds: the richest type decides (a table output also holds its page and a text fallback). Undefined for one that is none of these. */ +export function kindOfMimes(mimes: readonly string[]): TabKind | undefined { + if (mimes.includes(TABLE_MIME)) return 'table'; + if (mimes.includes(INSPECTOR_MIME)) return 'inspector'; + for (const mime of mimes) { + const visual = /^application\/vnd\.fry\.(chart|plot3d|visualizer)\.v\d+\+json$/.exec(mime); + if (visual) return visual[1] as TabKind; + } + if (mimes.includes(STDOUT_MIME) || mimes.includes(STDERR_MIME)) return 'console'; + if (mimes.some((mime) => mime.startsWith('image/'))) return 'image'; + if (mimes.includes('text/html')) return 'html'; + if (mimes.includes('text/markdown')) return 'markdown'; + if (mimes.includes('text/plain')) return 'console'; + return undefined; +} + +export const kindOf = (output: PackedOutput): TabKind | undefined => kindOfMimes(output.items.map((item) => item.mime)); + +export interface TabInfo { + /** 'all' shows every output, in order. */ + id: TabKind | 'all'; + label: string; + /** The badge: rows of the table, lines of the console, how many outputs of that kind (when more than one). */ + count?: number; + /** The outputs the tab shows, by their place in the cell's list. */ + outputs: number[]; +} + +/** The text of a console output (what a cell printed: stdout and stderr, or the plain text of a display). */ +export function consoleTextOf(output: PackedOutput): string { + const printed = output.items.filter((item) => item.mime === STDOUT_MIME || item.mime === STDERR_MIME); + const items = printed.length > 0 ? printed : output.items.filter((item) => item.mime === 'text/plain'); + return items.map((item) => item.text ?? '').join(''); +} + +/** How many lines text has (a last line break does not start another). */ +export function countLines(text: string): number { + if (text.length === 0) return 0; + const breaks = text.split('\n').length - 1; + return text.endsWith('\n') ? breaks : breaks + 1; +} + +function rowsOf(output: PackedOutput): number { + const item = output.items.find((i) => i.mime === TABLE_MIME); + if (!item?.text) return 0; + try { + const table = JSON.parse(item.text) as { rows?: unknown[]; totalRows?: number }; + return typeof table.totalRows === 'number' ? table.totalRows : Array.isArray(table.rows) ? table.rows.length : 0; + } catch { + return 0; + } +} + +/** The tabs of a cell's outputs: one for each kind present, as the desktop orders them, and All when there is more than one. */ +export function tabsOf(cell: PackedCell): TabInfo[] { + const tabs: TabInfo[] = []; + for (const kind of TAB_KINDS) { + const outputs = cell.outputs.flatMap((output, index) => (kindOf(output) === kind ? [index] : [])); + if (outputs.length === 0) continue; + const shown = outputs.map((index) => cell.outputs[index]); + let count: number | undefined; + if (kind === 'table') count = shown.reduce((sum, output) => sum + rowsOf(output), 0); + else if (kind === 'console') count = shown.reduce((sum, output) => sum + countLines(consoleTextOf(output)) + (output.omitted ?? 0), 0); + else if (outputs.length > 1) count = outputs.length; + tabs.push({ id: kind, label: TAB_LABELS[kind], count, outputs }); + } + if (tabs.length > 1) tabs.push({ id: 'all', label: 'All', outputs: cell.outputs.map((_, index) => index) }); + return tabs; +} + +/** The text a short description of the cell's output: what a place that cannot draw tabs shows (and an accessible reading of it). */ +export function summaryOf(cell: PackedCell, maxLines = 40): string { + const tabs = tabsOf(cell).filter((tab) => tab.id !== 'all'); + const parts = tabs.map((tab) => (tab.count === undefined ? tab.label : `${tab.label} (${tab.count})`)); + const lines = cell.outputs.filter((output) => kindOf(output) === 'console').map(consoleTextOf).join('').split('\n'); + const shown = lines.slice(0, maxLines).join('\n').trimEnd(); + return `[${parts.join(' · ')}]${shown ? '\n' + shown : ''}${lines.length > maxLines ? '\n…' : ''}`; +} + +/** Keeps the last `maxLines` lines of a text, and says how many it left off. */ +export function lastLines(text: string, maxLines: number): { text: string; omitted: number } { + const lines = text.split('\n'); + const trailing = text.endsWith('\n'); + const count = trailing ? lines.length - 1 : lines.length; + if (count <= maxLines) return { text, omitted: 0 }; + const kept = lines.slice(trailing ? count - maxLines : lines.length - maxLines, trailing ? count : undefined); + return { text: kept.join('\n') + (trailing ? '\n' : ''), omitted: count - maxLines }; +} + +// ── ANSI text ──────────────────────────────────────────────────────────────────────────────────────────────────── + +export interface AnsiSpan { + text: string; + /** A CSS colour (a theme variable with a fallback, or rgb()). */ + fg?: string; + bg?: string; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; +} + +const BASIC = ['Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White']; +const FALLBACK = ['#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5', '#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#e5e5e5']; + +// The terminal colours of the editor's theme (--vscode-terminal-ansiRed ...), so the console looks like VS Code's own. +function theme(index: number): string { + const name = (index >= 8 ? 'Bright' : '') + BASIC[index % 8]; + return `var(--vscode-terminal-ansi${name}, ${FALLBACK[index]})`; +} + +function xterm256(n: number): string | undefined { + if (n < 0 || n > 255) return undefined; + if (n < 16) return theme(n); + if (n >= 232) { + const level = 8 + (n - 232) * 10; + return `rgb(${level}, ${level}, ${level})`; + } + const cube = n - 16; + const channel = (value: number) => (value === 0 ? 0 : 55 + value * 40); + return `rgb(${channel(Math.floor(cube / 36))}, ${channel(Math.floor(cube / 6) % 6)}, ${channel(cube % 6)})`; +} + +/** + * Applies carriage returns as a terminal does, for the progress bars that redraw one line: what follows a return replaces the line so far + * (only the part it covers: a shorter redraw leaves the rest of the old line behind, as it would on screen). + */ +export function applyCarriageReturns(text: string): string { + if (!text.includes('\r')) return text; + return text + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => { + if (!line.includes('\r')) return line; + let shown = ''; + for (const part of line.split('\r')) shown = part + shown.slice(part.length); + return shown; + }) + .join('\n'); +} + +/** Text with ANSI colours and styles as runs of text and what to draw them with; other escape sequences are dropped. */ +export function parseAnsi(text: string): AnsiSpan[] { + const spans: AnsiSpan[] = []; + let style: Omit = {}; + let position = 0; + // the pattern is made of the escape character: that is what is being looked for + // eslint-disable-next-line no-control-regex + const pattern = /\u001b\[([?<>=]?[0-9;:]*)([A-Za-z])|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[()][A-Za-z0-9]/g; + const push = (end: number) => { + if (end > position) spans.push({ text: text.slice(position, end), ...style }); + }; + + for (let match = pattern.exec(text); match; match = pattern.exec(text)) { + push(match.index); + position = match.index + match[0].length; + if (match[2] !== 'm') continue; // cursor movement, clearing: not drawn + const codes = (match[1] === '' ? '0' : match[1]).split(/[;:]/).map((code) => (code === '' ? 0 : Number(code))); + for (let i = 0; i < codes.length; i++) { + const code = codes[i]; + if (code === 0) style = {}; + else if (code === 1) style = { ...style, bold: true }; + else if (code === 2) style = { ...style, dim: true }; + else if (code === 3) style = { ...style, italic: true }; + else if (code === 4) style = { ...style, underline: true }; + else if (code === 22) style = { ...style, bold: undefined, dim: undefined }; + else if (code === 23) style = { ...style, italic: undefined }; + else if (code === 24) style = { ...style, underline: undefined }; + else if (code >= 30 && code <= 37) style = { ...style, fg: theme(code - 30) }; + else if (code >= 90 && code <= 97) style = { ...style, fg: theme(code - 90 + 8) }; + else if (code >= 40 && code <= 47) style = { ...style, bg: theme(code - 40) }; + else if (code >= 100 && code <= 107) style = { ...style, bg: theme(code - 100 + 8) }; + else if (code === 39) style = { ...style, fg: undefined }; + else if (code === 49) style = { ...style, bg: undefined }; + else if (code === 38 || code === 48) { + const key = code === 38 ? 'fg' : 'bg'; + if (codes[i + 1] === 5) { + const colour = xterm256(codes[i + 2]); + if (colour) style = { ...style, [key]: colour }; + i += 2; + } else if (codes[i + 1] === 2) { + const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]]; + if ([r, g, b].every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) style = { ...style, [key]: `rgb(${r}, ${g}, ${b})` }; + i += 4; + } + } + } + } + + push(text.length); + return spans; +} diff --git a/vscode-frysharp/src/sidecar/events.ts b/vscode-frysharp/src/sidecar/events.ts new file mode 100644 index 00000000..9e46931e --- /dev/null +++ b/vscode-frysharp/src/sidecar/events.ts @@ -0,0 +1,30 @@ +// A small typed event, shaped like vscode.Event so the sidecar client needs no vscode import and runs in plain Node. + +export interface Disposable { + dispose(): void; +} + +export type Event = (listener: (value: T) => void) => Disposable; + +export class Emitter { + private readonly listeners = new Set<(value: T) => void>(); + + readonly event: Event = (listener) => { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + }; + + fire(value: T): void { + for (const listener of [...this.listeners]) { + try { + listener(value); + } catch { + // one listener's failure must not stop the others + } + } + } + + dispose(): void { + this.listeners.clear(); + } +} diff --git a/vscode-frysharp/src/sidecar/launcher.ts b/vscode-frysharp/src/sidecar/launcher.ts new file mode 100644 index 00000000..72d3223c --- /dev/null +++ b/vscode-frysharp/src/sidecar/launcher.ts @@ -0,0 +1,54 @@ +import * as path from 'path'; + +export interface LaunchInputs { + /** The frysharp.sidecarPath setting: a frysharp-sidecar.dll to use instead of the packaged one. */ + setting?: string; + extensionPath: string; + /** The extension is running from the repository (the Extension Development Host), not from an installed package. */ + development: boolean; + exists(file: string): boolean; +} + +export interface SidecarLaunch { + /** The arguments for `dotnet`. */ + args: string[]; + description: string; +} + +const DLL = 'frysharp-sidecar.dll'; + +/** + * Which sidecar to start. The one the setting names comes first. Running from the repository (the Extension Development + * Host) the one built there is next, or the project run by `dotnet run`: a developer's own build is what they mean, and a + * packaged copy left in the folder may be old. Otherwise, and when there is no build, the one packaged with the extension. + * An installed extension never runs a build it did not bring. + */ +export function resolveSidecarLaunch(inputs: LaunchInputs, workspaceRoot: string): SidecarLaunch { + const workspace = ['--workspace', workspaceRoot]; + + if (inputs.setting) { + if (!inputs.exists(inputs.setting)) { + throw new Error(`frysharp.sidecarPath names '${inputs.setting}', which does not exist.`); + } + return { args: [inputs.setting, ...workspace], description: `the sidecar from the frysharp.sidecarPath setting (${inputs.setting})` }; + } + + if (inputs.development) { + const repo = path.resolve(inputs.extensionPath, '..'); + const built = path.join(repo, 'FrySharp.Sidecar', 'bin', 'Debug', 'net10.0', DLL); + if (inputs.exists(built)) { + return { args: [built, ...workspace], description: `the sidecar built in the repository (${built})` }; + } + const project = path.join(repo, 'FrySharp.Sidecar', 'FrySharp.Sidecar.csproj'); + if (inputs.exists(project)) { + return { args: ['run', '--project', project, '--', ...workspace], description: `the sidecar project, run by dotnet (${project})` }; + } + } + + const packaged = path.join(inputs.extensionPath, 'bin', 'sidecar', DLL); + if (inputs.exists(packaged)) { + return { args: [packaged, ...workspace], description: `the packaged sidecar (${packaged})` }; + } + + throw new Error('The FrySharp engine is not part of this installation. Reinstall the extension, or set frysharp.sidecarPath.'); +} diff --git a/vscode-frysharp/src/sidecar/protocol.ts b/vscode-frysharp/src/sidecar/protocol.ts index 892fe5e1..d3fc8909 100644 --- a/vscode-frysharp/src/sidecar/protocol.ts +++ b/vscode-frysharp/src/sidecar/protocol.ts @@ -36,6 +36,21 @@ export interface CellOutput { text?: string; data?: Record; metadata?: Record; + /** Names a display a program may update later (a chart it redraws). */ + displayId?: string; + /** This redraws the display `displayId` already shown, rather than showing a new one. */ + update?: boolean; +} + +/** Where a cell went wrong, in its own lines and columns (1-based). */ +export interface DiagnosticDto { + severity: string; + code: string; + message: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; } export interface NotebookCell { @@ -57,23 +72,61 @@ export interface NotebookContent { cells: NotebookCell[]; } +/** + * Which notebook a request is about. A notebook open in an editor is named by `notebookId`, any text that stays the same + * for that notebook (a file's URI, an untitled notebook's); the engine then keeps only its kernels. One named by `path` + * (relative to the workspace) is a file the engine opens, edits and saves itself. + */ +export interface NotebookRef { + path?: string; + notebookId?: string; +} + export interface ExecuteCellParams { path: string; cellId: string; source: string; language?: string; + notebookId?: string; + /** The folder the notebook's kernels run in (the notebook's own folder, which need not be in the workspace). */ + workingDirectory?: string; + /** Names this run: every notification about it carries it. */ + executionId?: string; } export interface ExecuteCellResult { executionCount: number; success: boolean; durationMs: number; + /** The number of the last notification the run sent: the reply can arrive before some of them. */ + lastSeq?: number; + diagnostics?: DiagnosticDto[]; + /** Why the cell failed, in a line (not set when it was stopped). */ + error?: CellError; +} + +export interface CellError { + /** The kind of error: an exception's class, a compiler's code, "panic". */ + name: string; + message: string; +} + +/** A running cell asks for a line of input (`input()`, `Console.ReadLine()`). */ +export interface InputRequest { + prompt: string; + password: boolean; + path?: string; + cellId?: string; + executionId?: string; } export interface NotebookOutputNotification { path: string; cellId: string; output: CellOutput; + executionId?: string; + /** 1, 2, 3... for the notifications of one run, in the order they were made. */ + seq?: number; } export interface CellStateNotification { @@ -83,6 +136,8 @@ export interface CellStateNotification { executionCount?: number; success?: boolean; durationMs?: number; + executionId?: string; + seq?: number; } export interface RunStartParams { @@ -103,6 +158,18 @@ export interface RunOutputNotification { text: string; } +/** A program a run started shows something that is not text: a table, a chart, an image (the same MIME bundle as a cell's). */ +export interface RunDisplayNotification { + runId: string; + data: Record; + metadata?: Record; +} + +export interface RunInputParams { + runId: string; + text: string; +} + export interface RunExitedNotification { runId: string; exitCode: number; @@ -123,3 +190,63 @@ export interface VariableDto { value: string; kernel: string; } + +// ── Language features in a notebook cell ─────────────────────────────────────────────────────────────────────────── + +/** Where in a cell, and what the cells before it define (their text, in order, those in the same language). */ +export interface CellPositionParams { + language: string; + source: string; + offset: number; + context?: string[]; +} + +export interface CellDiagnosticsParams { + language: string; + source: string; + context?: string[]; +} + +export interface CellDiagnosticsResult { + diagnostics: DiagnosticDto[]; +} + +export interface CompletionItem { + label: string; + insertText: string; + kind: string; + detail?: string; + documentation?: string; + sortText: string; +} + +export interface CompletionResult { + items: CompletionItem[]; +} + +export interface HoverResult { + markdown: string[]; +} + +/** What the engine runs on and which tool each language found (`fry/engine/info`). */ +export interface EngineInfo { + serverVersion: string; + protocolVersion: number; + runtime: string; + operatingSystem: string; + architecture: string; + workspaceRoot: string; + toolchains: ToolchainReport[]; +} + +export interface ToolchainReport { + languageId: string; + displayName: string; + found: boolean; + name?: string; + version?: string; + path?: string; + source?: string; + /** When not found: what is missing and how to get it. */ + missing?: string; +} diff --git a/vscode-frysharp/src/sidecar/runtime.ts b/vscode-frysharp/src/sidecar/runtime.ts new file mode 100644 index 00000000..56e0e86f --- /dev/null +++ b/vscode-frysharp/src/sidecar/runtime.ts @@ -0,0 +1,65 @@ +import * as cp from 'child_process'; + +/** The .NET the engine needs. */ +export const REQUIRED_MAJOR = 10; + +/** True when the output of `dotnet --list-runtimes` has the shared runtime (Microsoft.NETCore.App) of this major version or newer. */ +export function hasRuntime(listing: string, major: number = REQUIRED_MAJOR): boolean { + return listing.split(/\r?\n/).some((line) => { + const found = /^Microsoft\.NETCore\.App (\d+)\./.exec(line.trim()); + return found !== null && parseInt(found[1], 10) >= major; + }); +} + +/** Runs `dotnet --list-runtimes` with this executable; null when it cannot be run. */ +export function listRuntimes(dotnet: string): Promise { + return new Promise((resolve) => { + cp.execFile(dotnet, ['--list-runtimes'], { timeout: 15_000 }, (error, stdout) => resolve(error ? null : stdout)); + }); +} + +export class DotnetMissingError extends Error { + constructor(message: string) { + super(message); + this.name = 'DotnetMissingError'; + } +} + +export interface RuntimeSources { + /** The frysharp.dotnetPath setting. */ + setting?: string; + /** Asks the .NET Install Tool (ms-dotnettools.vscode-dotnet-runtime) for a runtime; undefined when it is not installed. */ + acquire?: () => Promise; + log(message: string): void; +} + +/** + * The `dotnet` to run the engine with: the one the setting names; else the `dotnet` on the PATH when it has a runtime new + * enough (nothing to download); else the one the .NET Install Tool provides. Says what is missing when none does. + */ +export async function resolveDotnet(sources: RuntimeSources): Promise { + if (sources.setting) { + const listing = await listRuntimes(sources.setting); + if (listing === null) throw new DotnetMissingError(`frysharp.dotnetPath names '${sources.setting}', which cannot be run.`); + if (!hasRuntime(listing)) throw new DotnetMissingError(`'${sources.setting}' has no .NET ${REQUIRED_MAJOR} runtime.`); + return sources.setting; + } + + const onPath = await listRuntimes('dotnet'); + if (onPath !== null && hasRuntime(onPath)) return 'dotnet'; + + if (sources.acquire) { + try { + const acquired = await sources.acquire(); + if (acquired) return acquired; + } catch (error) { + sources.log(`The .NET Install Tool could not provide .NET ${REQUIRED_MAJOR}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + throw new DotnetMissingError( + onPath === null + ? `FrySharp needs .NET ${REQUIRED_MAJOR}, and no 'dotnet' could be run.` + : `FrySharp needs .NET ${REQUIRED_MAJOR}; the 'dotnet' on the PATH has no runtime that new.` + ); +} diff --git a/vscode-frysharp/src/sidecar/sidecarClient.ts b/vscode-frysharp/src/sidecar/sidecarClient.ts index ba059409..0ac4a2dd 100644 --- a/vscode-frysharp/src/sidecar/sidecarClient.ts +++ b/vscode-frysharp/src/sidecar/sidecarClient.ts @@ -1,192 +1,328 @@ import * as cp from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; import * as rpc from 'vscode-jsonrpc/node'; +import { Emitter } from './events'; import { + CellDiagnosticsParams, + CellDiagnosticsResult, + CellPositionParams, + CellStateNotification, + CompletionResult, + EngineInfo, ExecuteCellParams, ExecuteCellResult, InitializeParams, InitializeResult, - NotebookContent, + HoverResult, + InputRequest, + LanguageInfo, NotebookOutputNotification, - CellStateNotification, - RunOutputNotification, + NotebookRef, + RunDisplayNotification, RunExitedNotification, + RunOutputNotification, RunStartParams, RunStartResult, VariableDto } from './protocol'; -export class SidecarClient implements vscode.Disposable { - private process?: cp.ChildProcess; +/** This extension speaks version 1 of the session protocol (docs/session-protocol.md). */ +export const PROTOCOL_VERSION = 1; + +export type SidecarState = 'stopped' | 'starting' | 'ready' | 'crashed'; + +export interface SidecarProcess { + command: string; + args: string[]; + cwd: string; + /** Which sidecar this is and where it came from, for people looking for why something does not work. */ + description?: string; +} + +export interface SidecarClientOptions { + /** What to run, worked out each time the engine starts (the .NET to use, the sidecar to run). */ + prepare(): Promise; + log(message: string): void; + clientName?: string; +} + +/** Something that can be told to stop waiting (a VS Code CancellationToken is one). */ +export interface CancellationLike { + isCancellationRequested: boolean; + onCancellationRequested(listener: () => void): { dispose(): void }; +} + +export interface RequestOptions { + /** When it is cancelled the engine is told to stop, and the request is rejected. */ + token?: CancellationLike; + /** How long to wait for the answer; 0 waits as long as it takes (a cell). Default 30 s. */ + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * The extension's connection to the engine: JSON-RPC over the sidecar's stdio. It starts the engine when first asked, + * starts it again after it died (its notebooks' kernels are gone then, which `onDidLoseState` says), and gives every + * request a deadline. It imports nothing of VS Code, so it is tested in plain Node against the real engine. + */ +export class SidecarClient { + private child?: cp.ChildProcess; private connection?: rpc.MessageConnection; - private outputChannel: vscode.OutputChannel; - private onNotebookOutputEmitter = new vscode.EventEmitter(); - private onCellStateEmitter = new vscode.EventEmitter(); - private onRunOutputEmitter = new vscode.EventEmitter(); - private onRunExitedEmitter = new vscode.EventEmitter(); + private starting?: Promise; + private initialized?: InitializeResult; + private disposed = false; + private currentState: SidecarState = 'stopped'; + private everStarted = false; + private launched?: SidecarProcess; - public readonly onNotebookOutput = this.onNotebookOutputEmitter.event; - public readonly onCellState = this.onCellStateEmitter.event; - public readonly onRunOutput = this.onRunOutputEmitter.event; - public readonly onRunExited = this.onRunExitedEmitter.event; + private inputHandler?: (request: InputRequest) => Promise; - constructor(private context: vscode.ExtensionContext) { - this.outputChannel = vscode.window.createOutputChannel('FrySharp Sidecar'); + private readonly stateEmitter = new Emitter(); + private readonly initializedEmitter = new Emitter(); + private readonly lostStateEmitter = new Emitter<{ reason: string }>(); + private readonly notebookOutputEmitter = new Emitter(); + private readonly cellStateEmitter = new Emitter(); + private readonly runOutputEmitter = new Emitter(); + private readonly runExitedEmitter = new Emitter(); + private readonly runDisplayEmitter = new Emitter(); + private readonly runInputRequestedEmitter = new Emitter<{ runId: string }>(); + + readonly onDidChangeState = this.stateEmitter.event; + /** The engine started and said what it can do (its languages): once for each start. */ + readonly onDidInitialize = this.initializedEmitter.event; + /** The engine stopped on its own (once, when it did): every notebook's kernels, and their variables, are gone. A restart asked for does not say it. */ + readonly onDidLoseState = this.lostStateEmitter.event; + readonly onNotebookOutput = this.notebookOutputEmitter.event; + readonly onCellState = this.cellStateEmitter.event; + readonly onRunOutput = this.runOutputEmitter.event; + readonly onRunExited = this.runExitedEmitter.event; + readonly onRunDisplay = this.runDisplayEmitter.event; + /** A running program waits for a line of input. */ + readonly onRunInputRequested = this.runInputRequestedEmitter.event; + + constructor(private readonly options: SidecarClientOptions) {} + + /** What the engine was last started as (the command and which sidecar it is), when it has been. */ + get launch(): SidecarProcess | undefined { + return this.launched; } - public async start(): Promise { - if (this.connection) { - return; - } + get state(): SidecarState { + return this.currentState; + } - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); - this.outputChannel.appendLine(`Starting FrySharp sidecar in: ${workspaceRoot}`); + /** Who answers when a running cell asks for a line of input: the text, or null for the end of input. Without one, a cell sees the end of input. */ + setInputHandler(handler: ((request: InputRequest) => Promise) | undefined): void { + this.inputHandler = handler; + } - // Resolve sidecar executable: custom setting -> bundled binary -> repo build -> dotnet run - const config = vscode.workspace.getConfiguration('frysharp'); - const customSidecarPath = config.get('sidecarPath'); + /** The languages of the engine, once it has started. */ + get languages(): LanguageInfo[] { + return this.initialized?.languages ?? []; + } - const bundledDll = path.join(this.context.extensionPath, 'bin', 'sidecar', 'frysharp-sidecar.dll'); - const repoRoot = path.resolve(this.context.extensionPath, '..'); - const repoDevDll = path.join(repoRoot, 'FrySharp.Sidecar', 'bin', 'Debug', 'net10.0', 'frysharp-sidecar.dll'); - const repoCsproj = path.join(repoRoot, 'FrySharp.Sidecar', 'FrySharp.Sidecar.csproj'); + /** The folder the engine works in (relative paths are from here), once it has started. */ + get workspaceRoot(): string | undefined { + return this.initialized?.workspaceRoot; + } - let command = 'dotnet'; - let args: string[] = []; + get serverVersion(): string | undefined { + return this.initialized?.serverVersion; + } - if (this.process) { - try { this.process.kill(); } catch { } - this.process = undefined; - } + /** Starts the engine if it is not running. Everyone who asks while it starts gets the same start. */ + ensureStarted(): Promise { + if (this.disposed) return Promise.reject(new Error('The FrySharp engine client was disposed.')); + if (this.currentState === 'ready' && this.initialized) return Promise.resolve(this.initialized); + this.starting ??= this.start().finally(() => (this.starting = undefined)); + return this.starting; + } - if (customSidecarPath && fs.existsSync(customSidecarPath)) { - this.outputChannel.appendLine(`Using custom sidecar: ${customSidecarPath}`); - args = [customSidecarPath, '--workspace', workspaceRoot]; - } else if (fs.existsSync(repoDevDll) && (!fs.existsSync(bundledDll) || fs.statSync(repoDevDll).mtimeMs > fs.statSync(bundledDll).mtimeMs)) { - this.outputChannel.appendLine(`Using latest local dev sidecar: ${repoDevDll}`); - args = [repoDevDll, '--workspace', workspaceRoot]; - } else if (fs.existsSync(bundledDll)) { - this.outputChannel.appendLine(`Using bundled sidecar: ${bundledDll}`); - args = [bundledDll, '--workspace', workspaceRoot]; - } else if (fs.existsSync(repoDevDll)) { - this.outputChannel.appendLine(`Using local dev sidecar: ${repoDevDll}`); - args = [repoDevDll, '--workspace', workspaceRoot]; - } else if (fs.existsSync(repoCsproj)) { - this.outputChannel.appendLine(`Running sidecar via dotnet run: ${repoCsproj}`); - args = ['run', '--project', repoCsproj, '--', '--workspace', workspaceRoot]; - } else { - throw new Error('FrySharp sidecar binary not found. Please ensure .NET 10 is installed.'); - } + async request(method: string, params: unknown, options: RequestOptions = {}): Promise { + await this.ensureStarted(); + const connection = this.connection; + if (!connection) throw new Error('The FrySharp engine is not running.'); - this.process = cp.spawn(command, args, { - cwd: workspaceRoot, - env: { ...process.env, DOTNET_NOLOGO: '1' } - }); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const source = options.token ? new rpc.CancellationTokenSource() : undefined; + const link = options.token?.onCancellationRequested(() => source?.cancel()); + if (options.token?.isCancellationRequested) source?.cancel(); + const call = source ? connection.sendRequest(method, params, source.token) : connection.sendRequest(method, params); + if (timeoutMs === 0) return call.finally(() => link?.dispose()); - this.process.stderr?.on('data', (data) => { - this.outputChannel.append(`[Sidecar Log] ${data.toString()}`); + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`The FrySharp engine did not answer ${method} within ${Math.round(timeoutMs / 1000)} seconds.`)), timeoutMs); }); + try { + return await Promise.race([call, deadline]); + } finally { + clearTimeout(timer); + link?.dispose(); + } + } - this.process.on('exit', (code, signal) => { - this.outputChannel.appendLine(`Sidecar process exited with code ${code}, signal ${signal}`); - this.connection = undefined; - }); + // ── The requests the extension makes ────────────────────────────────────────────────────────────────────────── - this.connection = rpc.createMessageConnection( - new rpc.StreamMessageReader(this.process.stdout!), - new rpc.StreamMessageWriter(this.process.stdin!) - ); + executeCell(params: ExecuteCellParams): Promise { + return this.request('fry/notebook/execute', params, { timeoutMs: 0 }); + } - // Register notifications - this.connection.onNotification('fry/notebook/output', (n: NotebookOutputNotification) => { - this.onNotebookOutputEmitter.fire(n); - }); + async interruptNotebook(notebook: NotebookRef): Promise { + if (this.currentState === 'ready') await this.request('fry/notebook/interrupt', notebook); + } - this.connection.onNotification('fry/notebook/cellState', (n: CellStateNotification) => { - this.onCellStateEmitter.fire(n); - }); + async restartNotebook(notebook: NotebookRef): Promise { + if (this.currentState === 'ready') await this.request('fry/notebook/restart', notebook); + } - this.connection.onNotification('fry/run/output', (n: RunOutputNotification) => { - this.onRunOutputEmitter.fire(n); - }); + async closeNotebook(notebook: NotebookRef): Promise { + if (this.currentState === 'ready') await this.request('fry/notebook/close', notebook).catch(() => undefined); + } - this.connection.onNotification('fry/run/exited', (n: RunExitedNotification) => { - this.onRunExitedEmitter.fire(n); - }); + getVariables(notebook: NotebookRef): Promise { + return this.request('fry/notebook/variables', notebook); + } - this.connection.listen(); + // ── Language features of a notebook cell: quick, and not worth waiting long for ───────────────────────────────────── - const initResult = await this.connection.sendRequest('fry/initialize', { - clientId: 'vscode-client', - clientName: 'VS Code Extension', - protocolVersion: 1 - } as InitializeParams); + completeCell(params: CellPositionParams, token?: CancellationLike): Promise { + return this.request('fry/language/completeCell', params, { timeoutMs: 8_000, token }); + } - this.outputChannel.appendLine(`Connected to FrySharp Sidecar v${initResult.serverVersion}`); + hoverCell(params: CellPositionParams, token?: CancellationLike): Promise { + return this.request('fry/language/hoverCell', params, { timeoutMs: 8_000, token }); } - public async openNotebook(relativePath: string): Promise { - if (!this.connection) await this.start(); - return this.connection!.sendRequest('fry/notebook/open', { path: relativePath }); + /** What the engine runs on and which tool each language found (the probes can take a few seconds the first time). */ + engineInfo(): Promise { + return this.request('fry/engine/info', {}, { timeoutMs: 45_000 }); } - public async executeCell(relativePath: string, cellId: string, source: string, language?: string): Promise { - if (!this.connection) await this.start(); - return this.connection!.sendRequest('fry/notebook/execute', { - path: relativePath, - cellId, - source, - language - } as ExecuteCellParams); + diagnoseCell(params: CellDiagnosticsParams, token?: CancellationLike): Promise { + return this.request('fry/language/diagnoseCell', params, { timeoutMs: 15_000, token }); } - public async interruptNotebook(relativePath: string): Promise { - if (!this.connection) return; - await this.connection.sendRequest('fry/notebook/interrupt', { path: relativePath }); + startRun(params: RunStartParams): Promise { + return this.request('fry/run/start', params); } - public async restartNotebook(relativePath: string): Promise { - if (!this.connection) return; - await this.connection.sendRequest('fry/notebook/restart', { path: relativePath }); + /** A line for the program to read (the engine writes it into the output too, as a terminal would show it). */ + runInput(runId: string, text: string): Promise { + return this.request('fry/run/input', { runId, text }); } - public async getVariables(relativePath: string): Promise { - if (!this.connection) await this.start(); - return this.connection!.sendRequest('fry/notebook/variables', { path: relativePath }); + async stopRun(runId: string): Promise { + return this.currentState === 'ready' ? this.request('fry/run/stop', { runId }) : false; } - public async saveNotebook(relativePath: string): Promise { - if (!this.connection) return; - await this.connection.sendRequest('fry/notebook/save', { path: relativePath }); + /** Stops the engine and starts it again. */ + async restart(): Promise { + this.stopProcess(); + this.setState('stopped'); + await this.ensureStarted(); } - public async startRun(params: RunStartParams): Promise { - if (!this.connection) await this.start(); - return this.connection!.sendRequest('fry/run/start', params); + dispose(): void { + this.disposed = true; + this.stopProcess(); + this.setState('stopped'); + for (const emitter of [this.stateEmitter, this.initializedEmitter, this.lostStateEmitter, this.notebookOutputEmitter, this.cellStateEmitter, this.runOutputEmitter, this.runExitedEmitter, this.runDisplayEmitter, this.runInputRequestedEmitter]) { + emitter.dispose(); + } } - public async stopRun(runId: string): Promise { - if (!this.connection) return false; - return this.connection.sendRequest('fry/run/stop', { runId }); + // ── Starting, and noticing that it stopped ──────────────────────────────────────────────────────────────────── + + private async start(): Promise { + this.setState('starting'); + try { + const spec = await this.options.prepare(); + this.launched = spec; + this.options.log(`Starting the FrySharp engine: ${spec.command} ${spec.args.join(' ')}`); + + const child = cp.spawn(spec.command, spec.args, { cwd: spec.cwd, env: { ...process.env, DOTNET_NOLOGO: '1' } }); + this.child = child; + child.stderr?.on('data', (data: Buffer) => this.options.log(data.toString().trimEnd())); + child.on('error', (error) => this.options.log(`The engine could not be started: ${error.message}`)); + child.on('exit', (code, signal) => this.onExit(child, code, signal)); + + const connection = rpc.createMessageConnection(new rpc.StreamMessageReader(child.stdout!), new rpc.StreamMessageWriter(child.stdin!)); + connection.onNotification('fry/notebook/output', (n: NotebookOutputNotification) => this.notebookOutputEmitter.fire(n)); + connection.onNotification('fry/notebook/cellState', (n: CellStateNotification) => this.cellStateEmitter.fire(n)); + connection.onNotification('fry/run/output', (n: RunOutputNotification) => this.runOutputEmitter.fire(n)); + connection.onNotification('fry/run/exited', (n: RunExitedNotification) => this.runExitedEmitter.fire(n)); + connection.onNotification('fry/run/display', (n: RunDisplayNotification) => this.runDisplayEmitter.fire(n)); + connection.onNotification('fry/run/inputRequested', (n: { runId: string }) => this.runInputRequestedEmitter.fire(n)); + connection.onRequest('fry/input/request', (request: InputRequest) => (this.inputHandler ? this.inputHandler(request) : null)); + connection.onError(([error]) => this.options.log(`Connection error: ${error.message}`)); + connection.listen(); + this.connection = connection; + + const params: InitializeParams = { clientId: 'vscode-client', clientName: this.options.clientName ?? 'VS Code Extension', protocolVersion: PROTOCOL_VERSION }; + const init = await this.handshake(connection, params); + this.initialized = init; + this.everStarted = true; + this.setState('ready'); + this.options.log(`Connected to the FrySharp engine ${init.serverVersion}.`); + this.initializedEmitter.fire(init); + return init; + } catch (error) { + this.stopProcess(); + this.setState('crashed'); + throw error; + } + } + + private async handshake(connection: rpc.MessageConnection, params: InitializeParams): Promise { + try { + return await Promise.race([ + connection.sendRequest('fry/initialize', params), + new Promise((_, reject) => setTimeout(() => reject(new Error('The FrySharp engine did not answer when it started.')), 60_000).unref()) + ]); + } catch (error) { + const code = (error as { code?: number }).code; + if (code === -32007) { + throw new Error(`This extension speaks protocol ${PROTOCOL_VERSION}, and the engine does not (${(error as Error).message}). Update or reinstall FrySharp.`, { cause: error }); + } + throw error; + } } - public async restart(): Promise { - this.dispose(); - await this.start(); + private onExit(child: cp.ChildProcess, code: number | null, signal: NodeJS.Signals | null): void { + if (child !== this.child) return; // an engine this client already let go of + this.options.log(`The FrySharp engine exited (code ${code}, signal ${signal}).`); + this.child = undefined; + this.connection?.dispose(); + this.connection = undefined; + this.initialized = undefined; + if (this.disposed) return; + if (this.currentState === 'ready' || this.currentState === 'starting') { + this.setState('crashed'); + if (this.everStarted) this.lostStateEmitter.fire({ reason: 'The FrySharp engine stopped unexpectedly.' }); + } } - public dispose(): void { + private stopProcess(): void { + const child = this.child; + this.child = undefined; // first: its exit is expected, not a crash + this.initialized = undefined; try { this.connection?.dispose(); - } catch { } + } catch { + // already closed + } this.connection = undefined; - if (this.process) { - try { - this.process.kill(); - } catch { } - this.process = undefined; + try { + child?.kill(); + } catch { + // already gone } } + + private setState(state: SidecarState): void { + if (state === this.currentState) return; + this.currentState = state; + this.stateEmitter.fire(state); + } } diff --git a/vscode-frysharp/syntaxes/frysharp-directives.injection.json b/vscode-frysharp/syntaxes/frysharp-directives.injection.json new file mode 100644 index 00000000..13e647d1 --- /dev/null +++ b/vscode-frysharp/syntaxes/frysharp-directives.injection.json @@ -0,0 +1,38 @@ +{ + "scopeName": "frysharp.directives.injection", + "injectionSelector": "L:source.cs, L:source.python, L:source.js, L:source.fsharp, L:source.java, L:source.rust, L:source.go, L:source.cpp, L:source.sql", + "patterns": [{ "include": "#share" }, { "include": "#language" }, { "include": "#package" }], + "repository": { + "share": { + "comment": "#!share --from [--as ]", + "match": "^\\s*(#!)(share)\\b(?:\\s+(--from)\\s+([A-Za-z][\\w#+]*))?(?:\\s+(?!--)([A-Za-z_][\\w.]*))?(?:\\s+(--as)\\s+([A-Za-z_][\\w.]*))?", + "captures": { + "1": { "name": "punctuation.definition.directive.frysharp" }, + "2": { "name": "keyword.control.directive.share.frysharp" }, + "3": { "name": "keyword.operator.option.frysharp" }, + "4": { "name": "entity.name.type.language.frysharp" }, + "5": { "name": "variable.other.shared.frysharp" }, + "6": { "name": "keyword.operator.option.frysharp" }, + "7": { "name": "variable.other.alias.frysharp" } + } + }, + "language": { + "comment": "#!python, #!csharp, ...: the language of the cell. #![ is Rust's attribute and #!/ a shebang, neither is one.", + "match": "^\\s*(#!)(?![\\[/])([A-Za-z][\\w#+]*)(?![\\w#+])", + "captures": { + "1": { "name": "punctuation.definition.directive.frysharp" }, + "2": { "name": "entity.name.function.directive.language.frysharp" } + } + }, + "package": { + "comment": "%pip install x, %npm install x, %nuget install x", + "match": "^\\s*(%)(pip|npm|nuget)\\b\\s*(install|uninstall|list|show)?\\b\\s*(.*)$", + "captures": { + "1": { "name": "punctuation.definition.directive.frysharp" }, + "2": { "name": "keyword.control.directive.package.frysharp" }, + "3": { "name": "keyword.operator.option.frysharp" }, + "4": { "name": "string.unquoted.package.frysharp" } + } + } + } +} diff --git a/vscode-frysharp/test/fixtures/desktop.frynb b/vscode-frysharp/test/fixtures/desktop.frynb new file mode 100644 index 00000000..7ba8f4cf --- /dev/null +++ b/vscode-frysharp/test/fixtures/desktop.frynb @@ -0,0 +1,361 @@ +{ + "SchemaVersion": "1.0", + "Type": "notebook", + "Id": "fixture-notebook", + "Title": "Desktop fixture", + "Description": "", + "Category": "Interactive", + "Kernel": "csharp", + "Cells": [ + { + "Id": "intro", + "Type": 1, + "Source": "# Desktop fixture\nEvery kind of output.", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": true, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "text", + "Type": 0, + "Source": "Console.WriteLine(\u0022hi\u0022);", + "OutputText": "hi\n", + "ExecutionCount": 1, + "IsExecuting": false, + "ExecutionTimeText": "3 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "failed", + "Type": 0, + "Source": "throw new Exception(\u0022boom\u0022);", + "OutputText": "\u274C boom\n", + "ExecutionCount": 2, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": true, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "table", + "Type": 0, + "Source": "people.Dump();", + "OutputText": "before the table\n", + "ExecutionCount": 3, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-tbl-container\u0022 id=\u0022fry_tbl_277494a8\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;margin:10px 0;max-width:960px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow:hidden;\u0022\u003E\n \u003Cstyle\u003E\n #fry_tbl_277494a8 .fry-tbl-header {\n display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 8px 12px; background: var(--vscode-sideBar-background, #161b22);\n border-bottom: 1px solid var(--vscode-widget-border, #30363d);\n }\n #fry_tbl_277494a8 .fry-tbl-toggle-btn {\n display: flex; align-items: center; gap: 8px; background: transparent;\n border: none; color: var(--vscode-foreground, #c9d1d9); cursor: pointer;\n padding: 0; font-size: 13px; font-weight: 600; text-align: left;\n }\n #fry_tbl_277494a8 .fry-tbl-pill {\n font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-badge-background, #21262d); border: 1px solid var(--vscode-widget-border, #30363d);\n padding: 2px 8px; border-radius: 12px;\n }\n #fry_tbl_277494a8 .fry-tbl-actions {\n display: flex; align-items: center; gap: 8px;\n }\n #fry_tbl_277494a8 .fry-tbl-search-wrap {\n position: relative; display: flex; align-items: center;\n }\n #fry_tbl_277494a8 .fry-tbl-search-input {\n background: var(--vscode-input-background, #0d1117);\n border: 1px solid var(--vscode-input-border, #30363d);\n color: var(--vscode-input-foreground, #c9d1d9);\n padding: 3px 8px 3px 24px; border-radius: 4px; font-size: 11.5px;\n width: 140px; transition: border-color 0.2s, width 0.2s;\n }\n #fry_tbl_277494a8 .fry-tbl-search-input:focus {\n outline: none; border-color: var(--vscode-focusBorder, #58a6ff); width: 180px;\n }\n #fry_tbl_277494a8 .fry-tbl-search-icon {\n position: absolute; left: 7px; font-size: 11px; opacity: 0.6; pointer-events: none;\n }\n #fry_tbl_277494a8 .fry-tbl-btn {\n background: var(--vscode-button-secondaryBackground, #21262d);\n color: var(--vscode-button-secondaryForeground, #c9d1d9);\n border: 1px solid var(--vscode-widget-border, #30363d);\n border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: 500;\n cursor: pointer; transition: background 0.15s;\n }\n #fry_tbl_277494a8 .fry-tbl-btn:hover {\n background: var(--vscode-button-secondaryHoverBackground, #30363d);\n }\n #fry_tbl_277494a8 .fry-tbl-scroll {\n max-height: 420px; overflow-y: auto; overflow-x: auto;\n }\n #fry_tbl_277494a8 table.fry-tbl-grid {\n width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;\n }\n #fry_tbl_277494a8 thead th {\n position: sticky; top: 0; z-index: 5; background: var(--vscode-sideBar-background, #161b22);\n padding: 8px 12px; border-bottom: 2px solid var(--vscode-widget-border, #30363d);\n font-weight: 600; color: var(--vscode-foreground, #c9d1d9); user-select: none;\n }\n #fry_tbl_277494a8 thead th.sortable:hover {\n background: var(--vscode-list-hoverBackground, #21262d);\n }\n #fry_tbl_277494a8 tbody tr.fry-data-row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.06);\n transition: background 0.12s;\n }\n #fry_tbl_277494a8 tbody tr.fry-data-row:hover {\n background: var(--vscode-list-hoverBackground, rgba(56, 139, 253, 0.08)) !important;\n }\n #fry_tbl_277494a8 td {\n padding: 7px 12px; vertical-align: middle;\n }\n #fry_tbl_277494a8 .fry-num {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n text-align: right; color: #79c0ff;\n }\n #fry_tbl_277494a8 .fry-badge {\n display: inline-block; padding: 2px 7px; border-radius: 12px;\n font-size: 11px; font-weight: 500; letter-spacing: 0.2px;\n }\n #fry_tbl_277494a8 .fry-badge-green {\n background: rgba(35, 134, 54, 0.2); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.4);\n }\n #fry_tbl_277494a8 .fry-badge-amber {\n background: rgba(187, 128, 9, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4);\n }\n #fry_tbl_277494a8 .fry-badge-red {\n background: rgba(218, 54, 51, 0.2); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.4);\n }\n #fry_tbl_277494a8 .fry-badge-blue {\n background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.4);\n }\n #fry_tbl_277494a8 .fry-expander-btn {\n background: transparent; border: none; cursor: pointer;\n color: var(--vscode-textLink-foreground, #58a6ff); font-size: 10px;\n padding: 3px 5px; border-radius: 4px; transition: transform 0.15s;\n }\n #fry_tbl_277494a8 .fry-expander-btn:hover {\n background: rgba(255,255,255,0.1);\n }\n #fry_tbl_277494a8 tr.fry-detail-row {\n background: rgba(0, 0, 0, 0.28);\n }\n #fry_tbl_277494a8 .fry-prop-grid {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; margin-top: 6px;\n }\n #fry_tbl_277494a8 .fry-prop-card {\n background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);\n border-radius: 4px; padding: 6px 10px;\n }\n #fry_tbl_277494a8 .fry-prop-key {\n font-size: 10px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px;\n }\n #fry_tbl_277494a8 .fry-prop-val {\n font-size: 12px; color: #c9d1d9; margin-top: 2px; word-break: break-word;\n }\n #fry_tbl_277494a8 .fry-tbl-footer {\n display: flex; align-items: center; justify-content: space-between;\n padding: 6px 12px; font-size: 11px; color: var(--vscode-descriptionForeground, #8b949e);\n background: var(--vscode-sideBar-background, #161b22);\n border-top: 1px solid var(--vscode-widget-border, #30363d);\n }\n \u003C/style\u003E\n\n \u003Cdiv class=\u0022fry-tbl-header\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-toggle-btn\u0022 onclick=\u0022fryTblToggle(\u0027fry_tbl_277494a8\u0027)\u0022 title=\u0022Click to collapse/expand table\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-chevron\u0022 style=\u0022color:#58a6ff;\u0022\u003E\u25BC\u003C/span\u003E\n \u003Cspan\u003E\uD83D\uDCCA AnonymousType[2]\u003C/span\u003E\n \u003Cspan class=\u0022fry-tbl-pill\u0022\u003E2 rows \u00D7 3 cols\u003C/span\u003E\n \u003C/button\u003E\n \u003Cdiv class=\u0022fry-tbl-actions\u0022\u003E\n \u003Cdiv class=\u0022fry-tbl-search-wrap\u0022\u003E\n \u003Cspan class=\u0022fry-tbl-search-icon\u0022\u003E\uD83D\uDD0D\u003C/span\u003E\n \u003Cinput type=\u0022text\u0022 class=\u0022fry-tbl-search-input\u0022 placeholder=\u0022Filter rows...\u0022 oninput=\u0022fryTblFilter(\u0027fry_tbl_277494a8\u0027, this.value)\u0022 /\u003E\n \u003C/div\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_277494a8\u0027, \u0027csv\u0027)\u0022 title=\u0022Copy as CSV\u0022\u003ECSV\u003C/button\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-tbl-btn\u0022 onclick=\u0022fryTblCopy(\u0027fry_tbl_277494a8\u0027, \u0027json\u0027)\u0022 title=\u0022Copy as JSON\u0022\u003EJSON\u003C/button\u003E\n \u003C/div\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-scroll\u0022\u003E\n \u003Ctable class=\u0022fry-tbl-grid\u0022\u003E\n \u003Cthead\u003E\n \u003Ctr\u003E\n \u003Cth style=\u0022width:36px;text-align:center;padding:8px 4px;\u0022\u003E\u003C/th\u003E\n \u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_277494a8\u0027, 0, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Name\u0022\u003E\n Name \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_277494a8\u0027, 1, true)\u0022 style=\u0022text-align:right;cursor:pointer;\u0022 title=\u0022Sort by Age\u0022\u003E\n Age \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\u003Cth class=\u0022sortable\u0022 onclick=\u0022fryTblSort(\u0027fry_tbl_277494a8\u0027, 2, false)\u0022 style=\u0022text-align:left;cursor:pointer;\u0022 title=\u0022Sort by Active\u0022\u003E\n Active \u003Cspan class=\u0022fry-sort-ind\u0022 style=\u0022opacity:0.35;font-size:10px;margin-left:3px;\u0022\u003E\u2195\u003C/span\u003E\n\u003C/th\u003E\n \u003C/tr\u003E\n \u003C/thead\u003E\n \u003Ctbody\u003E\n \u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00220\u0022 style=\u0022background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_277494a8\u0027, 0)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EAda\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E36\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003ETrue\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_277494a8_detail_0\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #1 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EName\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EAda\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EAge\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E36\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EActive\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-green\u0022\u003ETrue\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\u003Ctr class=\u0022fry-data-row\u0022 data-row=\u00221\u0022 style=\u0022background:transparent;\u0022\u003E\n \u003Ctd style=\u0022text-align:center;padding:6px 4px;\u0022\u003E\n \u003Cbutton type=\u0022button\u0022 class=\u0022fry-expander-btn\u0022 onclick=\u0022fryTblToggleRow(\u0027fry_tbl_277494a8\u0027, 1)\u0022 title=\u0022Toggle row details\u0022\u003E\u25B6\u003C/button\u003E\n \u003C/td\u003E\n \u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003EGrace\u003C/td\u003E\u003Ctd style=\u0022text-align:right;\u0022 class=\u0022fry-num\u0022\u003E45\u003C/td\u003E\u003Ctd style=\u0022text-align:left;\u0022 class=\u0022\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-red\u0022\u003EFalse\u003C/span\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr class=\u0022fry-detail-row\u0022 id=\u0022fry_tbl_277494a8_detail_1\u0022 style=\u0022display:none;\u0022\u003E\n \u003Ctd colspan=\u00224\u0022 style=\u0022padding:10px 14px;border-bottom:1px solid var(--vscode-widget-border,#30363d);\u0022\u003E\n \u003Cdiv style=\u0022background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:6px;padding:10px 14px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;\u0022\u003E\n \u003Cdiv style=\u0022font-size:11.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD0D Row #2 Inspector\u003C/div\u003E\n \u003Cdiv style=\u0022font-size:10.5px;color:#8b949e;\u0022\u003E3 properties\u003C/div\u003E\n \u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-grid\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EName\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003EGrace\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EAge\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E45\u003C/div\u003E\n\u003C/div\u003E\u003Cdiv class=\u0022fry-prop-card\u0022\u003E\n \u003Cdiv class=\u0022fry-prop-key\u0022\u003EActive\u003C/div\u003E\n \u003Cdiv class=\u0022fry-prop-val\u0022\u003E\u003Cspan class=\u0022fry-badge fry-badge-red\u0022\u003EFalse\u003C/span\u003E\u003C/div\u003E\n\u003C/div\u003E\n \u003C/div\u003E\n \n \u003C/div\u003E\n \u003C/td\u003E\n\u003C/tr\u003E\n \u003C/tbody\u003E\n \u003C/table\u003E\n \u003C/div\u003E\n\n \u003Cdiv class=\u0022fry-tbl-footer\u0022\u003E\n \u003Cspan id=\u0022fry_tbl_277494a8_counter\u0022\u003EShowing 2 of 2 rows\u003C/span\u003E\n \u003Cspan style=\u0022opacity:0.6;font-size:10.5px;\u0022\u003EFrySharp Polyglot Data\u003C/span\u003E\n \u003C/div\u003E\n\n \u003Cscript\u003E\n (function() {\n if (window.__fryTblInit) return;\n window.__fryTblInit = true;\n\n window.fryTblToggle = function(id) {\n var el = document.getElementById(id);\n if (!el) return;\n var body = el.querySelector(\u0027.fry-tbl-scroll\u0027);\n var footer = el.querySelector(\u0027.fry-tbl-footer\u0027);\n var chevron = el.querySelector(\u0027.fry-tbl-chevron\u0027);\n var isHidden = body.style.display === \u0027none\u0027;\n body.style.display = isHidden ? \u0027block\u0027 : \u0027none\u0027;\n footer.style.display = isHidden ? \u0027flex\u0027 : \u0027none\u0027;\n if (chevron) chevron.textContent = isHidden ? \u0027\u25BC\u0027 : \u0027\u25B6\u0027;\n };\n\n window.fryTblToggleRow = function(id, rowIdx) {\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rowIdx);\n var el = document.getElementById(id);\n if (!drawer || !el) return;\n var btn = el.querySelector(\u0027tr[data-row=\u0022\u0027 \u002B rowIdx \u002B \u0027\u0022] .fry-expander-btn\u0027);\n var isOpen = drawer.style.display !== \u0027none\u0027;\n drawer.style.display = isOpen ? \u0027none\u0027 : \u0027table-row\u0027;\n if (btn) btn.textContent = isOpen ? \u0027\u25B6\u0027 : \u0027\u25BC\u0027;\n };\n\n window.fryTblFilter = function(id, query) {\n var el = document.getElementById(id);\n if (!el) return;\n var q = (query || \u0027\u0027).toLowerCase().trim();\n var rows = el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027);\n var count = 0;\n rows.forEach(function(row) {\n var rIdx = row.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n var match = !q || row.innerText.toLowerCase().indexOf(q) !== -1;\n row.style.display = match ? \u0027\u0027 : \u0027none\u0027;\n if (!match \u0026\u0026 drawer) drawer.style.display = \u0027none\u0027;\n if (match) count\u002B\u002B;\n });\n var cnt = document.getElementById(id \u002B \u0027_counter\u0027);\n if (cnt) {\n cnt.textContent = q ? (\u0027Showing \u0027 \u002B count \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows (filtered)\u0027) : (\u0027Showing \u0027 \u002B rows.length \u002B \u0027 of \u0027 \u002B rows.length \u002B \u0027 rows\u0027);\n }\n };\n\n window.fryTblSort = function(id, colIdx, isNum) {\n var el = document.getElementById(id);\n if (!el) return;\n var tbody = el.querySelector(\u0027tbody\u0027);\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var th = el.querySelectorAll(\u0027thead th\u0027)[colIdx \u002B 1];\n var dir = th.getAttribute(\u0027data-sort-dir\u0027) === \u0027asc\u0027 ? \u0027desc\u0027 : \u0027asc\u0027;\n\n el.querySelectorAll(\u0027thead th\u0027).forEach(function(h) {\n h.removeAttribute(\u0027data-sort-dir\u0027);\n var s = h.querySelector(\u0027.fry-sort-ind\u0027);\n if (s) s.textContent = \u0027\u2195\u0027;\n });\n\n th.setAttribute(\u0027data-sort-dir\u0027, dir);\n var ind = th.querySelector(\u0027.fry-sort-ind\u0027);\n if (ind) ind.textContent = dir === \u0027asc\u0027 ? \u0027\u25B2\u0027 : \u0027\u25BC\u0027;\n\n rows.sort(function(a, b) {\n var vA = a.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n var vB = b.children[colIdx \u002B 1]?.innerText?.trim() || \u0027\u0027;\n if (isNum) {\n var nA = parseFloat(vA.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n var nB = parseFloat(vB.replace(/[^0-9.-]/g, \u0027\u0027)) || 0;\n return dir === \u0027asc\u0027 ? nA - nB : nB - nA;\n }\n return dir === \u0027asc\u0027 ? vA.localeCompare(vB) : vB.localeCompare(vA);\n });\n\n rows.forEach(function(r) {\n var rIdx = r.getAttribute(\u0027data-row\u0027);\n var drawer = document.getElementById(id \u002B \u0027_detail_\u0027 \u002B rIdx);\n tbody.appendChild(r);\n if (drawer) tbody.appendChild(drawer);\n });\n };\n\n window.fryTblCopy = function(id, format) {\n var el = document.getElementById(id);\n if (!el) return;\n var ths = Array.from(el.querySelectorAll(\u0027thead th\u0027)).slice(1);\n var headers = ths.map(function(h) { return h.innerText.replace(/[\u2195\u25B2\u25BC]/g, \u0027\u0027).trim(); });\n var rows = Array.from(el.querySelectorAll(\u0027tbody tr.fry-data-row\u0027));\n var data = rows.map(function(r) {\n return Array.from(r.children).slice(1).map(function(c) { return c.innerText.trim(); });\n });\n\n var payload = \u0027\u0027;\n if (format === \u0027json\u0027) {\n var list = data.map(function(r) {\n var o = {};\n headers.forEach(function(h, idx) { o[h] = r[idx]; });\n return o;\n });\n payload = JSON.stringify(list, null, 2);\n } else {\n payload = headers.join(\u0027,\u0027) \u002B \u0027\\n\u0027 \u002B data.map(function(r) {\n return r.map(function(val) { return \u0027\u0022\u0027 \u002B val.replace(/\u0022/g, \u0027\u0022\u0022\u0027) \u002B \u0027\u0022\u0027; }).join(\u0027,\u0027);\n }).join(\u0027\\n\u0027);\n }\n\n navigator.clipboard.writeText(payload).then(function() {\n var btn = el.querySelector(\u0027.fry-tbl-btn[onclick*=\u0022\u0027 \u002B format \u002B \u0027\u0022]\u0027);\n if (btn) {\n var prev = btn.textContent;\n btn.textContent = \u0027\u2713 Copied!\u0027;\n setTimeout(function() { btn.textContent = prev; }, 1500);\n }\n });\n };\n })();\n \u003C/script\u003E\n\u003C/div\u003E", + "TableSnapshot": { + "Title": "AnonymousType[2]", + "Label": "People", + "Columns": [ + { + "Header": "Name", + "IsNumeric": false + }, + { + "Header": "Age", + "IsNumeric": true + }, + { + "Header": "Active", + "IsNumeric": false + } + ], + "Rows": [ + { + "Cells": [ + { + "DisplayText": "Ada", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "36", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "True", + "IsNumeric": false, + "IsBoolean": true, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "Grace", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "45", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "False", + "IsNumeric": false, + "IsBoolean": true, + "IsNull": false + } + ] + } + ] + }, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "inspector", + "Type": 0, + "Source": "obj", + "OutputText": "", + "ExecutionCount": 4, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-inspector-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:var(--vscode-editor-background,#0d1117);border:1px solid var(--vscode-widget-border,#30363d);border-radius:8px;padding:12px;margin:8px 0;max-width:840px;box-shadow:0 4px 14px rgba(0,0,0,0.3);\u0022\u003E\n \u003Cdiv style=\u0022display:flex;align-items:center;gap:8px;margin-bottom:10px;\u0022\u003E\n \u003Cspan style=\u0022font-size:13.5px;font-weight:600;color:#58a6ff;\u0022\u003E\uD83D\uDD2C \u0026lt;\u0026gt;f__AnonymousType1\u0026lt;String, Int32[], \u0026lt;\u0026gt;f__AnonymousType2\u0026lt;Int32, String\u0026gt;\u0026gt;\u003C/span\u003E\n \u003C/div\u003E\n \u003Cdiv style=\u0022display:flex;flex-direction:column;gap:5px;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:rgba(255,255,255,0.02);border-radius:4px;font-size:12px;\u0022\u003E\n \u003Cspan style=\u0022font-weight:600;color:#8b949e;\u0022\u003EName\u003C/span\u003E\n \u003Cspan\u003E\u003Cspan style=\u0022color:#a5d6ff;\u0022\u003E\u0022Code\u0022\u003C/span\u003E\u003C/span\u003E\n\u003C/div\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:rgba(255,255,255,0.02);border-radius:4px;font-size:12px;\u0022\u003E\n \u003Cspan style=\u0022font-weight:600;color:#8b949e;\u0022\u003ENumbers\u003C/span\u003E\n \u003Cspan\u003E[ 1, 2, 3 ]\u003C/span\u003E\n\u003C/div\u003E\u003Cdetails open style=\u0022border:1px solid rgba(255,255,255,0.06);border-radius:5px;padding:6px 10px;background:rgba(255,255,255,0.015);\u0022\u003E\n \u003Csummary style=\u0022cursor:pointer;font-size:12px;font-weight:600;color:#58a6ff;\u0022\u003EInner: \u003Cspan style=\u0022font-weight:normal;color:#8b949e;\u0022\u003E\u003C/span\u003E\u003C/summary\u003E\n \u003Cdiv style=\u0022margin-left:14px;margin-top:6px;border-left:2px solid #30363d;padding-left:10px;\u0022\u003E\n \u003Cdiv style=\u0022display:flex;flex-direction:column;gap:5px;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:rgba(255,255,255,0.02);border-radius:4px;font-size:12px;\u0022\u003E\n \u003Cspan style=\u0022font-weight:600;color:#8b949e;\u0022\u003EX\u003C/span\u003E\n \u003Cspan\u003E1\u003C/span\u003E\n\u003C/div\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:rgba(255,255,255,0.02);border-radius:4px;font-size:12px;\u0022\u003E\n \u003Cspan style=\u0022font-weight:600;color:#8b949e;\u0022\u003EY\u003C/span\u003E\n \u003Cspan\u003E\u003Cspan style=\u0022color:#a5d6ff;\u0022\u003E\u0022two\u0022\u003C/span\u003E\u003C/span\u003E\n\u003C/div\u003E\u003C/div\u003E\n \u003C/div\u003E\n\u003C/details\u003E\u003C/div\u003E\n\u003C/div\u003E", + "TableSnapshot": null, + "InspectorSnapshot": { + "HeaderTitle": "\u003C\u003Ef__AnonymousType1\u003CString, Int32[], \u003C\u003Ef__AnonymousType2\u003CInt32, String\u003E\u003E", + "IsExpandable": true, + "IsCircularReference": false, + "Properties": [ + { + "Name": "Name", + "SimpleValueText": "Code", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + }, + { + "Name": "Numbers", + "SimpleValueText": "[ 1, 2, 3 ]", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + }, + { + "Name": "Inner", + "SimpleValueText": "", + "IsNull": false, + "IsComplexChild": true, + "ChildNode": { + "HeaderTitle": "\u003C\u003Ef__AnonymousType2\u003CInt32, String\u003E", + "IsExpandable": true, + "IsCircularReference": false, + "Properties": [ + { + "Name": "X", + "SimpleValueText": "1", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + }, + { + "Name": "Y", + "SimpleValueText": "two", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + } + ] + } + } + ] + }, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "chart", + "Type": 0, + "Source": "Display.LineChart(sales);", + "OutputText": "", + "ExecutionCount": 5, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "\u003Cdiv class=\u0022fry-visual-card\u0022 style=\u0022font-family:system-ui,-apple-system,BlinkMacSystemFont,\u0027Segoe UI\u0027,Roboto,sans-serif;background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:14px;margin:8px 0;max-width:840px;box-shadow:0 4px 16px rgba(0,0,0,0.35);overflow-x:auto;\u0022\u003E\u003Cdiv style=\u0022display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:12px;\u0022\u003E\u003Cdiv\u003E\u003Cdiv style=\u0022font-weight:600;font-size:13.5px;color:#58a6ff;letter-spacing:0.2px;\u0022\u003E\uD83D\uDCCA Sales\u003C/div\u003E\u003C/div\u003E\u003Cdiv style=\u0022font-size:10.5px;font-weight:600;color:#8b949e;background:#161b22;padding:3px 9px;border-radius:12px;border:1px solid #30363d;letter-spacing:0.5px;\u0022\u003ELINE\u003C/div\u003E\u003C/div\u003E\u003Csvg viewBox=\u00220 0 760 360\u0022 width=\u0022100%\u0022 height=\u0022auto\u0022 style=\u0022display:block;border-radius:6px;background:#161b22;\u0022 xmlns=\u0022http://www.w3.org/2000/svg\u0022\u003E\u003Cdefs\u003E\u003ClinearGradient id=\u0022area-grad-0\u0022 x1=\u00220%\u0022 y1=\u00220%\u0022 x2=\u00220%\u0022 y2=\u0022100%\u0022\u003E\u003Cstop offset=\u00220%\u0022 stop-color=\u0022#58a6ff\u0022 stop-opacity=\u00220.35\u0022/\u003E\u003Cstop offset=\u0022100%\u0022 stop-color=\u0022#58a6ff\u0022 stop-opacity=\u00220.02\u0022/\u003E\u003C/linearGradient\u003E\u003C/defs\u003E\u003Cline x1=\u002260\u0022 y1=\u002230.0\u0022 x2=\u0022720\u0022 y2=\u002230.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u002234.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E3\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u002284.0\u0022 x2=\u0022720\u0022 y2=\u002284.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u002288.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E2.6\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022138.0\u0022 x2=\u0022720\u0022 y2=\u0022138.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022142.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E2.2\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022192.0\u0022 x2=\u0022720\u0022 y2=\u0022192.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022196.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E1.8\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022246.0\u0022 x2=\u0022720\u0022 y2=\u0022246.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022250.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E1.4\u003C/text\u003E\u003Cline x1=\u002260\u0022 y1=\u0022300.0\u0022 x2=\u0022720\u0022 y2=\u0022300.0\u0022 stroke=\u0022#21262d\u0022 stroke-width=\u00221\u0022 stroke-dasharray=\u00223,3\u0022/\u003E\u003Ctext x=\u002252\u0022 y=\u0022304.0\u0022 fill=\u0022#8b949e\u0022 font-size=\u002210.5\u0022 font-family=\u0022monospace\u0022 text-anchor=\u0022end\u0022\u003E1\u003C/text\u003E\u003Cpath d=\u0022M 60.0 300.0 L 390.0 30.0 L 720.0 165.0 \u0022 fill=\u0022none\u0022 stroke=\u0022#58a6ff\u0022 stroke-width=\u00222.5\u0022 stroke-linejoin=\u0022round\u0022 stroke-linecap=\u0022round\u0022/\u003E\u003Ccircle cx=\u002260.0\u0022 cy=\u0022300.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003ESales: 1.00 (#1)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022390.0\u0022 cy=\u002230.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003ESales: 3.00 (#2)\u003C/title\u003E\u003C/circle\u003E\u003Ccircle cx=\u0022720.0\u0022 cy=\u0022165.0\u0022 r=\u00223.5\u0022 fill=\u0022#58a6ff\u0022 stroke=\u0022#161b22\u0022 stroke-width=\u00221.5\u0022\u003E\u003Ctitle\u003ESales: 2.00 (#3)\u003C/title\u003E\u003C/circle\u003E\u003Cline x1=\u002260\u0022 y1=\u0022300\u0022 x2=\u0022720\u0022 y2=\u0022300\u0022 stroke=\u0022#30363d\u0022 stroke-width=\u00221.5\u0022/\u003E\u003Ccircle cx=\u002265.0\u0022 cy=\u0022335\u0022 r=\u00224.5\u0022 fill=\u0022#58a6ff\u0022/\u003E\u003Ctext x=\u002274.0\u0022 y=\u0022338.5\u0022 fill=\u0022#8b949e\u0022 font-size=\u002211\u0022 font-family=\u0022system-ui,sans-serif\u0022\u003ESales\u003C/text\u003E\u003C/svg\u003E\u003C/div\u003E", + "TableSnapshot": null, + "InspectorSnapshot": null, + "Visuals": [ + { + "MimeType": "application/vnd.fry.chart.v1\u002Bjson", + "Data": { + "kind": "line", + "series": [ + { + "name": "Sales", + "y": [ + 1, + 3, + 2 + ] + } + ], + "title": "Sales" + }, + "Title": "Sales" + } + ], + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "image", + "Type": 0, + "Source": "Display.Image(png);", + "OutputText": "", + "ExecutionCount": 6, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", + "ImageFormat": "PNG", + "ImageWidth": 1, + "ImageHeight": 1, + "HtmlContent": "\u003Cdiv class=\u0022fry-img-card\u0022\u003E\u003Cimg src=\u0022data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==\u0022 /\u003E\u003C/div\u003E", + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": true, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "collapsed", + "Type": 0, + "Language": "python", + "Source": "print(\u0027python\u0027)", + "OutputText": "python\n", + "ExecutionCount": 7, + "IsExecuting": false, + "ExecutionTimeText": "12 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": true, + "IsOutputCollapsed": true, + "IsOutputScrolled": true, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": true, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + } + ], + "Created": "2026-10-13T08:00:00Z", + "LastModified": "2026-10-13T09:00:00Z", + "ExecutionCount": 7 +} \ No newline at end of file diff --git a/vscode-frysharp/test/fixtures/jupyter.ipynb b/vscode-frysharp/test/fixtures/jupyter.ipynb new file mode 100644 index 00000000..06ee3921 --- /dev/null +++ b/vscode-frysharp/test/fixtures/jupyter.ipynb @@ -0,0 +1,143 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "m1", + "metadata": { + "tags": [ + "intro" + ] + }, + "source": [ + "# Jupyter fixture\n", + "Written by Jupyter, not by FrySharp." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c1", + "metadata": { + "collapsed": false, + "tags": [ + "slow" + ] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "line one\n", + "line two\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "a warning\n" + ] + } + ], + "source": [ + "print('line one')\n", + "print('line two')" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c2", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": { + "image/png": { + "width": 1, + "height": 1 + } + }, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "bold" + ], + "text/plain": [ + "bold" + ], + "application/json": { + "a": [ + 1, + 2 + ] + } + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fig" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c3", + "metadata": { + "polyglot_notebook": { + "kernelName": "python" + } + }, + "outputs": [ + { + "ename": "ZeroDivisionError", + "evalue": "division by zero", + "output_type": "error", + "traceback": [ + "Traceback (most recent call last)", + "ZeroDivisionError: division by zero" + ] + } + ], + "source": [ + "1/0" + ] + }, + { + "cell_type": "raw", + "id": "r1", + "metadata": { + "format": "text/latex" + }, + "source": [ + "\\alpha" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "language_info": { + "name": "csharp" + }, + "custom": { + "keep": "me" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/vscode-frysharp/test/fixtures/written/after-a-run.frynb b/vscode-frysharp/test/fixtures/written/after-a-run.frynb new file mode 100644 index 00000000..e4dd29dd --- /dev/null +++ b/vscode-frysharp/test/fixtures/written/after-a-run.frynb @@ -0,0 +1,240 @@ +{ + "SchemaVersion": "1.0", + "Type": "notebook", + "Id": "after-a-run", + "Title": "After a run", + "Description": "", + "Category": "Interactive", + "Kernel": "csharp", + "Cells": [ + { + "Id": "run", + "Type": 0, + "Source": "Display.Everything();", + "OutputText": "Result:\n", + "ExecutionCount": 3, + "IsExecuting": false, + "ExecutionTimeText": "12 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", + "ImageFormat": "PNG", + "ImageWidth": 1, + "ImageHeight": 1, + "HtmlContent": "
", + "TableSnapshot": { + "Title": "People", + "Label": null, + "Columns": [ + { + "Header": "Name", + "IsNumeric": false + }, + { + "Header": "Age", + "IsNumeric": true + } + ], + "Rows": [ + { + "Cells": [ + { + "DisplayText": "Ada", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "36", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "Grace", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": true + } + ] + } + ] + }, + "InspectorSnapshot": { + "HeaderTitle": "People", + "IsExpandable": true, + "IsCircularReference": false, + "Properties": [ + { + "Name": "name", + "SimpleValueText": "Code", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + }, + { + "Name": "another", + "SimpleValueText": "", + "IsNull": false, + "IsComplexChild": true, + "ChildNode": { + "HeaderTitle": "People", + "IsExpandable": true, + "IsCircularReference": true, + "Properties": [] + } + } + ] + }, + "Visuals": [ + { + "MimeType": "application/vnd.fry.chart.v1+json", + "Data": { + "kind": "line", + "series": [ + { + "name": "Sales", + "y": [ + 1, + 3, + 2 + ] + } + ], + "title": "Sales" + }, + "Title": "Sales" + } + ], + "HadInteractiveControl": false, + "HasImage": true, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false, + "OutputBundles": [ + { + "Items": [ + { + "Mime": "application/vnd.code.notebook.stdout", + "Text": "Result:\n" + } + ] + }, + { + "Items": [ + { + "Mime": "text/html", + "Text": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Name ↕\n\n Age ↕\n\n Active ↕\n
\n \n Ada36True
\n
\n
\n
🔍 Row #1 Inspector
\n
3 properties
\n
\n
\n
\n
Name
\n
Ada
\n
\n
Age
\n
36
\n
\n
Active
\n
True
\n
\n
\n \n
\n
\n \n Grace45False
\n
\n
\n
🔍 Row #2 Inspector
\n
3 properties
\n
\n
\n
\n
Name
\n
Grace
\n
\n
Age
\n
45
\n
\n
Active
\n
False
\n
\n
\n \n
\n
\n
\n\n
\n Showing 2 of 2 rows\n FrySharp Polyglot Data\n
\n\n \n
" + }, + { + "Mime": "application/vnd.fry.table+json", + "Text": "{\"title\":\"People\",\"columns\":[\"Name\",\"Age\"],\"numeric\":[false,true],\"rows\":[[\"Ada\",\"36\"],[\"Grace\",null]],\"totalRows\":2,\"totalColumns\":2}" + }, + { + "Mime": "text/plain", + "Text": "People" + } + ] + }, + { + "Items": [ + { + "Mime": "text/html", + "Text": "
\n
\n 🔬 <>f__AnonymousType1<String, Int32[], <>f__AnonymousType2<Int32, String>>\n
\n
\n Name\n \"Code\"\n
\n Numbers\n [ 1, 2, 3 ]\n
\n Inner: \n
\n
\n X\n 1\n
\n Y\n \"two\"\n
\n
\n
\n
" + }, + { + "Mime": "application/vnd.fry.inspector+json", + "Text": "{\"title\":\"People\",\"properties\":[{\"name\":\"name\",\"value\":\"Code\",\"isNull\":false,\"isString\":true,\"child\":null},{\"name\":\"another\",\"value\":\"\",\"isNull\":false,\"isString\":false,\"child\":{\"title\":\"People\",\"circular\":true,\"properties\":[]}}]}" + }, + { + "Mime": "text/plain", + "Text": "People" + } + ] + }, + { + "Items": [ + { + "Mime": "text/html", + "Text": "
📊 Sales
LINE
32.62.21.81.41Sales: 1.00 (#1)Sales: 3.00 (#2)Sales: 2.00 (#3)Sales
" + }, + { + "Mime": "application/vnd.fry.chart.v1+json", + "Text": "{\"kind\":\"line\",\"series\":[{\"name\":\"Sales\",\"y\":[1,3,2]}],\"title\":\"Sales\"}" + }, + { + "Mime": "text/plain", + "Text": "" + } + ] + }, + { + "Items": [ + { + "Mime": "text/html", + "Text": "
" + }, + { + "Mime": "image/png", + "Base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==" + }, + { + "Mime": "text/plain", + "Text": "" + } + ], + "Metadata": { + "image/png": { + "width": 1, + "height": 1 + } + } + } + ] + }, + { + "Id": "failed", + "Type": 0, + "Source": "Display.Everything();", + "OutputText": "boom\n", + "ExecutionCount": 4, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": true, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + } + ], + "Created": "2026-10-14T09:00:00.000Z", + "LastModified": "2026-10-14T09:00:00.000Z", + "ExecutionCount": 4 +} \ No newline at end of file diff --git a/vscode-frysharp/test/fixtures/written/desktop.frynb b/vscode-frysharp/test/fixtures/written/desktop.frynb new file mode 100644 index 00000000..6ec47044 --- /dev/null +++ b/vscode-frysharp/test/fixtures/written/desktop.frynb @@ -0,0 +1,361 @@ +{ + "SchemaVersion": "1.0", + "Type": "notebook", + "Id": "fixture-notebook", + "Title": "Desktop fixture", + "Description": "", + "Category": "Interactive", + "Kernel": "csharp", + "Cells": [ + { + "Id": "intro", + "Type": 1, + "Source": "# Desktop fixture\nEvery kind of output.", + "OutputText": "", + "ExecutionCount": null, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": true, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": false, + "IsCodeCell": false, + "IsMarkdownCell": true + }, + { + "Id": "text", + "Type": 0, + "Source": "Console.WriteLine(\"hi\");", + "OutputText": "hi\n", + "ExecutionCount": 1, + "IsExecuting": false, + "ExecutionTimeText": "3 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "failed", + "Type": 0, + "Source": "throw new Exception(\"boom\");", + "OutputText": "❌ boom\n", + "ExecutionCount": 2, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": true, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "table", + "Type": 0, + "Source": "people.Dump();", + "OutputText": "before the table\n", + "ExecutionCount": 3, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "
\n \n\n
\n \n
\n
\n 🔍\n \n
\n \n \n
\n
\n\n
\n \n \n \n \n \n \n \n \n \n \n \n\n\n \n\n \n \n\n\n \n\n \n
\n Name ↕\n\n Age ↕\n\n Active ↕\n
\n \n Ada36True
\n
\n
\n
🔍 Row #1 Inspector
\n
3 properties
\n
\n
\n
\n
Name
\n
Ada
\n
\n
Age
\n
36
\n
\n
Active
\n
True
\n
\n
\n \n
\n
\n \n Grace45False
\n
\n
\n
🔍 Row #2 Inspector
\n
3 properties
\n
\n
\n
\n
Name
\n
Grace
\n
\n
Age
\n
45
\n
\n
Active
\n
False
\n
\n
\n \n
\n
\n
\n\n
\n Showing 2 of 2 rows\n FrySharp Polyglot Data\n
\n\n \n
", + "TableSnapshot": { + "Title": "AnonymousType[2]", + "Label": "People", + "Columns": [ + { + "Header": "Name", + "IsNumeric": false + }, + { + "Header": "Age", + "IsNumeric": true + }, + { + "Header": "Active", + "IsNumeric": false + } + ], + "Rows": [ + { + "Cells": [ + { + "DisplayText": "Ada", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "36", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "True", + "IsNumeric": false, + "IsBoolean": true, + "IsNull": false + } + ] + }, + { + "Cells": [ + { + "DisplayText": "Grace", + "IsNumeric": false, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "45", + "IsNumeric": true, + "IsBoolean": false, + "IsNull": false + }, + { + "DisplayText": "False", + "IsNumeric": false, + "IsBoolean": true, + "IsNull": false + } + ] + } + ] + }, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "inspector", + "Type": 0, + "Source": "obj", + "OutputText": "", + "ExecutionCount": 4, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "
\n
\n 🔬 <>f__AnonymousType1<String, Int32[], <>f__AnonymousType2<Int32, String>>\n
\n
\n Name\n \"Code\"\n
\n Numbers\n [ 1, 2, 3 ]\n
\n Inner: \n
\n
\n X\n 1\n
\n Y\n \"two\"\n
\n
\n
\n
", + "TableSnapshot": null, + "InspectorSnapshot": { + "HeaderTitle": "<>f__AnonymousType1f__AnonymousType2>", + "IsExpandable": true, + "IsCircularReference": false, + "Properties": [ + { + "Name": "Name", + "SimpleValueText": "Code", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + }, + { + "Name": "Numbers", + "SimpleValueText": "[ 1, 2, 3 ]", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + }, + { + "Name": "Inner", + "SimpleValueText": "", + "IsNull": false, + "IsComplexChild": true, + "ChildNode": { + "HeaderTitle": "<>f__AnonymousType2", + "IsExpandable": true, + "IsCircularReference": false, + "Properties": [ + { + "Name": "X", + "SimpleValueText": "1", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + }, + { + "Name": "Y", + "SimpleValueText": "two", + "IsNull": false, + "IsComplexChild": false, + "ChildNode": null + } + ] + } + } + ] + }, + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "chart", + "Type": 0, + "Source": "Display.LineChart(sales);", + "OutputText": "", + "ExecutionCount": 5, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": "
📊 Sales
LINE
32.62.21.81.41Sales: 1.00 (#1)Sales: 3.00 (#2)Sales: 2.00 (#3)Sales
", + "TableSnapshot": null, + "InspectorSnapshot": null, + "Visuals": [ + { + "MimeType": "application/vnd.fry.chart.v1+json", + "Data": { + "kind": "line", + "series": [ + { + "name": "Sales", + "y": [ + 1, + 3, + 2 + ] + } + ], + "title": "Sales" + }, + "Title": "Sales" + } + ], + "HadInteractiveControl": false, + "HasImage": false, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "image", + "Type": 0, + "Source": "Display.Image(png);", + "OutputText": "", + "ExecutionCount": 6, + "IsExecuting": false, + "ExecutionTimeText": "", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": false, + "IsOutputCollapsed": false, + "IsOutputScrolled": false, + "ImageBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", + "ImageFormat": "PNG", + "ImageWidth": 1, + "ImageHeight": 1, + "HtmlContent": "
", + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": false, + "HasImage": true, + "HasHtml": true, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + }, + { + "Id": "collapsed", + "Type": 0, + "Language": "python", + "Source": "print('python')", + "OutputText": "python\n", + "ExecutionCount": 7, + "IsExecuting": false, + "ExecutionTimeText": "12 ms", + "HasError": false, + "IsMarkdownPreviewMode": false, + "IsInputCollapsed": true, + "IsOutputCollapsed": true, + "IsOutputScrolled": true, + "ImageBytes": null, + "ImageFormat": null, + "ImageWidth": null, + "ImageHeight": null, + "HtmlContent": null, + "TableSnapshot": null, + "InspectorSnapshot": null, + "HadInteractiveControl": true, + "HasImage": false, + "HasHtml": false, + "HasOutput": true, + "IsCodeCell": true, + "IsMarkdownCell": false + } + ], + "Created": "2026-10-13T08:00:00Z", + "LastModified": "2026-10-14T09:00:00.000Z", + "ExecutionCount": 7 +} \ No newline at end of file diff --git a/vscode-frysharp/test/integration/runTests.ts b/vscode-frysharp/test/integration/runTests.ts new file mode 100644 index 00000000..4b1ed773 --- /dev/null +++ b/vscode-frysharp/test/integration/runTests.ts @@ -0,0 +1,57 @@ +// Runs the extension in a real VS Code (an Extension Host) with a throwaway profile, against the real engine, and runs +// test/integration/suite inside it. `npm run test:integration` builds the extension first. +// +// Which VS Code: FRY_VSCODE_EXECUTABLE, else a VS Code or VSCodium installed in the usual place, else (only with +// FRY_ALLOW_DOWNLOAD=1) the latest stable, which @vscode/test-electron downloads. + +import { runTests } from '@vscode/test-electron'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const root = path.resolve(__dirname, '../../..'); + +function installedExecutable(): string | undefined { + const candidates = [ + process.env.FRY_VSCODE_EXECUTABLE, + '/Applications/Visual Studio Code.app/Contents/MacOS/Electron', + '/Applications/VSCodium.app/Contents/MacOS/VSCodium', + '/usr/share/code/code', + '/usr/share/codium/codium', + 'C:\\Program Files\\Microsoft VS Code\\Code.exe' + ]; + return candidates.find((candidate) => candidate && fs.existsSync(candidate)); +} + +async function main(): Promise { + const executable = installedExecutable(); + if (!executable && process.env.FRY_ALLOW_DOWNLOAD !== '1') { + console.error('No VS Code found. Set FRY_VSCODE_EXECUTABLE to one, or FRY_ALLOW_DOWNLOAD=1 to download the latest stable.'); + process.exit(2); + } + + // A workspace of the notebooks the tests open and save, copies of the fixtures: the tests change them. + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'frysharp-it-')); + const workspace = path.join(scratch, 'workspace'); + fs.mkdirSync(workspace); + for (const name of ['desktop.frynb', 'jupyter.ipynb']) fs.copyFileSync(path.join(root, 'test', 'fixtures', name), path.join(workspace, name)); + fs.writeFileSync(path.join(workspace, 'broken.frynb'), '{ this is not json'); + fs.writeFileSync(path.join(workspace, 'run.frynb'), JSON.stringify({ SchemaVersion: '1.0', Type: 'notebook', Id: 'run', Title: 'Run', Kernel: 'csharp', Cells: [] })); + process.env.FRY_IT_WORKSPACE = workspace; + + try { + await runTests({ + ...(executable ? { vscodeExecutablePath: executable } : {}), + extensionDevelopmentPath: root, + extensionTestsPath: path.join(__dirname, 'suite', 'index'), + launchArgs: [workspace, '--disable-extensions', '--disable-workspace-trust', '--skip-welcome', '--skip-release-notes', '--user-data-dir', path.join(scratch, 'user'), '--extensions-dir', path.join(scratch, 'extensions')] + }); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error('The extension tests failed:', error); + process.exit(1); +}); diff --git a/vscode-frysharp/test/integration/suite/cpp.ts b/vscode-frysharp/test/integration/suite/cpp.ts new file mode 100644 index 00000000..102b2420 --- /dev/null +++ b/vscode-frysharp/test/integration/suite/cpp.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; +import { notebookIn, notebookWith, runCell, startRun, waitFor } from './notebooks'; +import { itemsOf as items, textOfCell as textOf } from './outputs'; +import { needs } from './skip'; + +// C++ cells and files, built and run by a real compiler through the real engine, in a real VS Code. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; +const TABLE = 'application/vnd.fry.table+json'; +const INSPECTOR = 'application/vnd.fry.inspector+json'; + +// clang++ or g++ (the engine finds one the same way) +const needsCompiler = (): void => { + if (process.env.FRY_REQUIRE_CPP === '1') return; + try { + needs('clang++', 'FRY_REQUIRE_CPP'); + } catch { + needs('g++', 'FRY_REQUIRE_CPP'); + } +}; + +export const cppTests: Array<[string, () => Promise]> = [ + [ + 'a C++ cell keeps what it makes, shows a list of structs as a table and a struct as the Object Explorer', + async () => { + needsCompiler(); + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookIn( + 'cpp', + 'struct Person {\n std::string name;\n int age;\n};\n\nint total = 41;', + 'std::vector{{"Ada", 36}, {"Linus", 55}}', + 'Person{"Grace", 85}', + 'total + 1' + ); + await runCell(notebook, 0); + const list = await runCell(notebook, 1); + const one = await runCell(notebook, 2); + const sum = await runCell(notebook, 3); + + assert.equal(list.executionSummary?.success, true, textOf(list)); + const [table] = items(list, TABLE) as Array<{ columns: string[]; numeric: boolean[]; rows: string[][] }>; + assert.deepStrictEqual(table.columns, ['name', 'age']); + assert.deepStrictEqual(table.numeric, [false, true]); + assert.deepStrictEqual(table.rows, [['Ada', '36'], ['Linus', '55']]); + const [tree] = items(one, INSPECTOR) as Array<{ title: string }>; + assert.equal(tree.title, 'Person'); + assert.equal(sum.executionSummary?.success, true, textOf(sum)); + assert.match(textOf(sum), /42/); + + const kernels = await api.variables.getChildren(); + const cpp = kernels.find((node) => /C\+\+/i.test(String(api.variables.getTreeItem(node).label))); + assert.ok(cpp, 'the C++ kernel is listed'); + const names = (await api.variables.getChildren(cpp)).map((node) => api.variables.getTreeItem(node).label); + assert.ok(names.includes('total'), JSON.stringify(names)); + } + ], + [ + 'a C++ compile error is marked on the line of the cell it is about, and the text names the cell', + async () => { + needsCompiler(); + const notebook = await notebookIn('cpp', 'int a = 1;\n\nint s = "text";\nstd::cout << a << s;'); + const compile = await runCell(notebook, 0); + + assert.equal(compile.executionSummary?.success, false); + const marks = vscode.languages.getDiagnostics(compile.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(marks.length, 1); + assert.equal(marks[0].range.start.line, 2); + assert.equal(marks[0].range.start.character, 4); + assert.match(textOf(compile), /cell:3:5/); + } + ], + [ + 'fry::input in a C++ cell asks in an input box; closing it is the end of input, and the cell goes on', + async () => { + needsCompiler(); + const notebook = await notebookIn('cpp', 'auto line = fry::input("Name? ");\nstd::cout << (line ? "got " + *line : std::string("end of input")) << "\\n";'); + await vscode.window.showNotebookDocument(notebook); + await vscode.commands.executeCommand('notebook.selectKernel', { id: 'frysharp-kernel-frysharp-notebook', extension: EXTENSION }); + const cell = notebook.cellAt(0); + startRun(notebook, 0, 1); + + await new Promise((resolve) => setTimeout(resolve, 10000)); // the box is open by now (the build is slow): the cell has not finished + assert.equal(cell.executionSummary?.success, undefined, 'the cell is waiting for its answer'); + await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); + await waitFor('the cell to finish', () => cell.executionSummary?.success !== undefined, 90_000); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.match(textOf(cell), /end of input/); + } + ], + [ + 'a C++ cell can take a list a C# cell made, with #!share, and its own values go the other way', + async () => { + needsCompiler(); + const notebook = await notebookWith( + ['csharp', 'var rows = new[] { 3, 4, 5 };'], + ['cpp', '#!share --from csharp rows\nlong long total = 0;\nfor (auto n : rows) total += n;\nstd::cout << rows.size() << " rows, sum " << total << "\\n";'], + ['cpp', 'std::vector squares{1, 4, 9, 16};'], + ['csharp', '#!share --from cpp squares\nConsole.WriteLine(string.Join(",", squares));'] + ); + await runCell(notebook, 0); + const inCpp = await runCell(notebook, 1); + await runCell(notebook, 2); + const back = await runCell(notebook, 3); + + assert.equal(inCpp.executionSummary?.success, true, textOf(inCpp)); + assert.match(textOf(inCpp), /3 rows, sum 12/); + assert.equal(back.executionSummary?.success, true, textOf(back)); + assert.match(textOf(back), /1,4,9,16/); + } + ], + [ + 'Stop ends a C++ cell that is running, and what the notebook made before is still there', + async () => { + needsCompiler(); + const notebook = await notebookIn('cpp', 'int kept = 41;', 'std::cout << "started\\n";\nwhile (true) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); }', 'kept + 1'); + await runCell(notebook, 0); + const running = notebook.cellAt(1); + startRun(notebook, 1, 2); + await waitFor('the program to start', () => textOf(running).includes('started'), 90_000); + await vscode.commands.executeCommand('notebook.cell.cancelExecution', { ranges: [{ start: 1, end: 2 }], document: notebook.uri }); + await waitFor('the cell to stop', () => running.executionSummary?.success !== undefined, 30_000); + + const after = await runCell(notebook, 2); + assert.equal(after.executionSummary?.success, true, textOf(after)); + assert.match(textOf(after), /42/); + } + ], + [ + 'Run with FrySharp runs a .cpp file in the terminal, and a compile error is a problem at its line', + async () => { + needsCompiler(); + const { scripts } = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const hello = vscode.Uri.file(path.join(workspace, 'hello.cpp')); + fs.writeFileSync(hello.fsPath, '#include \n\nint main() {\n auto name = fry::input("Name? ");\n std::cout << "Hello, " << name.value_or("?") << std::endl;\n}\n'); + + await scripts.run(hello); + const ask = scripts.terminalFor(hello)!; + await waitFor('the prompt', () => ask.transcript.includes('Name? '), 120_000); + ask.handleInput('Ada'); + ask.handleInput('\r'); + await waitFor('the run to end', () => /exited with code/.test(ask.transcript), 30_000); + assert.match(ask.transcript, /Hello, Ada/); + assert.match(ask.transcript, /exited with code 0/); + + const broken = vscode.Uri.file(path.join(workspace, 'broken.cpp')); + fs.writeFileSync(broken.fsPath, '#include \n\nint main() {\n int total = "x";\n std::cout << total;\n}\n'); + await scripts.run(broken); + const run = scripts.terminalFor(broken)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript), 90_000); + + assert.match(run.transcript, /exited with code 1/); + await waitFor('the problem', () => vscode.languages.getDiagnostics(broken).some((d) => d.range.start.line === 3), 10_000); + } + ] +]; diff --git a/vscode-frysharp/test/integration/suite/fsharp.ts b/vscode-frysharp/test/integration/suite/fsharp.ts new file mode 100644 index 00000000..654f3181 --- /dev/null +++ b/vscode-frysharp/test/integration/suite/fsharp.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; +import { notebookIn, notebookWith, runCell, waitFor } from './notebooks'; +import { itemsOf as items, textOfCell as textOf } from './outputs'; +import { needs } from './skip'; + +// F# cells and files, run by the real `dotnet fsi` through the real engine, in a real VS Code. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; +const TABLE = 'application/vnd.fry.table+json'; +const INSPECTOR = 'application/vnd.fry.inspector+json'; + +export const fsharpTests: Array<[string, () => Promise]> = [ + [ + 'an F# cell keeps what it defines for the next one, shows records as a table, and the values are the notebook\'s variables', + async () => { + needs('dotnet', 'FRY_REQUIRE_FSHARP'); + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookIn( + 'fsharp', + 'type Person = { Name: string; Age: int }\nlet people = [ { Name = "Ada"; Age = 36 }; { Name = "Linus"; Age = 55 } ]', + 'people |> List.filter (fun p -> p.Age > 40)', + 'people.Head' + ); + await runCell(notebook, 0); + const filtered = await runCell(notebook, 1); + const head = await runCell(notebook, 2); + + assert.equal(filtered.executionSummary?.success, true, textOf(filtered)); + const [table] = items(filtered, TABLE) as Array<{ columns: string[]; rows: string[][] }>; + assert.deepStrictEqual(table.columns, ['Name', 'Age']); + assert.deepStrictEqual(table.rows, [['Linus', '55']]); + const [tree] = items(head, INSPECTOR) as Array<{ title: string; properties: Array<{ name: string; value: string }> }>; + assert.equal(tree.title, 'Person'); + assert.deepStrictEqual(tree.properties.map((p) => [p.name, p.value]), [['Name', 'Ada'], ['Age', '36']]); + + const kernels = await api.variables.getChildren(); + const fsharp = kernels.find((node) => /F#/.test(String(api.variables.getTreeItem(node).label))); + assert.ok(fsharp, 'the F# kernel is listed'); + const names = (await api.variables.getChildren(fsharp)).map((node) => api.variables.getTreeItem(node).label); + assert.deepStrictEqual(names, ['people']); + } + ], + [ + 'an F# compile error is underlined where the compiler found it, on the line and columns of the cell', + async () => { + needs('dotnet', 'FRY_REQUIRE_FSHARP'); + const notebook = await notebookIn('fsharp', 'let a = 1\n\nlet b = a + nope\nb'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, false); + const marks = vscode.languages.getDiagnostics(cell.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(marks.length, 1); + assert.equal(marks[0].code, 'FS0039'); + assert.equal(marks[0].range.start.line, 2); + assert.equal(marks[0].range.start.character, 12); + assert.equal(marks[0].range.end.character, 16); + assert.match(marks[0].message, /nope/); + } + ], + [ + 'an F# cell can take a list a C# cell made, with #!share, and its own values go the other way', + async () => { + needs('dotnet', 'FRY_REQUIRE_FSHARP'); + const notebook = await notebookWith( + ['csharp', 'var rows = new[] { new { Name = "Ada", Age = 36 }, new { Name = "Linus", Age = 55 } };'], + ['fsharp', '#!share --from csharp rows\nrows |> Array.map (fun r -> r.["Name"] :?> string) |> String.concat ", "'], + ['fsharp', 'let squares = [ for i in 1 .. 4 -> i * i ]'], + ['csharp', '#!share --from fsharp squares\nConsole.WriteLine(string.Join(",", squares));'] + ); + await runCell(notebook, 0); + const inFsharp = await runCell(notebook, 1); + await runCell(notebook, 2); + const back = await runCell(notebook, 3); + + assert.equal(inFsharp.executionSummary?.success, true, textOf(inFsharp)); + assert.match(textOf(inFsharp), /Ada, Linus/); + assert.equal(back.executionSummary?.success, true, textOf(back)); + assert.match(textOf(back), /1,4,9,16/); + } + ], + [ + 'Run with FrySharp runs an .fsx file in the terminal, and its error is a problem at its line', + async () => { + needs('dotnet', 'FRY_REQUIRE_FSHARP'); + const { scripts } = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const file = vscode.Uri.file(path.join(workspace, 'report.fsx')); + fs.writeFileSync(file.fsPath, 'printfn "total: %d" (List.sum [ 1; 2; 3 ])\nlet bad = 1 + "x"\n'); + + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript), 90_000); + + assert.match(run.transcript, /exited with code 1/); + await waitFor('the problem', () => vscode.languages.getDiagnostics(file).some((d) => /FS0001|type/i.test(String(d.code) + d.message)), 10_000); + const problem = vscode.languages.getDiagnostics(file).find((d) => d.range.start.line === 1); + assert.ok(problem, 'a problem on line 2'); + } + ] +]; diff --git a/vscode-frysharp/test/integration/suite/go.ts b/vscode-frysharp/test/integration/suite/go.ts new file mode 100644 index 00000000..fb9aa4ea --- /dev/null +++ b/vscode-frysharp/test/integration/suite/go.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; +import { notebookIn, notebookWith, runCell, startRun, waitFor } from './notebooks'; +import { itemsOf as items, textOfCell as textOf } from './outputs'; +import { needs } from './skip'; + +// Go cells and files, built and run by the real Go toolchain through the real engine, in a real VS Code. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; +const TABLE = 'application/vnd.fry.table+json'; +const INSPECTOR = 'application/vnd.fry.inspector+json'; + +export const goTests: Array<[string, () => Promise]> = [ + [ + 'a Go cell keeps what it makes, shows a list of structs as a table and a struct as the Object Explorer', + async () => { + needs('go', 'FRY_REQUIRE_GO'); + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookIn( + 'go', + 'type Person struct {\n\tName string\n\tAge int\n}\n\ntotal := 41', + '[]Person{{"Ada", 36}, {"Linus", 55}}', + 'Person{"Grace", 85}', + 'total + 1' + ); + await runCell(notebook, 0); + const list = await runCell(notebook, 1); + const one = await runCell(notebook, 2); + const sum = await runCell(notebook, 3); + + assert.equal(list.executionSummary?.success, true, textOf(list)); + const [table] = items(list, TABLE) as Array<{ columns: string[]; numeric: boolean[]; rows: string[][] }>; + assert.deepStrictEqual(table.columns, ['Name', 'Age']); + assert.deepStrictEqual(table.numeric, [false, true]); + assert.deepStrictEqual(table.rows, [['Ada', '36'], ['Linus', '55']]); + const [tree] = items(one, INSPECTOR) as Array<{ title: string }>; + assert.equal(tree.title, 'Person'); + assert.equal(sum.executionSummary?.success, true, textOf(sum)); + assert.match(textOf(sum), /42/); + + const kernels = await api.variables.getChildren(); + const go = kernels.find((node) => /^Go/i.test(String(api.variables.getTreeItem(node).label))); + assert.ok(go, 'the Go kernel is listed'); + const names = (await api.variables.getChildren(go)).map((node) => api.variables.getTreeItem(node).label); + assert.ok(names.includes('total'), JSON.stringify(names)); + } + ], + [ + 'a Go compile error and a panic are marked on the line of the cell they are about, and the text names the cell', + async () => { + needs('go', 'FRY_REQUIRE_GO'); + const notebook = await notebookIn('go', 'a := 1\n\nvar s string = 5\nfmt.Println(a, s)', 'v := []int{}\n\nfmt.Println(v[3])'); + const compile = await runCell(notebook, 0); + const panic = await runCell(notebook, 1); + + assert.equal(compile.executionSummary?.success, false); + const marks = vscode.languages.getDiagnostics(compile.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(marks.length, 1); + assert.equal(marks[0].range.start.line, 2); + assert.equal(marks[0].range.start.character, 15); + assert.match(marks[0].message, /cannot use 5/); + assert.match(textOf(compile), /cell:3:16/); + + assert.equal(panic.executionSummary?.success, false); + const thrown = vscode.languages.getDiagnostics(panic.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(thrown.length, 1); + assert.equal(thrown[0].range.start.line, 2); + assert.match(textOf(panic), /cell:3/); + } + ], + [ + 'fry.Input in a Go cell asks in an input box; closing it is the end of input, and the cell goes on', + async () => { + needs('go', 'FRY_REQUIRE_GO'); + const notebook = await notebookIn('go', 'line, ok := fry.Input("Name? ")\nif ok {\n\tfmt.Println("got", line)\n} else {\n\tfmt.Println("end of input")\n}'); + await vscode.window.showNotebookDocument(notebook); + await vscode.commands.executeCommand('notebook.selectKernel', { id: 'frysharp-kernel-frysharp-notebook', extension: EXTENSION }); + const cell = notebook.cellAt(0); + startRun(notebook, 0, 1); + + await new Promise((resolve) => setTimeout(resolve, 8000)); // the box is open by now (the first build is slower): the cell has not finished + assert.equal(cell.executionSummary?.success, undefined, 'the cell is waiting for its answer'); + await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); + await waitFor('the cell to finish', () => cell.executionSummary?.success !== undefined, 90_000); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.match(textOf(cell), /end of input/); + } + ], + [ + 'a Go cell can take a list a C# cell made, with #!share, and its own values go the other way', + async () => { + needs('go', 'FRY_REQUIRE_GO'); + const notebook = await notebookWith( + ['csharp', 'var rows = new[] { 3, 4, 5 };'], + ['go', '#!share --from csharp rows\ntotal := 0\nfor _, n := range rows {\n\ttotal += n\n}\nfmt.Println(len(rows), "rows, sum", total)'], + ['go', 'squares := []int{1, 4, 9, 16}'], + ['csharp', '#!share --from go squares\nConsole.WriteLine(string.Join(",", squares));'] + ); + await runCell(notebook, 0); + const inGo = await runCell(notebook, 1); + await runCell(notebook, 2); + const back = await runCell(notebook, 3); + + assert.equal(inGo.executionSummary?.success, true, textOf(inGo)); + assert.match(textOf(inGo), /3 rows, sum 12/); + assert.equal(back.executionSummary?.success, true, textOf(back)); + assert.match(textOf(back), /1,4,9,16/); + } + ], + [ + 'Stop ends a Go cell that is running, and what the notebook made before is still there', + async () => { + needs('go', 'FRY_REQUIRE_GO'); + const notebook = await notebookIn('go', 'kept := 41', 'fmt.Println("started")\nfor {\n\ttime.Sleep(50 * time.Millisecond)\n}', 'kept + 1'); + await runCell(notebook, 0); + const running = notebook.cellAt(1); + startRun(notebook, 1, 2); + await waitFor('the program to start', () => textOf(running).includes('started'), 90_000); + await vscode.commands.executeCommand('notebook.cell.cancelExecution', { ranges: [{ start: 1, end: 2 }], document: notebook.uri }); + await waitFor('the cell to stop', () => running.executionSummary?.success !== undefined, 30_000); + + const after = await runCell(notebook, 2); + assert.equal(after.executionSummary?.success, true, textOf(after)); + assert.match(textOf(after), /42/); + } + ], + [ + 'Run with FrySharp runs a .go file in the terminal, asks for input with fry.Input, and a compile error is a problem at its line', + async () => { + needs('go', 'FRY_REQUIRE_GO'); + const { scripts } = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const asks = vscode.Uri.file(path.join(workspace, 'asks.go')); + fs.writeFileSync(asks.fsPath, 'package main\n\nimport (\n\t"fmt"\n\t"fry"\n)\n\nfunc main() {\n\tname, _ := fry.Input("Name? ")\n\tfmt.Println("Hello,", name)\n}\n'); + + await scripts.run(asks); + const ask = scripts.terminalFor(asks)!; + await waitFor('the prompt', () => ask.transcript.includes('Name? '), 120_000); + ask.handleInput('Ada'); + ask.handleInput('\r'); + await waitFor('the run to end', () => /exited with code/.test(ask.transcript), 30_000); + assert.match(ask.transcript, /Hello, Ada/); + assert.match(ask.transcript, /exited with code 0/); + + const broken = vscode.Uri.file(path.join(workspace, 'broken.go')); + fs.writeFileSync(broken.fsPath, 'package main\n\nimport "fmt"\n\nfunc main() {\n\tvar total int = "x"\n\tfmt.Println(total)\n}\n'); + await scripts.run(broken); + const run = scripts.terminalFor(broken)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript), 90_000); + + assert.match(run.transcript, /exited with code 1/); + await waitFor('the problem', () => vscode.languages.getDiagnostics(broken).some((d) => d.range.start.line === 5), 10_000); + } + ] +]; diff --git a/vscode-frysharp/test/integration/suite/index.ts b/vscode-frysharp/test/integration/suite/index.ts new file mode 100644 index 00000000..61cd3b2a --- /dev/null +++ b/vscode-frysharp/test/integration/suite/index.ts @@ -0,0 +1,38 @@ +import { cppTests } from './cpp'; +import { fsharpTests } from './fsharp'; +import { javaTests } from './java'; +import { goTests } from './go'; +import { rustTests } from './rust'; +import { languageTests } from './language'; +import { tests as notebookTests } from './notebooks'; +import { scriptTests } from './scripts'; +import { SkipTest } from './skip'; +import { sqlTests } from './sql'; + +const tests = [...notebookTests, ...languageTests, ...sqlTests, ...fsharpTests, ...javaTests, ...rustTests, ...goTests, ...cppTests, ...scriptTests]; + +// Runs in the Extension Host: each test in turn, so the notebooks they open do not meet. A failure names the test. +export async function run(): Promise { + // FRY_IT_FILTER=java,lens runs the tests whose name has one of those words (a quick look at one language or feature) + const filters = process.env.FRY_IT_FILTER?.toLowerCase().split(',').map((word) => word.trim()).filter(Boolean) ?? []; + const selected = filters.length > 0 ? tests.filter(([name]) => filters.some((word) => name.toLowerCase().includes(word))) : tests; + const failures: string[] = []; + const skipped: string[] = []; + for (const [name, test] of selected) { + const started = Date.now(); + try { + await test(); + console.log(` ok ${name} (${Date.now() - started} ms)`); + } catch (error) { + if (error instanceof SkipTest) { + console.log(` SKIP ${name}: ${error.message}`); + skipped.push(name); + continue; + } + console.log(` FAIL ${name}\n ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + failures.push(name); + } + } + if (failures.length > 0) throw new Error(`${failures.length} of ${selected.length} failed: ${failures.join('; ')}`); + console.log(`${selected.length - skipped.length} extension tests passed${skipped.length > 0 ? `, ${skipped.length} skipped` : ''}.`); +} diff --git a/vscode-frysharp/test/integration/suite/java.ts b/vscode-frysharp/test/integration/suite/java.ts new file mode 100644 index 00000000..6cc8b9ae --- /dev/null +++ b/vscode-frysharp/test/integration/suite/java.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; +import { notebookIn, notebookWith, runCell, startRun, waitFor } from './notebooks'; +import { itemsOf as items, textOfCell as textOf } from './outputs'; +import { needs } from './skip'; + +// Java cells and files, run by the real JDK (a JShell kernel) through the real engine, in a real VS Code. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; +const TABLE = 'application/vnd.fry.table+json'; +const INSPECTOR = 'application/vnd.fry.inspector+json'; + +export const javaTests: Array<[string, () => Promise]> = [ + [ + 'a Java cell keeps what it declares, shows a list of records as a table and an object as the Object Explorer', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookIn( + 'java', + 'record Person(String name, int age) {}\nvar people = List.of(new Person("Ada", 36), new Person("Linus", 55));', + 'people', + 'people.get(0)' + ); + await runCell(notebook, 0); + const list = await runCell(notebook, 1); + const one = await runCell(notebook, 2); + + assert.equal(list.executionSummary?.success, true, textOf(list)); + const [table] = items(list, TABLE) as Array<{ columns: string[]; numeric: boolean[]; rows: string[][] }>; + assert.deepStrictEqual(table.columns, ['name', 'age']); + assert.deepStrictEqual(table.numeric, [false, true]); + assert.deepStrictEqual(table.rows, [['Ada', '36'], ['Linus', '55']]); + const [tree] = items(one, INSPECTOR) as Array<{ title: string }>; + assert.equal(tree.title, 'Person'); + + const kernels = await api.variables.getChildren(); + const java = kernels.find((node) => /Java/i.test(String(api.variables.getTreeItem(node).label))); + assert.ok(java, 'the Java kernel is listed'); + const names = (await api.variables.getChildren(java)).map((node) => api.variables.getTreeItem(node).label); + assert.deepStrictEqual(names, ['people']); + } + ], + [ + 'a Java object that points at itself reaches the Object Explorer five levels deep, each reference back listing its members (as the desktop)', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const notebook = await notebookIn( + 'java', + 'public class People { public String name = "Prashant"; public int[] numbers = {1, 34, 45}; public People another; }\nPeople person = new People();\nperson.another = person;\nperson' + ); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + type Node = { title: string; circular?: boolean; properties: Array<{ name: string; child?: Node }> }; + const [top] = items(cell, INSPECTOR) as Node[]; + const chain = [top]; + for (let next = chain[0].properties.find((p) => p.name === 'another')?.child; next; next = next.properties.find((p) => p.name === 'another')?.child) chain.push(next); + + assert.equal(chain.length, 5); + assert.deepStrictEqual(chain.map((node) => node.circular === true), [false, true, true, true, false]); + assert.deepStrictEqual(chain.slice(0, 4).map((node) => node.properties.map((p) => p.name)), Array(4).fill(['name', 'numbers', 'another'])); + assert.deepStrictEqual(chain[4].properties, []); + } + ], + [ + 'a Java compile error is underlined where the compiler found it, and a runtime error is on its line of the cell', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const notebook = await notebookIn('java', 'int a = 1;\n\nString s = 5;\nint c = a;', 'void f(int x) {\n if (x > 1) throw new IllegalStateException("boom");\n}\n\nf(5);'); + const compile = await runCell(notebook, 0); + const runtime = await runCell(notebook, 1); + + assert.equal(compile.executionSummary?.success, false); + const marks = vscode.languages.getDiagnostics(compile.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(marks.length, 1); + assert.equal(marks[0].range.start.line, 2); + assert.equal(marks[0].range.start.character, 11); + assert.match(marks[0].message, /incompatible types/); + + assert.equal(runtime.executionSummary?.success, false); + const thrown = vscode.languages.getDiagnostics(runtime.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(thrown.length, 1); + assert.equal(thrown[0].code, 'IllegalStateException'); + assert.equal(thrown[0].range.start.line, 1, 'on the line inside f where it was thrown'); + } + ], + [ + 'a Java cell can take a list a C# cell made, with #!share, and its own values go the other way', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const notebook = await notebookWith( + ['csharp', 'var rows = new[] { new { Name = "Ada", Age = 36 }, new { Name = "Linus", Age = 55 } };'], + ['java', '#!share --from csharp rows\nSystem.out.println(rows.size() + " rows");'], + ['java', 'int[] squares = {1, 4, 9, 16};'], + ['csharp', '#!share --from java squares\nConsole.WriteLine(string.Join(",", squares));'] + ); + await runCell(notebook, 0); + const inJava = await runCell(notebook, 1); + await runCell(notebook, 2); + const back = await runCell(notebook, 3); + + assert.equal(inJava.executionSummary?.success, true, textOf(inJava)); + assert.match(textOf(inJava), /2 rows/); + assert.equal(back.executionSummary?.success, true, textOf(back)); + assert.match(textOf(back), /1,4,9,16/); + } + ], + [ + 'a Java cell that reads a line asks in an input box; closing it is the end of input, and the cell goes on', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const notebook = await notebookIn( + 'java', + 'var reader = new java.io.BufferedReader(new java.io.InputStreamReader(System.in));\nString line = reader.readLine();\nSystem.out.println(line == null ? "end of input" : "got " + line);' + ); + await vscode.window.showNotebookDocument(notebook); + await vscode.commands.executeCommand('notebook.selectKernel', { id: 'frysharp-kernel-frysharp-notebook', extension: EXTENSION }); + const cell = notebook.cellAt(0); + startRun(notebook, 0, 1); + + await new Promise((resolve) => setTimeout(resolve, 9000)); // the box is open by now (the JVM starts first): the cell has not finished + assert.equal(cell.executionSummary?.success, undefined, 'the cell is waiting for its answer'); + await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); + await waitFor('the cell to finish', () => cell.executionSummary?.success !== undefined, 90_000); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.match(textOf(cell), /end of input/); + } + ], + [ + 'Stop ends a Java cell that only computes, and what the notebook declared is still there', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const notebook = await notebookIn('java', 'int kept = 41;', 'long i = 0;\nwhile (true) { i++; }', 'kept + 1'); + await runCell(notebook, 0); + const running = notebook.cellAt(1); + startRun(notebook, 1, 2); + await new Promise((resolve) => setTimeout(resolve, 2500)); // long enough for it to be in its loop + await vscode.commands.executeCommand('notebook.cell.cancelExecution', { ranges: [{ start: 1, end: 2 }], document: notebook.uri }); + await waitFor('the cell to stop', () => running.executionSummary?.success !== undefined, 15_000); + + const after = await runCell(notebook, 2); + assert.equal(after.executionSummary?.success, true, textOf(after)); + assert.match(textOf(after), /42/); + } + ], + [ + 'a .java file that reads a line (Scanner) gets what is typed in the terminal', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const { scripts } = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const file = vscode.Uri.file(path.join(workspace, 'Greet.java')); + fs.writeFileSync(file.fsPath, 'import java.util.Scanner;\n\npublic class Greet {\n public static void main(String[] args) {\n System.out.print("Name? ");\n String name = new Scanner(System.in).nextLine();\n System.out.println("Hello, " + name);\n }\n}\n'); + + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the prompt', () => run.transcript.includes('Name? '), 120_000); + run.handleInput('Ada'); + run.handleInput('\r'); + await waitFor('the run to end', () => /exited with code/.test(run.transcript), 30_000); + + assert.match(run.transcript, /Hello, Ada/); + assert.match(run.transcript, /exited with code 0/); + } + ], + [ + 'Run with FrySharp runs a .java file in the terminal, and its compile error is a problem at its line', + async () => { + needs('javac', 'FRY_REQUIRE_JAVA'); + const { scripts } = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const file = vscode.Uri.file(path.join(workspace, 'Report.java')); + fs.writeFileSync(file.fsPath, 'public class Report {\n public static void main(String[] args) {\n int total = "x";\n System.out.println(total);\n }\n}\n'); + + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript), 90_000); + + assert.match(run.transcript, /exited with code 1/); + await waitFor('the problem', () => vscode.languages.getDiagnostics(file).some((d) => d.range.start.line === 2), 10_000); + } + ] +]; diff --git a/vscode-frysharp/test/integration/suite/language.ts b/vscode-frysharp/test/integration/suite/language.ts new file mode 100644 index 00000000..3cfc5a9e --- /dev/null +++ b/vscode-frysharp/test/integration/suite/language.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import { notebookIn, notebookWith, runCell, waitFor } from './notebooks'; + +// Completion, hover and problems in the cells of a notebook, asked of a real VS Code, which asks the extension, which asks the +// real engine. The first answers can be slow (the compiler warms up), so each waits for what it expects instead of asking once. + +const labelOf = (item: vscode.CompletionItem): string => (typeof item.label === 'string' ? item.label : item.label.label); + +async function completionsAt(cell: vscode.NotebookCell, line: number, character: number, trigger?: string): Promise { + const list = await vscode.commands.executeCommand('vscode.executeCompletionItemProvider', cell.document.uri, new vscode.Position(line, character), trigger); + return (list?.items ?? []).map(labelOf); +} + +// The editor adds the words of the document as suggestions (kind Text): only an item of the engine's own proves the engine answered. +async function engineItemsAt(cell: vscode.NotebookCell, line: number, character: number, trigger?: string): Promise { + const list = await vscode.commands.executeCommand('vscode.executeCompletionItemProvider', cell.document.uri, new vscode.Position(line, character), trigger); + return (list?.items ?? []).filter((item) => item.kind !== vscode.CompletionItemKind.Text); +} + +async function eventually(what: string, ask: () => Thenable, accept: (answer: T) => boolean, ms = 60_000): Promise { + const until = Date.now() + ms; + for (;;) { + const answer = await ask(); + if (accept(answer)) return answer; + if (Date.now() > until) throw new Error(`timed out waiting for ${what}; the last answer was ${JSON.stringify(answer)}`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } +} + +const problemsOf = (cell: vscode.NotebookCell): vscode.Diagnostic[] => vscode.languages.getDiagnostics(cell.document.uri).filter((d) => d.source === 'FrySharp'); + +async function replaceText(cell: vscode.NotebookCell, text: string): Promise { + const edit = new vscode.WorkspaceEdit(); + edit.replace(cell.document.uri, new vscode.Range(0, 0, cell.document.lineCount, 0), text); + assert.ok(await vscode.workspace.applyEdit(edit)); +} + +export const languageTests: Array<[string, () => Promise]> = [ + [ + 'C# completion in a cell knows what the cells before it define', + async () => { + const notebook = await notebookIn('csharp', 'var greeting = "hello";', 'greeting.'); + const cell = notebook.cellAt(1); + const labels = await eventually('completions after greeting.', () => completionsAt(cell, 0, 'greeting.'.length, '.'), (items) => items.includes('ToUpper')); + assert.ok(labels.includes('Length'), 'a member of string'); + const names = await eventually('completions of a name', () => engineItemsAt(cell, 0, 0), (items) => items.some((item) => labelOf(item) === 'greeting')); + assert.ok(names.some((item) => labelOf(item) === 'greeting'), 'the variable of the cell before, from the engine and not from the words of the page'); + } + ], + [ + 'hover in a C# cell shows the type, with the cells before it in scope', + async () => { + const notebook = await notebookIn('csharp', 'var count = 42;', 'var next = count + 1;'); + const cell = notebook.cellAt(1); + const hovers = await eventually( + 'a hover on count', + () => vscode.commands.executeCommand('vscode.executeHoverProvider', cell.document.uri, new vscode.Position(0, 'var next = c'.length)), + (items) => (items ?? []).length > 0 + ); + const text = hovers.flatMap((hover) => hover.contents).map((content) => (typeof content === 'string' ? content : content.value)).join('\n'); + assert.match(text, /int/); + } + ], + [ + 'directives are completed in a cell of any language', + async () => { + const notebook = await notebookIn('python', '#!'); + const labels = await eventually('directive completions', () => completionsAt(notebook.cellAt(0), 0, 2, '!'), (items) => items.includes('share')); + assert.ok(labels.includes('python') && labels.includes('csharp'), 'the languages of the engine'); + } + ], + [ + '#!share completes the variables the other kernel holds', + async () => { + const notebook = await notebookWith(['csharp', 'var population = 8;'], ['python', '#!share --from csharp ']); + await runCell(notebook, 0); + const labels = await eventually('the variable of the C# kernel', () => completionsAt(notebook.cellAt(1), 0, '#!share --from csharp '.length), (items) => items.includes('population')); + assert.ok(labels.includes('population')); + } + ], + [ + 'a JavaScript cell gets the editor\'s own completion, next to the notebook\'s directives', + async () => { + const notebook = await notebookIn('javascript', 'console.'); + const labels = await eventually('completions after console.', () => completionsAt(notebook.cellAt(0), 0, 'console.'.length, '.'), (items) => items.includes('log')); + assert.ok(labels.includes('log')); + } + ], + [ + 'SQL cells get completion (keywords, functions, the tables earlier cells created, dot-commands) and hover', + async () => { + const notebook = await notebookIn('sql', 'create table people (id integer);', 'select * from peo', 'select CO', '.ta', 'select name from people group by name'); + + const tables = await eventually('the table of the cell before', () => engineItemsAt(notebook.cellAt(1), 0, 'select * from peo'.length), (items) => items.some((item) => labelOf(item) === 'people')); + assert.equal(tables.find((item) => labelOf(item) === 'people')?.kind, vscode.CompletionItemKind.Struct); + const functions = await eventually('functions', () => engineItemsAt(notebook.cellAt(2), 0, 'select CO'.length), (items) => items.some((item) => labelOf(item) === 'COUNT')); + assert.ok(functions.some((item) => labelOf(item) === 'COALESCE')); + + // after the dot that was typed, the command inserts its name, not a second dot + const list = await vscode.commands.executeCommand('vscode.executeCompletionItemProvider', notebook.cellAt(3).document.uri, new vscode.Position(0, 3), '.'); + const tablesCommand = list.items.find((item) => labelOf(item) === '.tables'); + assert.equal(tablesCommand?.insertText, 'tables'); + + const hovers = await eventually( + 'a hover on group', + () => vscode.commands.executeCommand('vscode.executeHoverProvider', notebook.cellAt(4).document.uri, new vscode.Position(0, 'select name from people gr'.length)), + (items) => (items ?? []).length > 0 + ); + const text = hovers.flatMap((hover) => hover.contents).map((content) => (typeof content === 'string' ? content : content.value)).join('\n'); + assert.match(text, /GROUP BY column1, column2/); + } + ], + [ + 'a compile error is marked in the cell as it is typed, and gone when it is fixed', + async () => { + const notebook = await notebookIn('csharp', 'var a = 1;', 'var b = missing + a;'); + const cell = notebook.cellAt(1); + const marked = await eventually('the error to be marked', async () => problemsOf(cell), (problems) => problems.some((p) => p.message.includes('missing'))); + assert.equal(marked[0].severity, vscode.DiagnosticSeverity.Error); + assert.equal(marked[0].range.start.line, 0, 'on the line of the cell, not of the cells joined before it'); + + await replaceText(cell, 'var b = a + 1;'); + await eventually('the mark to go', async () => problemsOf(cell), (problems) => problems.length === 0); + } + ], + [ + 'defining a name again in a later cell is not an error, and the cells after an error are still checked', + async () => { + const notebook = await notebookIn('csharp', 'var x = 1;', 'var x = 2;', 'int y = "text";'); + await eventually('the last cell to be checked', async () => problemsOf(notebook.cellAt(2)), (problems) => problems.length > 0); + assert.deepStrictEqual(problemsOf(notebook.cellAt(0)).map((p) => p.message), []); + assert.deepStrictEqual(problemsOf(notebook.cellAt(1)).map((p) => p.message), [], 'redefinition is what cells are for'); + } + ], + [ + 'a directive that is not one is marked, in a Python cell', + async () => { + const notebook = await notebookIn('python', '#!pyton\nprint(1)'); + const problems = await eventually('the directive to be marked', async () => problemsOf(notebook.cellAt(0)), (found) => found.length > 0); + assert.equal(problems[0].code, 'FRY0001'); + assert.equal(problems[0].range.start.line, 0); + } + ], + [ + 'the problems of a run and of typing are one list, each problem once', + async () => { + const notebook = await notebookIn('csharp', 'int n = "text";'); + const cell = notebook.cellAt(0); + const typed = await eventually('the error to be marked', async () => problemsOf(cell), (problems) => problems.length > 0); + await runCell(notebook, 0); + await waitFor('the run to be done', () => cell.executionSummary?.success === false); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.equal(problemsOf(cell).length, typed.length, 'running it does not mark the same error twice'); + } + ] +]; diff --git a/vscode-frysharp/test/integration/suite/notebooks.ts b/vscode-frysharp/test/integration/suite/notebooks.ts new file mode 100644 index 00000000..91161f33 --- /dev/null +++ b/vscode-frysharp/test/integration/suite/notebooks.ts @@ -0,0 +1,548 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; +import { isTabbed, outputsOf } from './outputs'; + +// These run inside a real VS Code, with the real engine (the sidecar), on copies of the fixtures in the workspace. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; +const TABLE = 'application/vnd.fry.table+json'; +const decode = (data: Uint8Array) => new TextDecoder().decode(data); + +export async function waitFor(what: string, condition: () => boolean, ms = 60_000): Promise { + const until = Date.now() + ms; + while (!condition()) { + if (Date.now() > until) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function open(name: string): Promise { + return vscode.workspace.openNotebookDocument(vscode.Uri.file(path.join(workspace, name))); +} + +// A change that makes the notebook dirty and means nothing, so that a save writes it. +async function touch(notebook: vscode.NotebookDocument): Promise { + const edit = new vscode.WorkspaceEdit(); + const cell = notebook.cellAt(0); + edit.set(notebook.uri, [vscode.NotebookEdit.updateCellMetadata(0, { ...cell.metadata, touched: true })]); + assert.ok(await vscode.workspace.applyEdit(edit)); + assert.equal(notebook.isDirty, true); +} + +async function save(notebook: vscode.NotebookDocument): Promise { + assert.ok(await notebook.save()); + assert.equal(notebook.isDirty, false); +} + +export async function runCell(notebook: vscode.NotebookDocument, index: number): Promise { + await vscode.window.showNotebookDocument(notebook); + await vscode.commands.executeCommand('notebook.selectKernel', { id: `frysharp-kernel-${notebook.notebookType}`, extension: EXTENSION }); + const cell = notebook.cellAt(index); + await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 1 }], document: notebook.uri }); + await waitFor(`cell ${index} to finish`, () => cell.executionSummary?.success !== undefined); + return cell; +} + +// A new notebook of C# cells, shown, with the FrySharp kernel selected. +async function notebookOf(...codes: string[]): Promise { + return notebookIn('csharp', ...codes); +} + +export async function notebookIn(language: string, ...codes: string[]): Promise { + return notebookWith(...codes.map((code): [string, string] => [language, code])); +} + +// A new notebook of cells in their own languages, shown, with the FrySharp kernel selected. +export async function notebookWith(...contents: Array<[language: string, code: string]>): Promise { + const cells = contents.map(([language, code]) => new vscode.NotebookCellData(vscode.NotebookCellKind.Code, code, language)); + const notebook = await vscode.workspace.openNotebookDocument('frysharp-notebook', new vscode.NotebookData(cells)); + await vscode.window.showNotebookDocument(notebook); + await vscode.commands.executeCommand('notebook.selectKernel', { id: 'frysharp-kernel-frysharp-notebook', extension: EXTENSION }); + return notebook; +} + +// Starts the cells running and returns at once: the command itself only finishes when the cells have, which is no use to +// a test that wants to see them while they run (or to stop them). +export function startRun(notebook: vscode.NotebookDocument, start: number, end: number): void { + void Promise.resolve(vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start, end }], document: notebook.uri })).catch(() => undefined); +} + +const textOf = (cell: vscode.NotebookCell): string => outputsOf(cell).flatMap((output) => output.items).filter((item) => item.mime.startsWith('application/vnd.code.notebook.std') || item.mime === 'text/plain').map((item) => decode(item.data)).join(''); + +const withoutStamp = (json: string): unknown => { + const parsed = JSON.parse(json); + delete parsed.LastModified; + return parsed; +}; + +export const tests: Array<[string, () => Promise]> = [ + [ + 'the extension is there, starts, and offers its commands', + async () => { + const extension = vscode.extensions.getExtension(EXTENSION); + assert.ok(extension, `${EXTENSION} is not loaded`); + await extension.activate(); + const commands = await vscode.commands.getCommands(true); + for (const command of ['frysharp.newNotebook', 'frysharp.runScript', 'frysharp.restartNotebook', 'frysharp.showLog']) assert.ok(commands.includes(command), command); + } + ], + + [ + 'a notebook the desktop wrote opens with each of its outputs', + async () => { + const notebook = await open('desktop.frynb'); + assert.equal(notebook.notebookType, 'frysharp-notebook'); + assert.equal(notebook.cellCount, 8); + + const byId = (id: string) => notebook.getCells().find((cell) => cell.metadata.id === id) as vscode.NotebookCell; + // text and a table are two kinds of output: one output, drawn as tabs, that holds both + assert.equal(isTabbed(byId('table')), true); + assert.deepStrictEqual(outputsOf(byId('table')).map((output) => output.items.map((item) => item.mime)), [['application/vnd.code.notebook.stdout'], ['text/html', TABLE, 'text/plain']]); + assert.equal(isTabbed(byId('text')), false); + assert.equal(decode(byId('text').outputs[0].items[0].data), 'hi\n'); + assert.equal(byId('collapsed').document.languageId, 'python'); + assert.equal(byId('intro').kind, vscode.NotebookCellKind.Markup); + assert.equal(byId('failed').executionSummary?.success, false); + + const table = JSON.parse(decode(outputsOf(byId('table'))[1].items[1].data)); + assert.deepStrictEqual(table.columns, ['Name', 'Age', 'Active']); + assert.deepStrictEqual(table.rows[0], ['Ada', '36', 'True']); + } + ], + + [ + 'saving it from VS Code writes the same notebook (nothing the desktop kept is lost)', + async () => { + const file = path.join(workspace, 'desktop.frynb'); + const before = fs.readFileSync(file, 'utf8'); + const notebook = await open('desktop.frynb'); + + await touch(notebook); + await save(notebook); + + assert.deepStrictEqual(withoutStamp(fs.readFileSync(file, 'utf8')), withoutStamp(before)); + } + ], + + [ + 'a Jupyter notebook opened with FrySharp is saved back byte for byte', + async () => { + const file = path.join(workspace, 'jupyter.ipynb'); + const before = fs.readFileSync(file, 'utf8'); + await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.file(file), 'frysharp-jupyter'); + await waitFor('the notebook to open', () => vscode.workspace.notebookDocuments.some((n) => n.uri.fsPath === file && n.notebookType === 'frysharp-jupyter')); + const notebook = vscode.workspace.notebookDocuments.find((n) => n.uri.fsPath === file) as vscode.NotebookDocument; + + assert.equal(notebook.cellCount, 5); + await touch(notebook); + await save(notebook); + + assert.equal(fs.readFileSync(file, 'utf8'), before); + } + ], + + [ + 'a file that is not a notebook is refused, not opened empty for a save to write over it', + async () => { + const file = path.join(workspace, 'broken.frynb'); + await assert.rejects(async () => vscode.workspace.openNotebookDocument(vscode.Uri.file(file))); + assert.equal(fs.readFileSync(file, 'utf8'), '{ this is not json'); + } + ], + + [ + 'an untitled notebook runs C# cells, and what one defines the next sees', + async () => { + await vscode.commands.executeCommand('frysharp.newNotebook'); + const notebook = vscode.window.activeNotebookEditor?.notebook as vscode.NotebookDocument; + assert.equal(notebook.uri.scheme, 'untitled'); + + const first = await runCell(notebook, 1); + assert.equal(first.executionSummary?.success, true); + assert.match(textOf(first), /Hello from FrySharp! a = 42/); + + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(2, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'Console.WriteLine(a + 1);', 'csharp')])]); + await vscode.workspace.applyEdit(edit); + const second = await runCell(notebook, 2); + + assert.equal(second.executionSummary?.success, true); + assert.match(textOf(second), /43/); + } + ], + + [ + 'a run that shows a table is saved so the desktop can read it, and opens again with its table', + async () => { + const file = path.join(workspace, 'run.frynb'); + const notebook = await open('run.frynb'); + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(0, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'Console.WriteLine("before");\nDisplay.Table(new[] { new { Name = "Ada", Age = 36 }, new { Name = "Grace", Age = 45 } }, "People");', 'csharp')])]); + await vscode.workspace.applyEdit(edit); + + const cell = await runCell(notebook, 0); + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.ok(outputsOf(cell).some((output) => output.items.some((item) => item.mime === TABLE)), 'a table output'); + await save(notebook); + + const saved = JSON.parse(fs.readFileSync(file, 'utf8')).Cells[0]; + assert.equal(saved.OutputText, 'before\n'); + assert.ok(saved.TableSnapshot.Title, 'the table has a title'); + assert.deepStrictEqual(saved.TableSnapshot.Columns.map((c: { Header: string }) => c.Header), ['Name', 'Age']); + assert.equal(saved.TableSnapshot.Rows[1].Cells[0].DisplayText, 'Grace'); + assert.ok(saved.HtmlContent.includes('fry-tbl')); + assert.equal(saved.ExecutionCount, 1); + assert.match(saved.ExecutionTimeText, /ms$/); + + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + const reopened = await vscode.workspace.openNotebookDocument(vscode.Uri.file(file)); + assert.ok(outputsOf(reopened.cellAt(0)).some((output) => output.items.some((item) => item.mime === TABLE))); + } + ] + , + [ + 'a cell that prints 20,000 lines has one output with all of them', + async () => { + const notebook = await notebookOf('for (var i = 0; i < 20000; i++) Console.WriteLine("line " + i);'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true); + assert.equal(cell.outputs.length, 1, `${cell.outputs.length} outputs`); + const text = textOf(cell); + assert.ok(text.includes('line 0\n')); + assert.ok(text.trimEnd().endsWith('line 19999'), text.slice(-40)); + assert.equal(text.split('\n').filter(Boolean).length, 20000); + } + ], + + [ + 'text a cell prints, a table, and text again, are three outputs in that order (in the one output drawn as tabs)', + async () => { + const notebook = await notebookOf('Console.WriteLine("one");\nDisplay.Table(new[] { new { A = 1 } });\nConsole.WriteLine("two");'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.equal(isTabbed(cell), true); + assert.equal(cell.outputs.length, 1); + const kinds = outputsOf(cell).map((output) => (output.items.some((item) => item.mime === TABLE) ? 'table' : decode(output.items[0].data).trim())); + assert.deepStrictEqual(kinds, ['one', 'table', 'two']); + } + ], + + [ + 'a cell with text and a table is drawn as tabs: Table with its rows, Console with its lines, and All', + async () => { + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookOf('Console.WriteLine("one");\nConsole.WriteLine("two");\nDisplay.Table(new[] { new { A = 1 }, new { A = 2 }, new { A = 3 } }, "Numbers");'); + const before = api.rendererReports.length; + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + await waitFor('the tabs to be drawn', () => api.rendererReports.slice(before).some((r) => r.mime === 'application/vnd.fry.cell-output+json' && r.type === 'rendered'), 30_000); + const report = api.rendererReports.slice(before).filter((r) => r.mime === 'application/vnd.fry.cell-output+json').pop()!; + assert.deepStrictEqual(report.tabs, ['Table', 'Console', 'All']); + assert.equal(report.selected, 'Table'); + assert.deepStrictEqual(api.rendererReports.slice(before).filter((r) => r.type === 'error'), []); + } + ], + + [ + 'a cell that makes a second kind of output while it runs becomes tabs, and the text printed after is in them', + async () => { + const notebook = await notebookOf('Console.WriteLine("start");\nDisplay.Table(new[] { new { A = 1 } });\nSystem.Threading.Thread.Sleep(700);\nConsole.WriteLine("after");'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.equal(isTabbed(cell), true); + assert.equal(cell.outputs.length, 1); + const kinds = outputsOf(cell).map((output) => (output.items.some((item) => item.mime === TABLE) ? 'table' : decode(output.items[0].data).trim())); + assert.deepStrictEqual(kinds, ['start', 'table', 'after']); + } + ], + + [ + 'with the tabs off, the same cell has its outputs one after another, as before', + async () => { + const settings = vscode.workspace.getConfiguration('frysharp.notebook'); + await settings.update('outputTabs', false, vscode.ConfigurationTarget.Global); + try { + const notebook = await notebookOf('Console.WriteLine("one");\nDisplay.Table(new[] { new { A = 1 } });\nConsole.WriteLine("two");'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.equal(isTabbed(cell), false); + assert.equal(cell.outputs.length, 3, `${cell.outputs.length} outputs`); + } finally { + await settings.update('outputTabs', undefined, vscode.ConfigurationTarget.Global); + } + } + ], + + [ + 'a cell that fails shows why as VS Code shows an error, after what it printed, and the file does not keep that line', + async () => { + const file = path.join(workspace, 'failed.frynb'); + fs.copyFileSync(path.join(workspace, 'run.frynb'), file); + const notebook = await open('failed.frynb'); + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(0, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'Console.WriteLine("before");\nthrow new InvalidOperationException("boom");', 'csharp')])]); + await vscode.workspace.applyEdit(edit); + + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, false); + const last = cell.outputs[cell.outputs.length - 1]; + assert.equal(last.items[0].mime, 'application/vnd.code.notebook.error'); + const error = JSON.parse(decode(last.items[0].data)); + assert.equal(error.name, 'InvalidOperationException'); + assert.match(error.message, /boom/); + assert.match(textOf(cell), /before/); + assert.equal(isTabbed(cell), false); // printed text and an error: one kind of output and the error + + await save(notebook); + const saved = JSON.parse(fs.readFileSync(file, 'utf8')).Cells[0]; + assert.equal(saved.HasError, true); + assert.ok(!JSON.stringify(saved).includes('fryEngineError'), 'the error line is not in the file'); + assert.equal((saved.OutputText.match(/boom/g) ?? []).length, 1, `the message is in the cell's text once: ${saved.OutputText}`); + } + ], + + [ + 'a cell with text and a table that then fails is tabs followed by the error', + async () => { + const notebook = await notebookOf('Console.WriteLine("one");\nDisplay.Table(new[] { new { A = 1 } });\nthrow new InvalidOperationException("late");'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, false); + assert.equal(cell.outputs.length, 2, `${cell.outputs.length} outputs`); + assert.equal(cell.outputs[0].items[0].mime, 'application/vnd.fry.cell-output+json'); + assert.equal(cell.outputs[1].items[0].mime, 'application/vnd.code.notebook.error'); + } + ], + + [ + 'a cell that is stopped is not shown as an error', + async () => { + const notebook = await notebookIn('python', 'import time\nprint("going", flush=True)\ntime.sleep(60)'); + const cell = notebook.cellAt(0); + startRun(notebook, 0, 1); + await waitFor('the cell to print', () => textOf(cell).includes('going'), 60_000); + await vscode.commands.executeCommand('notebook.cell.cancelExecution', { ranges: [{ start: 0, end: 1 }], document: notebook.uri }); + await waitFor('the cell to stop', () => cell.executionSummary?.success !== undefined, 30_000); + + assert.ok(!cell.outputs.some((output) => output.items.some((item) => item.mime === 'application/vnd.code.notebook.error')), 'no error output for Stop'); + } + ], + + [ + 'FrySharp: Show Engine Info opens a page with the engine, its runtime and the tool each language found', + async () => { + await vscode.commands.executeCommand('frysharp.showEngineInfo'); + await waitFor('the page', () => vscode.workspace.textDocuments.some((d) => d.getText().startsWith('# FrySharp engine info')), 60_000); + + const page = vscode.workspace.textDocuments.find((d) => d.getText().startsWith('# FrySharp engine info'))!.getText(); + assert.match(page, /\| Engine \| \d+\.\d+\.\d+ \(protocol 1\)/); + assert.match(page, /\| Runtime \| \.NET /); + assert.match(page, /\| C# \| ✓ found/); + assert.match(page, /\| (Python|Java) \| /); + assert.match(page, /## Languages/); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + } + ], + + [ + 'a notebook saved with tabs is the same file as one saved without: the file holds the outputs, not the tabs', + async () => { + const file = path.join(workspace, 'tabs.frynb'); + const notebook = await open('run.frynb'); + fs.copyFileSync(path.join(workspace, 'run.frynb'), file); + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(0, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'Console.WriteLine("before");\nDisplay.Table(new[] { new { Name = "Ada" } }, "People");', 'csharp')])]); + await vscode.workspace.applyEdit(edit); + const cell = await runCell(notebook, 0); + assert.equal(isTabbed(cell), true); + await save(notebook); + + const saved = JSON.parse(fs.readFileSync(path.join(workspace, 'run.frynb'), 'utf8')).Cells[0]; + assert.equal(saved.OutputText, 'before\n'); + assert.equal(saved.TableSnapshot.Columns[0].Header, 'Name'); + assert.equal(JSON.stringify(saved).includes('vnd.fry.cell-output'), false, 'the tabs are not written to the file'); + } + ], + + [ + 'a cell that reads a line asks in an input box; closing it is the end of input, and the cell goes on', + async () => { + const notebook = await notebookOf('var line = Console.ReadLine();\nConsole.WriteLine(line == null ? "end of input" : "got " + line);'); + await vscode.window.showNotebookDocument(notebook); + await vscode.commands.executeCommand('notebook.selectKernel', { id: 'frysharp-kernel-frysharp-notebook', extension: EXTENSION }); + const cell = notebook.cellAt(0); + startRun(notebook, 0, 1); + + await new Promise((resolve) => setTimeout(resolve, 3000)); // the box is open by now: the cell has not finished + assert.equal(cell.executionSummary?.success, undefined, 'the cell is waiting for its answer'); + await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); + await waitFor('the cell to finish', () => cell.executionSummary?.success !== undefined); + + assert.equal(cell.executionSummary?.success, true); + assert.match(textOf(cell), /end of input/); + } + ], + + [ + 'a compile error is marked in the cell, and the mark goes when the code changes', + async () => { + const notebook = await notebookOf('int ok = 1;\nint bad = ;'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, false); + const marks = vscode.languages.getDiagnostics(cell.document.uri); + assert.equal(marks.length, 1); + assert.equal(marks[0].range.start.line, 1); + assert.equal(marks[0].source, 'FrySharp'); + assert.match(String(marks[0].code), /^CS/); + assert.match(textOf(cell), /CS\d+/, 'the error is also in the cell\'s output'); + + const edit = new vscode.WorkspaceEdit(); + edit.insert(cell.document.uri, new vscode.Position(1, 0), '// fixed\n'); + await vscode.workspace.applyEdit(edit); + await waitFor('the mark to go', () => vscode.languages.getDiagnostics(cell.document.uri).length === 0, 5000); + } + ], + + [ + 'Run All stops at the cell that fails, and the cells after it are not run', + async () => { + const notebook = await notebookOf('Console.WriteLine("first");', 'throw new System.Exception("stop here");', 'Console.WriteLine("never");'); + startRun(notebook, 0, 3); + await waitFor('the failing cell', () => notebook.cellAt(1).executionSummary?.success === false); + await new Promise((resolve) => setTimeout(resolve, 1500)); + + assert.equal(notebook.cellAt(0).executionSummary?.success, true); + assert.equal(notebook.cellAt(2).executionSummary?.success, undefined, 'the third cell was not run'); + assert.deepStrictEqual(notebook.cellAt(2).outputs, []); + } + ], + + [ + 'Stop ends a long Python cell at once, and the notebook runs on with the variables it had', + async () => { + // (a C# cell blocked in a call cannot be interrupted: the engine gives up on it after 3 s and the kernel stays busy until it returns) + const notebook = await notebookIn('python', 'kept = 5', 'import time\ntime.sleep(60)', 'print(kept + 1)'); + await runCell(notebook, 0); + + const long = notebook.cellAt(1); + startRun(notebook, 1, 2); + await new Promise((resolve) => setTimeout(resolve, 2500)); + assert.equal(long.executionSummary?.success, undefined, 'the long cell is running'); + + const stopped = Date.now(); + await vscode.commands.executeCommand('notebook.cell.cancelExecution', { ranges: [{ start: 1, end: 2 }], document: notebook.uri }); + await waitFor('the long cell to end', () => long.executionSummary?.success !== undefined, 20_000); + assert.ok(Date.now() - stopped < 8_000, `it ended ${Date.now() - stopped} ms after Stop`); + assert.equal(long.executionSummary?.success, false); + + const next = await runCell(notebook, 2); + assert.equal(next.executionSummary?.success, true, textOf(next)); + assert.match(textOf(next), /6/); + } + ], + + [ + 'a chart the program redraws is one output, redrawn', + async () => { + const notebook = await notebookOf('var h = Display.LineChart(new[] { 1.0, 2.0, 3.0 }, "Before");\nh.Update(s => s.Title = "After");'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.equal(cell.outputs.length, 1, `${cell.outputs.length} outputs`); + const page = decode(cell.outputs[0].items.find((item) => item.mime === 'text/html')!.data); + assert.ok(page.includes('After')); + assert.ok(!page.includes('Before')); + } + ] + , + [ + 'the Variables view lists what the notebook defined, and its status says the engine is ready', + async () => { + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookOf('int count = 42;\nstring name = "Ada";'); + const cell = await runCell(notebook, 0); + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + + const kernels = await api.variables.getChildren(); + assert.deepStrictEqual(kernels.map((node) => api.variables.getTreeItem(node).label), ['C#']); + const variables = await api.variables.getChildren(kernels[0]); + const items = variables.map((node) => api.variables.getTreeItem(node)); + assert.deepStrictEqual(items.map((item) => item.label).sort(), ['count', 'name']); + assert.match(String(items.find((item) => item.label === 'count')?.description), /42/); + + assert.match(api.statusText(), /ready/); + } + ], + + [ + 'the status says a cell is running while it runs', + async () => { + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookIn('python', 'import time\ntime.sleep(60)'); + startRun(notebook, 0, 1); + await waitFor('the status to say running', () => /running/.test(api.statusText()), 15_000); + await vscode.commands.executeCommand('notebook.cell.cancelExecution', { ranges: [{ start: 0, end: 1 }], document: notebook.uri }); + await waitFor('the status to say ready again', () => /ready/.test(api.statusText()), 15_000); + } + ] + , + [ + 'the output renderer draws an object, a table and an ECharts chart, and the chart really drew', + async () => { + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookOf( + 'class P { public string Name = "Code"; public int[] Numbers = { 1, 2, 3 }; }\nnew P()', + 'Display.Table(new[] { new { Name = "Ada", Age = 36 }, new { Name = "Grace", Age = 45 } });', + 'EChart.Line(new[] { 3.0, 5.0, 2.0, 8.0 }, "Sales").Title("Sales").Show();' + ); + const before = api.rendererReports.length; + for (let i = 0; i < 3; i++) assert.equal((await runCell(notebook, i)).executionSummary?.success, true); + + await waitFor('the three outputs to be drawn', () => api.rendererReports.slice(before).filter((r) => r.type === 'rendered').length >= 3, 30_000); + const reports = api.rendererReports.slice(before); + assert.deepStrictEqual(reports.filter((r) => r.type === 'error'), []); + const mimes = reports.map((r) => r.mime); + assert.ok(mimes.includes('application/vnd.fry.inspector+json'), mimes.join()); + assert.ok(mimes.includes('application/vnd.fry.table+json'), mimes.join()); + const chart = reports.find((r) => r.mime === 'text/html'); + assert.ok(chart, 'the chart page was drawn by the renderer'); + assert.equal(chart.chartDrawn, true, 'ECharts drew its chart inside the frame'); + } + ] + , + // Not a check: runs cells of every kind and leaves the window open (FRY_IT_PAUSE_MS) to be looked at. + ...(process.env.FRY_IT_PAUSE_MS + ? ([ + [ + 'show every kind of output (FRY_IT_PAUSE_MS)', + async () => { + await vscode.commands.executeCommand('frysharp.newNotebook'); + const notebook = vscode.window.activeNotebookEditor?.notebook as vscode.NotebookDocument; + const code = (source: string) => new vscode.NotebookCellData(vscode.NotebookCellKind.Code, source, 'csharp'); + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [ + vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(0, notebook.cellCount), [ + code('public class People { public string Name { get; set; } public int[] Numbers { get; set; } public People Another { get; set; } }\nvar people = new People { Name = "Code", Numbers = new[] { 1, 34, 45 } };\npeople.Another = people;\npeople'), + code('Display.Table(new[] { new { Name = "Ada", Age = 36, Active = true }, new { Name = "Grace", Age = 45, Active = false } }, "People");'), + code('Display.LineChart(new[] { 3.0, 1.0, 4.0, 1.0, 5.0 }, "Latency");') + ]) + ]); + await vscode.workspace.applyEdit(edit); + for (let i = 0; i < 3; i++) await runCell(notebook, i); + await new Promise((resolve) => setTimeout(resolve, Number(process.env.FRY_IT_PAUSE_MS))); + } + ] + ] as Array<[string, () => Promise]>) + : []) +]; diff --git a/vscode-frysharp/test/integration/suite/outputs.ts b/vscode-frysharp/test/integration/suite/outputs.ts new file mode 100644 index 00000000..2de1034a --- /dev/null +++ b/vscode-frysharp/test/integration/suite/outputs.ts @@ -0,0 +1,36 @@ +import type * as vscode from 'vscode'; +import { unpack } from '../../../src/notebook/format/tabs'; +import { CELL_OUTPUT_MIME } from '../../../src/shared/outputTabs'; + +// What a cell's output holds, as the tests ask about it. A cell that made several kinds of output has them as one output drawn as tabs +// (the setting frysharp.notebook.outputTabs, on unless turned off): these see the outputs it was made of, as a saved file has them. + +export interface PlainOutput { + items: Array<{ mime: string; data: Uint8Array }>; + metadata?: Record; +} + +const decode = (data: Uint8Array) => new TextDecoder().decode(data); + +/** The outputs of a cell, with a tabbed output as the outputs it holds. */ +export function outputsOf(cell: vscode.NotebookCell): PlainOutput[] { + return cell.outputs.flatMap((output) => unpack({ items: output.items.map((item) => ({ mime: item.mime, data: item.data })), metadata: output.metadata })); +} + +/** Whether the cell's output is the one output drawn as tabs. */ +export const isTabbed = (cell: vscode.NotebookCell): boolean => cell.outputs.some((output) => output.items.some((item) => item.mime === CELL_OUTPUT_MIME)); + +/** The items of a MIME type in a cell's outputs, read as JSON. */ +export const itemsOf = (cell: vscode.NotebookCell, mime: string): unknown[] => + outputsOf(cell) + .flatMap((output) => output.items) + .filter((item) => item.mime === mime) + .map((item) => JSON.parse(decode(item.data))); + +/** What the cell printed (its stdout and stderr items), in order. */ +export const textOfCell = (cell: vscode.NotebookCell): string => + outputsOf(cell) + .flatMap((output) => output.items) + .filter((item) => item.mime.startsWith('application/vnd.code.notebook.std')) + .map((item) => decode(item.data)) + .join(''); diff --git a/vscode-frysharp/test/integration/suite/rust.ts b/vscode-frysharp/test/integration/suite/rust.ts new file mode 100644 index 00000000..fb9805e6 --- /dev/null +++ b/vscode-frysharp/test/integration/suite/rust.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; +import { notebookIn, notebookWith, runCell, startRun, waitFor } from './notebooks'; +import { itemsOf as items, textOfCell as textOf } from './outputs'; +import { needsCargo } from './skip'; + +// Rust cells and files, built and run by the real Cargo through the real engine, in a real VS Code. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; +const TABLE = 'application/vnd.fry.table+json'; +const INSPECTOR = 'application/vnd.fry.inspector+json'; + +export const rustTests: Array<[string, () => Promise]> = [ + [ + 'a Rust cell keeps what it makes, shows a list of structs as a table and a struct as the Object Explorer', + async () => { + needsCargo('FRY_REQUIRE_RUST'); + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookIn( + 'rust', + '#[derive(Debug)]\nstruct Person { name: String, age: u32 }\nlet total = 41;', + 'vec![Person { name: "Ada".into(), age: 36 }, Person { name: "Linus".into(), age: 55 }]', + 'Person { name: "Grace".into(), age: 85 }', + 'total + 1' + ); + await runCell(notebook, 0); + const list = await runCell(notebook, 1); + const one = await runCell(notebook, 2); + const sum = await runCell(notebook, 3); + + assert.equal(list.executionSummary?.success, true, textOf(list)); + const [table] = items(list, TABLE) as Array<{ columns: string[]; numeric: boolean[]; rows: string[][] }>; + assert.deepStrictEqual(table.columns, ['name', 'age']); + assert.deepStrictEqual(table.numeric, [false, true]); + assert.deepStrictEqual(table.rows, [['Ada', '36'], ['Linus', '55']]); + const [tree] = items(one, INSPECTOR) as Array<{ title: string }>; + assert.equal(tree.title, 'Person'); + assert.equal(sum.executionSummary?.success, true, textOf(sum)); + assert.match(textOf(sum), /42/); + + const kernels = await api.variables.getChildren(); + const rust = kernels.find((node) => /Rust/i.test(String(api.variables.getTreeItem(node).label))); + assert.ok(rust, 'the Rust kernel is listed'); + const names = (await api.variables.getChildren(rust)).map((node) => api.variables.getTreeItem(node).label); + assert.ok(names.includes('total'), JSON.stringify(names)); + } + ], + [ + 'a Rust compile error and a panic are marked on the line of the cell they are about, and the text names the cell', + async () => { + needsCargo('FRY_REQUIRE_RUST'); + const notebook = await notebookIn('rust', 'let a = 1;\n\nlet s: String = 5;\nlet c = a;', 'let v: Vec = Vec::new();\n\nprintln!("{}", v[3]);'); + const compile = await runCell(notebook, 0); + const panic = await runCell(notebook, 1); + + assert.equal(compile.executionSummary?.success, false); + const marks = vscode.languages.getDiagnostics(compile.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(marks.length, 1); + assert.equal(marks[0].range.start.line, 2); + assert.equal(marks[0].range.start.character, 16); + assert.match(marks[0].message, /mismatched types/); + assert.match(textOf(compile), /--> cell:3:17/); + + assert.equal(panic.executionSummary?.success, false); + const thrown = vscode.languages.getDiagnostics(panic.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(thrown.length, 1); + assert.equal(thrown[0].range.start.line, 2); + assert.match(textOf(panic), /panicked at cell:3:/); + } + ], + [ + 'fry::input in a Rust cell asks in an input box; closing it is the end of input, and the cell goes on', + async () => { + needsCargo('FRY_REQUIRE_RUST'); + const notebook = await notebookIn('rust', 'let line = fry::input("Name? ");\nprintln!("{}", match line { Some(l) => format!("got {}", l), None => "end of input".to_string() });'); + await vscode.window.showNotebookDocument(notebook); + await vscode.commands.executeCommand('notebook.selectKernel', { id: 'frysharp-kernel-frysharp-notebook', extension: EXTENSION }); + const cell = notebook.cellAt(0); + startRun(notebook, 0, 1); + + await new Promise((resolve) => setTimeout(resolve, 8000)); // the box is open by now (the first build is slower): the cell has not finished + assert.equal(cell.executionSummary?.success, undefined, 'the cell is waiting for its answer'); + await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); + await waitFor('the cell to finish', () => cell.executionSummary?.success !== undefined, 90_000); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.match(textOf(cell), /end of input/); + } + ], + [ + 'a Rust cell can take a list a C# cell made, with #!share, and its own values go the other way', + async () => { + needsCargo('FRY_REQUIRE_RUST'); + const notebook = await notebookWith( + ['csharp', 'var rows = new[] { 3, 4, 5 };'], + ['rust', '#!share --from csharp rows\nprintln!("{} rows, sum {}", rows.len(), rows.iter().sum::());'], + ['rust', 'let squares = vec![1, 4, 9, 16];\nfry::share!(squares);'], + ['csharp', '#!share --from rust squares\nConsole.WriteLine(string.Join(",", squares));'] + ); + await runCell(notebook, 0); + const inRust = await runCell(notebook, 1); + await runCell(notebook, 2); + const back = await runCell(notebook, 3); + + assert.equal(inRust.executionSummary?.success, true, textOf(inRust)); + assert.match(textOf(inRust), /3 rows, sum 12/); + assert.equal(back.executionSummary?.success, true, textOf(back)); + assert.match(textOf(back), /1,4,9,16/); + } + ], + [ + 'Stop ends a Rust cell that is running, and what the notebook made before is still there', + async () => { + needsCargo('FRY_REQUIRE_RUST'); + const notebook = await notebookIn('rust', 'let kept = 41;', 'println!("started");\nloop { std::thread::sleep(std::time::Duration::from_millis(50)); }', 'kept + 1'); + await runCell(notebook, 0); + const running = notebook.cellAt(1); + startRun(notebook, 1, 2); + await waitFor('the program to start', () => textOf(running).includes('started'), 90_000); + await vscode.commands.executeCommand('notebook.cell.cancelExecution', { ranges: [{ start: 1, end: 2 }], document: notebook.uri }); + await waitFor('the cell to stop', () => running.executionSummary?.success !== undefined, 30_000); + + const after = await runCell(notebook, 2); + assert.equal(after.executionSummary?.success, true, textOf(after)); + assert.match(textOf(after), /42/); + } + ], + [ + 'Run with FrySharp runs a .rs file in the terminal, asks for input with fry::input, and a compile error is a problem at its line', + async () => { + needsCargo('FRY_REQUIRE_RUST'); + const { scripts } = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const asks = vscode.Uri.file(path.join(workspace, 'asks.rs')); + fs.writeFileSync(asks.fsPath, 'fn main() {\n let name = fry::input("Name? ").unwrap_or_default();\n println!("Hello, {}", name);\n}\n'); + + await scripts.run(asks); + const ask = scripts.terminalFor(asks)!; + await waitFor('the prompt', () => ask.transcript.includes('Name? '), 120_000); + ask.handleInput('Ada'); + ask.handleInput('\r'); + await waitFor('the run to end', () => /exited with code/.test(ask.transcript), 30_000); + assert.match(ask.transcript, /Hello, Ada/); + assert.match(ask.transcript, /exited with code 0/); + + const broken = vscode.Uri.file(path.join(workspace, 'broken.rs')); + fs.writeFileSync(broken.fsPath, 'fn main() {\n let total: i32 = 1;\n let bad: i32 = "x";\n println!("{} {}", total, bad);\n}\n'); + await scripts.run(broken); + const run = scripts.terminalFor(broken)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript), 90_000); + + assert.match(run.transcript, /exited with code 1/); + await waitFor('the problem', () => vscode.languages.getDiagnostics(broken).some((d) => d.range.start.line === 2), 10_000); + } + ] +]; diff --git a/vscode-frysharp/test/integration/suite/scripts.ts b/vscode-frysharp/test/integration/suite/scripts.ts new file mode 100644 index 00000000..541fdbc5 --- /dev/null +++ b/vscode-frysharp/test/integration/suite/scripts.ts @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; + +// Script runs, in a real VS Code with the real engine: the terminal's text, input, Stop and Run Again, and what lands in +// the Problems panel and the Results panel. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; + +const api = async (): Promise => (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + +async function waitFor(what: string, condition: () => boolean, ms = 60_000): Promise { + const until = Date.now() + ms; + while (!condition()) { + if (Date.now() > until) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +function script(name: string, code: string): vscode.Uri { + const file = path.join(workspace, name); + fs.writeFileSync(file, code); + return vscode.Uri.file(file); +} + +// What the terminal shows when it is done: the text with backspaces, carriage returns and colours applied, as a screen does. +function screen(transcript: string): string[] { + const lines: string[][] = [[]]; + let row = lines[0]; + let column = 0; + for (let i = 0; i < transcript.length; i++) { + const c = transcript[i]; + if (c === '\x1b') { + // eslint-disable-next-line no-control-regex -- an escape sequence is what is being read + const sequence = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(transcript.slice(i)); + if (sequence) { + if (sequence[0] === '\x1b[2J') { + lines.length = 0; + lines.push((row = [])); + column = 0; + } + i += sequence[0].length - 1; + } + } else if (c === '\r') column = 0; + else if (c === '\n') lines.push((row = [])); + else if (c === '\b') column = Math.max(0, column - 1); + else row[column++] = c; + } + return lines.map((line) => Array.from(line, (ch) => ch ?? ' ').join('').trimEnd()); +} + +const finished = (run: { running: boolean }) => !run.running; + +export const scriptTests: Array<[string, () => Promise]> = [ + [ + 'a script runs in a terminal, with the program\'s own colours, and says how it ended', + async () => { + const { scripts } = await api(); + const file = script('colours.py', "print('\\x1b[32mgreen\\x1b[0m and plain')\n"); + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript)); + + assert.ok(run.transcript.includes('\x1b[32mgreen\x1b[0m and plain'), 'the colours are the program\'s, passed through'); + assert.match(run.transcript, /✓ exited with code 0/); + assert.match(run.transcript, /press any key to run again/); + assert.ok(finished(run)); + } + ], + + [ + 'a script that asks for input gets what is typed, and the typed line is on the screen once', + async () => { + const { scripts } = await api(); + const file = script('ask.py', "name = input('Name? ')\nprint('Hello', name)\n"); + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the prompt', () => run.transcript.includes('Name? ')); + + run.handleInput('Ada'); + run.handleInput('\r'); + await waitFor('the run to end', () => /exited with code/.test(run.transcript)); + + const lines = screen(run.transcript); + assert.ok(lines.includes('Name? Ada'), JSON.stringify(lines)); + assert.ok(lines.includes('Hello Ada'), JSON.stringify(lines)); + } + ], + + [ + 'Ctrl+C stops a running script, and any key runs it again from a clean screen', + async () => { + const { scripts } = await api(); + const file = script('wait.py', "print('started', flush=True)\nimport time\ntime.sleep(60)\n"); + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('it to start', () => run.transcript.includes('started')); + assert.ok(run.running); + + const stopped = Date.now(); + run.handleInput('\x03'); + await waitFor('it to stop', () => /exited with code/.test(run.transcript), 20_000); + assert.ok(Date.now() - stopped < 10_000); + assert.match(run.transcript, /✗ exited with code/); + assert.ok(!run.running); + + run.handleInput('x'); // any key + await waitFor('it to run again', () => run.running && screen(run.transcript).filter((line) => line === 'started').length >= 1); + assert.equal(screen(run.transcript).filter((line) => line.includes('exited with code')).length, 0, 'the screen was cleared'); + run.handleInput('\x03'); + await waitFor('it to stop again', () => !run.running, 20_000); + } + ], + + [ + 'a script that fails shows its error as links, and puts it in the Problems panel', + async () => { + const { scripts } = await api(); + const file = script('boom.py', "x = 1\nprint(x / 0)\n"); + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript)); + + assert.match(run.transcript, /ZeroDivisionError/); + assert.match(run.transcript, /✗ exited with code [1-9]/); + await waitFor('a problem', () => vscode.languages.getDiagnostics(file).length > 0, 10_000); + assert.equal(vscode.languages.getDiagnostics(file)[0].range.start.line, 1); + } + ], + + [ + 'a script that shows a table puts it in the Results panel and says so in the terminal', + async () => { + const { scripts, results } = await api(); + results.clear(); + const file = script('table.csx', 'Console.WriteLine("before");\nDisplay.Table(new[] { new { Name = "Ada", Age = 36 } });\n'); + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript)); + + assert.match(run.transcript, /Shown in FrySharp Results/); + assert.equal(results.count, 1); + // the panel opened and its page drew the table with the same renderer as a notebook + await waitFor('the Results page to draw the table', () => results.reports.some((r) => r.type === 'rendered' && r.mime === 'application/vnd.fry.table+json'), 20_000); + assert.deepStrictEqual(results.reports.filter((r) => r.type === 'error'), []); + assert.ok(screen(run.transcript).includes('before')); + } + ], + + [ + 'a C# script with a compile error is marked at its line in the Problems panel', + async () => { + const { scripts } = await api(); + const file = script('bad.csx', 'int ok = 1;\nint bad = ;\n'); + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript)); + + assert.match(run.transcript, /✗ exited with code/); + await waitFor('a problem', () => vscode.languages.getDiagnostics(file).length > 0, 10_000); + const problem = vscode.languages.getDiagnostics(file)[0]; + assert.equal(problem.range.start.line, 1); + assert.match(String(problem.code), /^CS/); + + // running it again with the error fixed clears the mark + fs.writeFileSync(file.fsPath, 'int ok = 1;\n'); + await scripts.run(file); + await waitFor('the run to end', () => /✓ exited with code 0/.test(run.transcript)); + assert.equal(vscode.languages.getDiagnostics(file).length, 0); + } + ], + + [ + 'a file the engine cannot run is refused with what it can run', + async () => { + const { scripts } = await api(); + const file = script('notes.txt', 'not a program'); + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the message', () => /does not run/.test(run.transcript)); + + assert.match(run.transcript, /runs .*\.py/); + assert.ok(!run.running); + } + ] + , + [ + 'the scratchpad runs a selection from a file, and keeps what it defined for the next one', + async () => { + const first = script('pad1.py', "total = 40\nprint('pad says', total + 2)\n"); + const document = await vscode.workspace.openTextDocument(first); + const editor = await vscode.window.showTextDocument(document); + editor.selection = new vscode.Selection(0, 0, 1, 0); // the first line: total = 40 + await vscode.commands.executeCommand('frysharp.sendToScratchpad'); + const pad = vscode.window.activeNotebookEditor!.notebook; + await waitFor('the first cell to run', () => pad.cellCount >= 1 && pad.cellAt(0).executionSummary?.success === true); + assert.equal(pad.cellAt(0).document.getText().trim(), 'total = 40'); + + const editor2 = await vscode.window.showTextDocument(document); + editor2.selection = new vscode.Selection(1, 0, 1, document.lineAt(1).text.length); // print('pad says', total + 2) + await vscode.commands.executeCommand('frysharp.sendToScratchpad'); + await waitFor('the second cell to run', () => pad.cellCount >= 2 && pad.cellAt(1).executionSummary?.success === true); + + const text = pad.cellAt(1).outputs.flatMap((o) => o.items).map((i) => new TextDecoder().decode(i.data)).join(''); + assert.match(text, /pad says 42/); + assert.equal(pad.cellAt(1).document.languageId, 'python'); + } + ], + + [ + 'the Run lens is above a script only when the setting is on, and says Stop while it runs', + async () => { + const file = script('lens.py', "import time\nprint('lens', flush=True)\ntime.sleep(60)\n"); + const document = await vscode.workspace.openTextDocument(file); + const lensesOf = async () => ((await vscode.commands.executeCommand('vscode.executeCodeLensProvider', file)) ?? []).filter((l) => /FrySharp|Running|Stop/.test(l.command?.title ?? '')); + const settings = vscode.workspace.getConfiguration('frysharp.codeLens'); + + assert.deepStrictEqual(await lensesOf(), [], 'off by default'); + await settings.update('enabled', true, vscode.ConfigurationTarget.Global); + try { + for (const until = Date.now() + 30_000; Date.now() < until && !(await lensesOf()).some((l) => l.command?.command === 'frysharp.runScript'); ) await new Promise((resolve) => setTimeout(resolve, 100)); + const lenses = await lensesOf(); + assert.equal(lenses.length, 1, JSON.stringify(lenses.map((l) => l.command?.title))); + assert.equal(lenses[0].command?.command, 'frysharp.runScript'); + assert.equal(lenses[0].range.start.line, 0); + + const { scripts } = await api(); + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand(lenses[0].command!.command, ...(lenses[0].command!.arguments ?? [])); + const run = scripts.terminalFor(file)!; + await waitFor('the program to print', () => run.transcript.includes('lens')); + const whileRunning = await lensesOf(); + assert.ok(whileRunning.some((l) => l.command?.command === 'frysharp.stopRun'), JSON.stringify(whileRunning.map((l) => l.command?.title))); + await vscode.commands.executeCommand('frysharp.stopRun'); + await waitFor('the run to end', () => /exited with code/.test(run.transcript), 20_000); + } finally { + await settings.update('enabled', undefined, vscode.ConfigurationTarget.Global); + } + } + ] +]; diff --git a/vscode-frysharp/test/integration/suite/skip.ts b/vscode-frysharp/test/integration/suite/skip.ts new file mode 100644 index 00000000..a82bc86a --- /dev/null +++ b/vscode-frysharp/test/integration/suite/skip.ts @@ -0,0 +1,30 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +/** Thrown by a test that cannot run on this machine (a tool it needs is not installed): reported as skipped, not as passed. */ +export class SkipTest extends Error {} + +/** True when `program` is on the PATH. */ +export function onPath(program: string): boolean { + const names = process.platform === 'win32' ? [`${program}.exe`, `${program}.cmd`] : [program]; + return (process.env.PATH ?? '') + .split(path.delimiter) + .filter(Boolean) + .some((folder) => names.some((name) => fs.existsSync(path.join(folder, name)))); +} + +/** Skips the test without `program`, unless `required` names an environment variable set to 1, which makes its absence a failure. */ +export function needs(program: string, required: string): void { + if (process.env[required] === '1') return; + if (!onPath(program)) throw new SkipTest(`needs ${program} on the PATH (set ${required}=1 to make this a failure)`); +} + +/** Like {@link needs} for Rust's `cargo`, which rustup puts in `~/.cargo/bin` (on the PATH of a login shell, not always of a test host). */ +export function needsCargo(required: string): void { + if (process.env[required] === '1') return; + const cargoHome = process.env.CARGO_HOME ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.cargo'); + const name = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; + if (!onPath('cargo') && !fs.existsSync(path.join(cargoHome, 'bin', name))) { + throw new SkipTest(`needs cargo on the PATH or in ${path.join(cargoHome, 'bin')} (set ${required}=1 to make this a failure)`); + } +} diff --git a/vscode-frysharp/test/integration/suite/sql.ts b/vscode-frysharp/test/integration/suite/sql.ts new file mode 100644 index 00000000..f2b93f21 --- /dev/null +++ b/vscode-frysharp/test/integration/suite/sql.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { FrySharpApi } from '../../../src/extension'; +import { notebookIn, notebookWith, runCell, waitFor } from './notebooks'; +import { itemsOf, textOfCell as textOf } from './outputs'; +import { needs } from './skip'; + +// SQL cells and files, run by the real sqlite3 through the real engine, in a real VS Code. + +const workspace = process.env.FRY_IT_WORKSPACE as string; +const EXTENSION = 'CodeFryDev.frysharp'; +const TABLE = 'application/vnd.fry.table+json'; + +const tablesOf = (cell: vscode.NotebookCell) => itemsOf(cell, TABLE) as Array<{ columns: string[]; numeric: boolean[]; rows: string[][] }>; + +export const sqlTests: Array<[string, () => Promise]> = [ + [ + 'a SQL cell shows its rows as a table (not as text), and the notebook\'s tables are its variables', + async () => { + needs('sqlite3', 'FRY_REQUIRE_SQL'); + const api = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const notebook = await notebookIn( + 'sql', + "create table people (id integer primary key, name text, age integer);\ninsert into people (name, age) values ('Ada', 36), ('Linus', 55);\nselect id, name, age from people order by id;" + ); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + const [table, ...more] = tablesOf(cell); + assert.deepStrictEqual(more, []); + assert.deepStrictEqual(table.columns, ['id', 'name', 'age']); + assert.deepStrictEqual(table.numeric, [true, false, true], 'numbers are right-aligned and sort as numbers'); + assert.deepStrictEqual(table.rows, [['1', 'Ada', '36'], ['2', 'Linus', '55']]); + assert.equal(textOf(cell), '', 'no text table next to the real one'); + + const kernels = await api.variables.getChildren(); + assert.ok(kernels.some((node) => api.variables.getTreeItem(node).label === 'SQL'), 'the SQL kernel is listed'); + const sql = kernels.find((node) => api.variables.getTreeItem(node).label === 'SQL')!; + const tables = (await api.variables.getChildren(sql)).map((node) => api.variables.getTreeItem(node).label); + assert.deepStrictEqual(tables, ['people']); + } + ], + [ + 'a SQL error is marked on the line of the cell where it is, and says what is wrong', + async () => { + needs('sqlite3', 'FRY_REQUIRE_SQL'); + const notebook = await notebookIn('sql', 'select 1 as fine;\nselect * from nosuch;'); + const cell = await runCell(notebook, 0); + + assert.equal(cell.executionSummary?.success, false); + const marks = vscode.languages.getDiagnostics(cell.document.uri).filter((d) => d.source === 'FrySharp'); + assert.equal(marks.length, 1); + assert.equal(marks[0].range.start.line, 1); + assert.match(marks[0].message, /no such table/); + assert.equal(tablesOf(cell).length, 1, 'the statement before the error still shows its rows'); + } + ], + [ + 'a SQL cell can query a list a C# cell made, with #!share', + async () => { + needs('sqlite3', 'FRY_REQUIRE_SQL'); + const notebook = await notebookWith( + ['csharp', 'var rows = new[] { new { Name = "Ada", Age = 36 }, new { Name = "Linus", Age = 55 } };'], + ['sql', '#!share --from csharp rows\nselect Name from rows where Age > 40;'] + ); + await runCell(notebook, 0); + const cell = await runCell(notebook, 1); + + assert.equal(cell.executionSummary?.success, true, textOf(cell)); + assert.deepStrictEqual(tablesOf(cell).map((t) => t.rows), [[['Linus']]]); + } + ], + [ + 'Run with FrySharp runs a .sql file in the terminal, and its error is a link and a problem', + async () => { + needs('sqlite3', 'FRY_REQUIRE_SQL'); + const { scripts } = (await vscode.extensions.getExtension(EXTENSION)!.activate()) as FrySharpApi; + const file = vscode.Uri.file(path.join(workspace, 'report.sql')); + fs.writeFileSync(file.fsPath, "create table t (name text);\ninsert into t values ('Ada');\nselect name from t;\nselect * from nosuch;\n"); + + await scripts.run(file); + const run = scripts.terminalFor(file)!; + await waitFor('the run to end', () => /exited with code/.test(run.transcript)); + + assert.match(run.transcript, /\| Ada\s+\|/, 'the rows, as sqlite3 shows them'); + assert.match(run.transcript, /no such table/); + assert.match(run.transcript, /exited with code 1/); + await waitFor('the problem', () => vscode.languages.getDiagnostics(file).some((d) => /no such table/.test(d.message)), 10_000); + const problem = vscode.languages.getDiagnostics(file).find((d) => /no such table/.test(d.message))!; + assert.equal(problem.range.start.line, 3); + } + ] +]; diff --git a/vscode-frysharp/test/tsconfig.json b/vscode-frysharp/test/tsconfig.json new file mode 100644 index 00000000..198e62ca --- /dev/null +++ b/vscode-frysharp/test/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "outDir": "../out-test", + "sourceMap": false + }, + "include": [ + "./**/*.ts", + "../src/notebook/format/**/*.ts", + "../src/renderer/tableModel.ts", + "../src/renderer/inspectorModel.ts", + "../src/renderer/htmlPrepare.ts", + "../src/renderer/chooseMime.ts", + "../src/language/cellContext.ts", + "../src/language/directives.ts" + ] +} diff --git a/vscode-frysharp/test/unit/cellContext.test.ts b/vscode-frysharp/test/unit/cellContext.test.ts new file mode 100644 index 00000000..2550d428 --- /dev/null +++ b/vscode-frysharp/test/unit/cellContext.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { CellLike, contextFor, effectiveLanguage, resolverFor } from '../../src/language/cellContext'; + +const resolve = resolverFor([ + { id: 'csharp', aliases: ['c#', 'cs'], shortName: 'C#' }, + { id: 'python', aliases: ['py'] }, + { id: 'javascript', aliases: ['js', 'node'] } +]); +const code = (language: string, text: string): CellLike => ({ kind: 'code', language, text }); + +describe('the language a cell runs in', () => { + it('is its own, unless its first lines say another', () => { + assert.equal(effectiveLanguage('var a = 1;', 'csharp', resolve), 'csharp'); + assert.equal(effectiveLanguage('#!python\nprint(1)', 'csharp', resolve), 'python'); + assert.equal(effectiveLanguage('#!js\nconsole.log(1)', 'csharp', resolve), 'javascript'); + assert.equal(effectiveLanguage('#!C#\nvar a = 1;', 'python', resolve), 'csharp'); + }); + + it('lets blank lines, comments and #!share come before the directive, and ends at the first line of code', () => { + assert.equal(effectiveLanguage('\n// note\n#!share --from csharp n\n#!python\nx = 1', 'csharp', resolve), 'python'); + assert.equal(effectiveLanguage('var a = 1;\n#!python\nx = 1', 'csharp', resolve), 'csharp'); + }); + + it('does not take #!share, an attribute (#![...]) or a shebang for a language', () => { + assert.equal(effectiveLanguage('#!share --from csharp n', 'python', resolve), 'python'); + assert.equal(effectiveLanguage('#![allow(unused)]\nfn main() {}', 'rust', resolve), 'rust'); + assert.equal(effectiveLanguage('#!/usr/bin/env python\nprint(1)', 'python', resolve), 'python'); + }); + + it('keeps its own language for a directive that names none', () => { + assert.equal(effectiveLanguage('#!pyton\nx = 1', 'csharp', resolve), 'csharp'); + }); +}); + +describe('what the earlier cells give a cell', () => { + const cells: CellLike[] = [ + { kind: 'markdown', language: 'markdown', text: '# notes' }, + code('csharp', 'int a = 1;'), + code('python', 'import os'), + code('csharp', '#!python\nprint(1)'), + code('csharp', 'var b = a + 1;'), + code('csharp', 'b.') + ]; + + it('is the code cells before it that run in the same language, in order', () => { + assert.deepStrictEqual(contextFor(cells, 5, 'csharp', resolve), ['int a = 1;', 'var b = a + 1;']); + assert.deepStrictEqual(contextFor(cells, 5, 'python', resolve), ['import os', '#!python\nprint(1)']); + }); + + it('is nothing for the first cell, and never includes the cell itself or what follows', () => { + assert.deepStrictEqual(contextFor(cells, 0, 'csharp', resolve), []); + assert.deepStrictEqual(contextFor(cells, 2, 'csharp', resolve), ['int a = 1;']); + }); +}); diff --git a/vscode-frysharp/test/unit/cellDiagnostics.test.ts b/vscode-frysharp/test/unit/cellDiagnostics.test.ts new file mode 100644 index 00000000..4eb8bf82 --- /dev/null +++ b/vscode-frysharp/test/unit/cellDiagnostics.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { placeDiagnostics } from '../../src/notebook/cellDiagnostics'; + +const diagnostic = (startLine: number, startColumn: number, endLine = startLine, endColumn = startColumn + 1, severity = 'error') => ({ severity, code: 'X1', message: 'm', startLine, startColumn, endLine, endColumn }); + +describe('placing diagnostics in a cell', () => { + const text = 'int a = 1;\nint b = ;\nConsole.WriteLine(b);'; + + it('counts from 0, where the engine counts from 1', () => { + const [placed] = placeDiagnostics([diagnostic(2, 9, 2, 10)], text); + assert.deepStrictEqual([placed.startLine, placed.startCharacter, placed.endLine, placed.endCharacter], [1, 8, 1, 9]); + }); + + it('takes a column past the end of the line as the end of the line (a traceback names only the line)', () => { + const [placed] = placeDiagnostics([diagnostic(3, 1, 3, 2147483647)], text); + assert.deepStrictEqual([placed.startCharacter, placed.endCharacter], [0, 'Console.WriteLine(b);'.length]); + }); + + it('gives a range something to underline when the engine gave an empty one', () => { + const [placed] = placeDiagnostics([diagnostic(1, 5, 1, 5)], text); + assert.equal(placed.endCharacter - placed.startCharacter, 1); + }); + + it('stays inside the cell when the cell has changed since', () => { + const [placed] = placeDiagnostics([diagnostic(40, 10, 41, 99)], 'one line'); + assert.deepStrictEqual([placed.startLine, placed.startCharacter, placed.endLine, placed.endCharacter], [0, 8, 0, 8]); + }); + + it('says the severity as an editor does, and keeps the code and message', () => { + const placed = placeDiagnostics([diagnostic(1, 1, 1, 2, 'warning'), diagnostic(1, 1, 1, 2, 'info'), diagnostic(1, 1)], text); + assert.deepStrictEqual(placed.map((p) => p.severity), ['warning', 'information', 'error']); + assert.equal(placed[0].code, 'X1'); + }); + + it('is empty when there is nothing to place', () => { + assert.deepStrictEqual(placeDiagnostics(undefined, text), []); + assert.deepStrictEqual(placeDiagnostics([], text), []); + }); +}); diff --git a/vscode-frysharp/test/unit/cellStatus.test.ts b/vscode-frysharp/test/unit/cellStatus.test.ts new file mode 100644 index 00000000..669e88bc --- /dev/null +++ b/vscode-frysharp/test/unit/cellStatus.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { languageNote, StatusLanguage } from '../../src/notebook/cellStatus'; +import { offersLens } from '../../src/run/lensRules'; + +const languages: StatusLanguage[] = [ + { id: 'csharp', displayName: 'C#', aliases: ['c#', 'cs'], shortName: 'CS', canRunCells: true }, + { id: 'python', displayName: 'Python', aliases: ['py'], shortName: 'PY', canRunCells: true }, + { id: 'javascript', displayName: 'JavaScript', aliases: ['js', 'node'], shortName: 'JS', canRunCells: true } +]; + +test('a cell that runs as itself has nothing to say', () => { + assert.equal(languageNote('print(1)', 'python', languages), undefined); + assert.equal(languageNote('#!share --from csharp rows\nprint(rows)', 'python', languages), undefined); + assert.equal(languageNote('// just a comment\nvar x = 1;', 'csharp', languages), undefined); +}); + +test('a #!directive that makes a cell run as another language is said under the cell, with the reason', () => { + const note = languageNote('#!python\nprint(1)', 'csharp', languages)!; + + assert.equal(note.text, '$(arrow-right) Python'); + assert.match(note.tooltip, /runs as Python: its first line says #!python, not as C#/); + assert.equal(languageNote('// note\n#!js\nconsole.log(1)', 'csharp', languages)?.text, '$(arrow-right) JavaScript'); +}); + +test('a #!share before the language directive is not mistaken for it, and an unknown name changes nothing', () => { + const note = languageNote('#!share --from csharp rows\n#!py\nprint(rows)', 'csharp', languages)!; + assert.equal(note.text, '$(arrow-right) Python'); + assert.match(note.tooltip, /#!py,/); + assert.equal(languageNote('#!cobol\nDISPLAY', 'csharp', languages), undefined); +}); + +test('the Run lens is for the languages the engine runs, not for a plain .cs file, a notebook cell or an output', () => { + const runnable = ['csharp', 'python', 'java']; + assert.equal(offersLens('python', '/a/b.py', 'file', runnable), true); + assert.equal(offersLens('csharp', '/a/b.csx', 'file', runnable), true); + assert.equal(offersLens('csharp', '/a/B.cs', 'file', runnable), false); + assert.equal(offersLens('ruby', '/a/b.rb', 'file', runnable), false); + assert.equal(offersLens('python', 'cell', 'vscode-notebook-cell', runnable), false); + assert.equal(offersLens('python', 'log', 'output', runnable), false); +}); diff --git a/vscode-frysharp/test/unit/directives.test.ts b/vscode-frysharp/test/unit/directives.test.ts new file mode 100644 index 00000000..f720b2c1 --- /dev/null +++ b/vscode-frysharp/test/unit/directives.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { DirectiveContext, directiveCompletions } from '../../src/language/directives'; + +const languages = [ + { id: 'csharp', displayName: 'C#', shortName: 'C#', aliases: ['c#', 'cs'], canRunCells: true }, + { id: 'python', displayName: 'Python', shortName: 'PY', aliases: ['py'], canRunCells: true }, + { id: 'java', displayName: 'Java', shortName: 'JV', aliases: [], canRunCells: false } +]; +const variables: Record = { csharp: ['count', 'name'], python: ['df'] }; +const context = (cellLanguage = 'csharp'): DirectiveContext => ({ languages, cellLanguage, variablesOf: async (id) => variables[id] ?? [] }); +const labels = async (line: string, cellLanguage?: string) => (await directiveCompletions(line, context(cellLanguage)))?.items.map((i) => i.label); + +describe('completing a directive', () => { + it('offers share and the languages the engine runs in cells, after #!', async () => { + assert.deepStrictEqual(await labels('#!'), ['share', 'csharp', 'c#', 'cs', 'python', 'py']); + assert.deepStrictEqual(await labels('#!py'), ['share', 'csharp', 'c#', 'cs', 'python', 'py']); // the editor filters by what is typed + assert.equal((await directiveCompletions('#!', context()))!.from, 2); + assert.equal((await directiveCompletions(' #!', context()))!.from, 4); + }); + + it('offers --from after #!share, then the languages, then that language\'s variables, then --as', async () => { + assert.deepStrictEqual(await labels('#!share '), ['--from']); + assert.deepStrictEqual(await labels('#!share --from '), ['csharp', 'python']); + assert.deepStrictEqual(await labels('#!share --from csharp '), ['count', 'name']); + assert.deepStrictEqual(await labels('#!share --from py '), ['df']); // by alias + assert.deepStrictEqual(await labels('#!share --from csharp count '), ['--as']); + assert.deepStrictEqual(await labels('#!share --from csharp count --as '), []); + assert.deepStrictEqual(await labels('#!share --from nonsense '), []); + }); + + it('says where the word being typed starts', async () => { + assert.equal((await directiveCompletions('#!share --from cs', context()))!.from, '#!share --from '.length); + assert.equal((await directiveCompletions('#!share --from csharp co', context()))!.from, '#!share --from csharp '.length); + }); + + it('offers the package line of the cell\'s language after %', async () => { + assert.deepStrictEqual(await labels('%', 'python'), ['pip install']); + assert.deepStrictEqual(await labels('%np', 'javascript'), ['npm install']); + assert.deepStrictEqual(await labels('!', 'python'), ['pip install']); + assert.equal(await labels('%', 'java'), undefined); + }); + + it('is not about a line of code', async () => { + assert.equal(await directiveCompletions('var a = 1;', context()), undefined); + assert.equal(await directiveCompletions('x = 5 % 3', context()), undefined); + assert.equal(await directiveCompletions('// #!', context()), undefined); + }); +}); diff --git a/vscode-frysharp/test/unit/engineInfo.test.ts b/vscode-frysharp/test/unit/engineInfo.test.ts new file mode 100644 index 00000000..4a1f189c --- /dev/null +++ b/vscode-frysharp/test/unit/engineInfo.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { engineInfoMarkdown } from '../../src/engineInfo'; +import type { EngineInfo } from '../../src/sidecar/protocol'; + +const info: EngineInfo = { + serverVersion: '1.0.0', + protocolVersion: 1, + runtime: '.NET 10.0.0', + operatingSystem: 'Darwin 25.5.0', + architecture: 'Arm64', + workspaceRoot: '/work/space', + toolchains: [ + { languageId: 'csharp', displayName: 'C#', found: true, name: '.NET 10.0.0', version: '10.0.0', source: 'built into the engine' }, + { languageId: 'java', displayName: 'Java', found: true, name: 'OpenJDK 17.0.20', version: '17.0.20', path: '/usr/bin/java', source: 'PATH' }, + { languageId: 'rust', displayName: 'Rust', found: false, missing: 'Rust toolchain not found\nInstall it from https://rustup.rs\n • then reload' } + ] +}; + +test('the report says what the engine runs on, one line each, and how it was started', () => { + const text = engineInfoMarkdown(info, { + extensionVersion: '1.2.3', + vscodeVersion: '1.135.0', + launch: { command: '/opt/homebrew/bin/dotnet', description: 'the packaged sidecar (/ext/bin/sidecar/frysharp-sidecar.dll)' }, + logFolder: '/logs', + settings: [['frysharp.dotnetPath', ''], ['frysharp.notebook.outputTabs', 'true']] + }); + + assert.match(text, /\| Extension \| 1\.2\.3 \|/); + assert.match(text, /\| Engine \| 1\.0\.0 \(protocol 1\) \|/); + assert.match(text, /\| Runtime \| \.NET 10\.0\.0 \|/); + assert.match(text, /\| Engine started as \| the packaged sidecar/); + assert.match(text, /\| \.NET \| \/opt\/homebrew\/bin\/dotnet \|/); + assert.match(text, /\| Logs \| \/logs \|/); +}); + +test('each language is a row: what it found and where, or that it is not found', () => { + const text = engineInfoMarkdown(info, { extensionVersion: '1', vscodeVersion: '1', settings: [] }); + + assert.match(text, /\| C# \| ✓ found \| \.NET 10\.0\.0 \| inside the engine \| built into the engine \|/); + assert.match(text, /\| Java \| ✓ found \| OpenJDK 17\.0\.20 \| \/usr\/bin\/java \| PATH \|/); + assert.match(text, /\| Rust \| ✗ \*\*not found\*\* \|/); +}); + +test('what is missing says how to get it, indented so Markdown shows it as it is written', () => { + const text = engineInfoMarkdown(info, { extensionVersion: '1', vscodeVersion: '1', settings: [] }); + + assert.match(text, /### Rust\n\n {4}Rust toolchain not found\n {4}Install it from https:\/\/rustup\.rs\n {6}• then reload/); + assert.ok(!engineInfoMarkdown({ ...info, toolchains: [info.toolchains[0]] }, { extensionVersion: '1', vscodeVersion: '1', settings: [] }).includes('What is missing')); +}); + +test('a setting that is not set says so, and a pipe in a value does not break its table row', () => { + const text = engineInfoMarkdown({ ...info, workspaceRoot: '/a|b' }, { extensionVersion: '1', vscodeVersion: '1', settings: [['frysharp.sidecarPath', '']] }); + + assert.match(text, /- `frysharp\.sidecarPath`: \(not set\)/); + assert.match(text, /\| Workspace \| \/a\\\|b \|/); +}); diff --git a/vscode-frysharp/test/unit/frynb.test.ts b/vscode-frysharp/test/unit/frynb.test.ts new file mode 100644 index 00000000..0bc3d478 --- /dev/null +++ b/vscode-frysharp/test/unit/frynb.test.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; +import { parseFrynb, writeFrynb } from '../../src/notebook/format/frynb'; +import { + CellModel, + CellOutput, + fromBase64, + INSPECTOR, + jsonItem, + STDERR, + STDOUT, + TABLE, + textItem +} from '../../src/notebook/format/model'; +import { fingerprint } from '../../src/notebook/format/outputs'; + +// vscode-frysharp/test/fixtures: desktop.frynb is written by the desktop's own writer (Tests.Core/ExtensionFixturesTests), +// written/ holds what this extension writes from it, which the desktop's tests read back. +const fixtures = path.resolve(__dirname, '../../../test/fixtures'); +const read = (name: string) => fs.readFileSync(path.join(fixtures, name), 'utf8'); +const NOW = new Date('2026-10-14T09:00:00.000Z'); + +function withoutTimestamps(json: string): unknown { + const parsed = JSON.parse(json); + delete parsed.LastModified; + return parsed; +} + +describe('a notebook the desktop wrote', () => { + const desktop = read('desktop.frynb'); + + it('is written back as the same notebook, field for field', () => { + const written = writeFrynb(parseFrynb(desktop), NOW); + + assert.deepStrictEqual(withoutTimestamps(written), withoutTimestamps(desktop)); + fs.writeFileSync(path.join(fixtures, 'written', 'desktop.frynb'), written); + }); + + it('shows every output the desktop kept, in VS Code terms', () => { + const notebook = parseFrynb(desktop); + const cell = (id: string) => notebook.cells.find((c) => c.id === id) as CellModel; + const mimes = (id: string) => cell(id).outputs.map((o) => o.items.map((i) => i.mime)); + + assert.deepStrictEqual(mimes('text'), [[STDOUT]]); + assert.deepStrictEqual(mimes('failed'), [[STDERR]]); + assert.equal(cell('failed').failed, true); + + // the output text first, then the table with the page the desktop drew it with + assert.deepStrictEqual(mimes('table'), [[STDOUT], ['text/html', TABLE, 'text/plain']]); + assert.deepStrictEqual(mimes('inspector'), [['text/html', INSPECTOR, 'text/plain']]); + assert.deepStrictEqual(mimes('chart'), [['text/html', 'application/vnd.fry.chart.v1+json', 'text/plain']]); + assert.deepStrictEqual(mimes('image'), [['text/html', 'image/png', 'text/plain']]); + + assert.equal(cell('collapsed').language, 'python'); + assert.equal(cell('intro').kind, 'markdown'); + assert.equal(notebook.kernel, 'csharp'); + }); + + it('keeps what only the desktop knows: collapse flags, the cell that had a live control, its own table snapshot', () => { + const written = JSON.parse(writeFrynb(parseFrynb(desktop), NOW)); + const original = JSON.parse(desktop); + const pick = (notebook: { Cells: Array> }, id: string) => notebook.Cells.find((c) => c.Id === id) as Record; + + const collapsed = pick(written, 'collapsed'); + assert.equal(collapsed.IsInputCollapsed, true); + assert.equal(collapsed.IsOutputScrolled, true); + assert.equal(collapsed.HadInteractiveControl, true); + assert.equal(collapsed.ExecutionTimeText, '12 ms'); + assert.deepStrictEqual(pick(written, 'table').TableSnapshot, pick(original, 'table').TableSnapshot); + }); + + it('is not given an OutputBundles copy while its fields say everything', () => { + const written = JSON.parse(writeFrynb(parseFrynb(desktop), NOW)); + assert.ok(written.Cells.every((c: Record) => !('OutputBundles' in c))); + }); +}); + +// What the sidecar sends for a cell: the engine's own MIME bundles (RichOutputMime), each with its page and a text fallback. +describe('a notebook saved after a run', () => { + const desktop = JSON.parse(read('desktop.frynb')); + const cellOf = (id: string) => desktop.Cells.find((c: Record) => c.Id === id); + const tableHtml: string = cellOf('table').HtmlContent; + const inspectorHtml: string = cellOf('inspector').HtmlContent; + const chartHtml: string = cellOf('chart').HtmlContent; + const chart = cellOf('chart').Visuals[0]; + const imageHtml: string = cellOf('image').HtmlContent; + + const table: CellOutput = { + items: [ + textItem('text/html', tableHtml), + jsonItem(TABLE, { title: 'People', columns: ['Name', 'Age'], numeric: [false, true], rows: [['Ada', '36'], ['Grace', null]], totalRows: 2, totalColumns: 2 }), + textItem('text/plain', 'People') + ] + }; + const inspector: CellOutput = { + items: [ + textItem('text/html', inspectorHtml), + jsonItem(INSPECTOR, { + title: 'People', + properties: [ + { name: 'name', value: 'Code', isNull: false, isString: true, child: null }, + { name: 'another', value: '', isNull: false, isString: false, child: { title: 'People', circular: true, properties: [] } } + ] + }), + textItem('text/plain', 'People') + ] + }; + const visual: CellOutput = { items: [textItem('text/html', chartHtml), jsonItem(chart.MimeType, chart.Data), textItem('text/plain', '')] }; + const image: CellOutput = { + items: [textItem('text/html', imageHtml), { mime: 'image/png', data: fromBase64(cellOf('image').ImageBytes) }, textItem('text/plain', '')], + metadata: { 'image/png': { width: 1, height: 1 } } + }; + const stream: CellOutput = { items: [textItem(STDOUT, 'Result:\n')] }; + + const run = (outputs: CellOutput[], failed = false): CellModel => ({ + id: 'run', + kind: 'code', + source: 'Display.Everything();', + executionCount: 3, + executionMs: 12, + failed, + outputs, + state: { extra: {}, snapshots: {}, outputsKey: '' } + }); + + const notebookWith = (...cells: CellModel[]) => ({ format: 'frynb' as const, kernel: 'csharp', root: { Id: 'after-a-run', Title: 'After a run' }, cells }); + + it('keeps a table, an inspector, a chart and an image in the fields the desktop reads, and the whole list besides', () => { + const failed: CellModel = { ...run([{ items: [textItem(STDOUT, 'boom\n')] }], true), id: 'failed', executionCount: 4, executionMs: undefined }; + const written = writeFrynb(notebookWith(run([stream, table, inspector, visual, image]), failed), NOW); + fs.writeFileSync(path.join(fixtures, 'written', 'after-a-run.frynb'), written); + + const cell = JSON.parse(written).Cells[0]; + assert.equal(cell.OutputText, 'Result:\n'); + assert.equal(cell.ExecutionTimeText, '12 ms'); + assert.equal(cell.TableSnapshot.Title, 'People'); + assert.equal(cell.InspectorSnapshot.HeaderTitle, 'People'); + assert.equal(cell.Visuals.length, 1); + assert.equal(cell.ImageFormat, 'PNG'); + assert.equal(cell.ImageWidth, 1); + assert.equal(cell.HasOutput, true); + // the desktop has one slot for each kind and an order of its own: the extension also keeps the outputs as they were + assert.equal(cell.OutputBundles.length, 5); + }); + + it('reads back as the outputs it was given', () => { + const outputs = [stream, table, inspector, visual, image]; + const back = parseFrynb(writeFrynb(notebookWith(run(outputs)), NOW)).cells[0]; + + assert.equal(fingerprint(back.outputs), fingerprint(outputs)); + assert.equal(back.executionCount, 3); + }); + + it('does not save the list again when the fields say it all', () => { + // (the desktop keeps one page, the last output's: earlier ones that came with a page of their own need the list) + const bare = (output: CellOutput): CellOutput => ({ ...output, items: output.items.filter((item) => item.mime !== 'text/html') }); + for (const outputs of [[table], [stream, table], [inspector], [visual], [image], [stream, bare(image), bare(table), bare(inspector), visual]]) { + const cell = JSON.parse(writeFrynb(notebookWith(run(outputs)), NOW)).Cells[0]; + assert.equal('OutputBundles' in cell, false, `${outputs.length} output(s)`); + assert.equal(fingerprint(parseFrynb(writeFrynb(notebookWith(run(outputs)), NOW)).cells[0].outputs, true), fingerprint(outputs, true)); + } + }); + + it('saves the list when each output has a page of its own, since the desktop keeps only the last', () => { + const cell = JSON.parse(writeFrynb(notebookWith(run([table, inspector])), NOW)).Cells[0]; + assert.equal(cell.OutputBundles.length, 2); + }); + + it('keeps two tables of one cell, which the desktop has one slot for', () => { + const second: CellOutput = { items: [jsonItem(TABLE, { title: 'Other', columns: ['X'], numeric: [true], rows: [['1']], totalRows: 1, totalColumns: 1 }), textItem('text/plain', 'Other')] }; + const written = writeFrynb(notebookWith(run([table, stream, second])), NOW); + + const back = parseFrynb(written).cells[0]; + assert.equal(fingerprint(back.outputs), fingerprint([table, stream, second])); + assert.equal(JSON.parse(written).Cells[0].TableSnapshot.Title, 'Other'); // the desktop shows the last + }); + + it('a failed cell is a failed cell', () => { + const cell = JSON.parse(writeFrynb(notebookWith(run([{ items: [textItem(STDERR, 'boom\n')] }], true)), NOW)).Cells[0]; + assert.equal(cell.HasError, true); + assert.equal(cell.OutputText, 'boom\n'); + }); + + it('the error line the extension adds to a failed cell is not written: its text is in the cell\'s text already', () => { + const error: CellOutput = { items: [textItem('application/vnd.code.notebook.error', '{"name":"CS0029","message":"Cannot convert"}')], metadata: { fryEngineError: true } }; + + const cell = JSON.parse(writeFrynb(notebookWith(run([{ items: [textItem(STDOUT, 'Line 1: CS0029 Cannot convert\n')] }, error], true)), NOW)).Cells[0]; + + assert.equal(cell.HasError, true); + assert.equal(cell.OutputText, 'Line 1: CS0029 Cannot convert\n'); + assert.equal(cell.OutputBundles ?? null, null); + }); +}); + +describe('reading', () => { + it('keeps fields it does not know, in a cell and in the notebook', () => { + const json = JSON.stringify({ + SchemaVersion: '1.0', + Kernel: 'csharp', + FutureRootField: { a: 1 }, + Cells: [{ Id: 'a', Type: 0, Source: 'x', FutureCellField: [1, 2], IsOutputCollapsed: true }] + }); + + const written = JSON.parse(writeFrynb(parseFrynb(json), NOW)); + assert.deepStrictEqual(written.FutureRootField, { a: 1 }); + assert.deepStrictEqual(written.Cells[0].FutureCellField, [1, 2]); + assert.equal(written.Cells[0].IsOutputCollapsed, true); + }); + + it('reads camelCase names, a cell with no id, a list of source lines, and an unknown cell type as code', () => { + const notebook = parseFrynb( + JSON.stringify({ kernel: 'python', cells: [{ type: 'markdown', source: ['# a\n', 'b'] }, { source: 'print(1)', language: 'python' }, { type: 'weird', source: 'z' }] }) + ); + + assert.equal(notebook.kernel, 'python'); + assert.deepStrictEqual(notebook.cells.map((c) => [c.id, c.kind, c.source]), [['cell-1', 'markdown', '# a\nb'], ['cell-2', 'code', 'print(1)'], ['cell-3', 'code', 'z']]); + }); + + it('writes a language only when it is not the notebook\'s', () => { + const cells: CellModel[] = [ + { id: 'a', kind: 'code', language: 'csharp', source: '', failed: false, outputs: [], state: { extra: {}, snapshots: {}, outputsKey: '' } }, + { id: 'b', kind: 'code', language: 'python', source: '', failed: false, outputs: [], state: { extra: {}, snapshots: {}, outputsKey: '' } } + ]; + const written = JSON.parse(writeFrynb({ format: 'frynb', kernel: 'csharp', root: {}, cells }, NOW)); + assert.equal('Language' in written.Cells[0], false); + assert.equal(written.Cells[1].Language, 'python'); + }); + + it('rejects text that is not a notebook', () => { + assert.throws(() => parseFrynb('[1,2]')); + assert.throws(() => parseFrynb('not json')); + }); +}); diff --git a/vscode-frysharp/test/unit/grammar.test.ts b/vscode-frysharp/test/unit/grammar.test.ts new file mode 100644 index 00000000..75ef1f31 --- /dev/null +++ b/vscode-frysharp/test/unit/grammar.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { before, describe, it } from 'node:test'; +import { loadWASM, OnigScanner, OnigString } from 'vscode-oniguruma'; +import { INITIAL, IGrammar, parseRawGrammar, Registry } from 'vscode-textmate'; + +const root = path.resolve(__dirname, '../../..'); + +// What the host language's grammar does with a "#" line: it is a comment. The directive grammar is injected into it. +const comments = { scopeName: 'source.python', patterns: [{ match: '#.*$', name: 'comment.line.number-sign' }] }; +const directives = readFileSync(path.join(root, 'syntaxes', 'frysharp-directives.injection.json'), 'utf8'); + +let grammar: IGrammar; + +before(async () => { + const wasm = readFileSync(path.join(path.dirname(require.resolve('vscode-oniguruma')), 'onig.wasm')); + await loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength) as ArrayBuffer); + const registry = new Registry({ + onigLib: Promise.resolve({ createOnigScanner: (patterns) => new OnigScanner(patterns), createOnigString: (text) => new OnigString(text) }), + loadGrammar: async (scopeName) => { + if (scopeName === 'source.python') return parseRawGrammar(JSON.stringify(comments), 'comments.json'); + if (scopeName === 'frysharp.directives.injection') return parseRawGrammar(directives, 'directives.json'); + return null; + }, + getInjections: (scopeName) => (scopeName === 'source.python' ? ['frysharp.directives.injection'] : undefined) + }); + const loaded = await registry.loadGrammar('source.python'); + assert.ok(loaded, 'the grammar loads'); + grammar = loaded; +}); + +/** The tokens of a line as [text, scopes it has beyond the language's own]. */ +function tokens(line: string): Array<[string, string[]]> { + const result = grammar.tokenizeLine(line, INITIAL); + return result.tokens.map((token) => [line.slice(token.startIndex, token.endIndex), token.scopes.filter((scope) => scope.endsWith('.frysharp'))]); +} + +const scopeOf = (line: string, text: string): string | undefined => tokens(line).find(([part]) => part === text)?.[1][0]; + +describe('the grammar of a cell\'s directives', () => { + it('marks #!share, its options, the language, the variable and the new name', () => { + const line = '#!share --from csharp people --as team'; + assert.equal(scopeOf(line, 'share'), 'keyword.control.directive.share.frysharp'); + assert.equal(scopeOf(line, '--from'), 'keyword.operator.option.frysharp'); + assert.equal(scopeOf(line, 'csharp'), 'entity.name.type.language.frysharp'); + assert.equal(scopeOf(line, 'people'), 'variable.other.shared.frysharp'); + assert.equal(scopeOf(line, '--as'), 'keyword.operator.option.frysharp'); + assert.equal(scopeOf(line, 'team'), 'variable.other.alias.frysharp'); + }); + + it('marks the language directives', () => { + assert.equal(scopeOf('#!python', 'python'), 'entity.name.function.directive.language.frysharp'); + assert.equal(scopeOf('#!c#', 'c#'), 'entity.name.function.directive.language.frysharp'); + assert.equal(scopeOf(' #!javascript', 'javascript'), 'entity.name.function.directive.language.frysharp'); + }); + + it('marks package lines', () => { + const line = '%pip install requests'; + assert.equal(scopeOf(line, 'pip'), 'keyword.control.directive.package.frysharp'); + assert.equal(scopeOf(line, 'install'), 'keyword.operator.option.frysharp'); + assert.equal(scopeOf(line, 'requests'), 'string.unquoted.package.frysharp'); + assert.equal(scopeOf('%npm install lodash', 'npm'), 'keyword.control.directive.package.frysharp'); + }); + + it('leaves a shebang, a Rust attribute and a plain comment to the language', () => { + for (const line of ['#!/usr/bin/env python3', '#![allow(unused)]', '# not a directive', 'x = 1 #!python']) { + assert.deepStrictEqual( + tokens(line).filter(([, scopes]) => scopes.length > 0), + [], + line + ); + } + }); +}); diff --git a/vscode-frysharp/test/unit/identity.test.ts b/vscode-frysharp/test/unit/identity.test.ts new file mode 100644 index 00000000..13a8ea64 --- /dev/null +++ b/vscode-frysharp/test/unit/identity.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { identityOf, UriLike } from '../../src/notebook/identity'; + +const file = (fsPath: string): UriLike => ({ scheme: 'file', fsPath, path: fsPath, toString: () => `file://${fsPath}` }); +const untitled = (name: string): UriLike => ({ scheme: 'untitled', fsPath: name, path: name, toString: () => `untitled:${name}` }); +const folder = (root?: string) => () => root; + +describe('a notebook\'s identity', () => { + it('is its URI, so two files with one name in two folders are two notebooks', () => { + const a = identityOf(file('/work/a/notebook.frynb'), folder('/work')); + const b = identityOf(file('/work/b/notebook.frynb'), folder('/work')); + + assert.notEqual(a.notebookId, b.notebookId); + assert.equal(a.path, 'a/notebook.frynb'); + assert.equal(a.workingDirectory, '/work/a'); + }); + + it('works for a notebook outside every workspace folder, which runs in its own folder', () => { + const outside = identityOf(file('/elsewhere/deep/nb.frynb'), folder('/work')); + + assert.equal(outside.path, 'nb.frynb'); + assert.equal(outside.workingDirectory, '/elsewhere/deep'); + }); + + it('works with no folder open at all', () => { + assert.equal(identityOf(file('/home/me/nb.frynb'), folder(undefined)).path, 'nb.frynb'); + }); + + it('works for an untitled notebook, which runs in the workspace folder', () => { + const one = identityOf(untitled('Untitled-1.frynb'), folder('/work')); + const two = identityOf(untitled('Untitled-2.frynb'), folder('/work')); + + assert.notEqual(one.notebookId, two.notebookId); + assert.equal(one.path, 'Untitled-1.frynb'); + assert.equal(one.workingDirectory, '/work'); + assert.equal(identityOf(untitled('Untitled-3.frynb'), folder(undefined)).workingDirectory, undefined); + }); +}); diff --git a/vscode-frysharp/test/unit/ipynb.test.ts b/vscode-frysharp/test/unit/ipynb.test.ts new file mode 100644 index 00000000..f8537866 --- /dev/null +++ b/vscode-frysharp/test/unit/ipynb.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; +import { parseIpynb, writeIpynb } from '../../src/notebook/format/ipynb'; +import { ERROR, STDERR, STDOUT, textItem } from '../../src/notebook/format/model'; + +// A notebook Jupyter wrote (test/fixtures/jupyter.ipynb). +const text = fs.readFileSync(path.resolve(__dirname, '../../../test/fixtures/jupyter.ipynb'), 'utf8'); + +describe('a Jupyter notebook', () => { + it('is written back exactly as it was read', () => { + assert.equal(writeIpynb(parseIpynb(text)), text); + }); + + it('keeps cell tags, notebook metadata, and a raw cell as raw', () => { + const written = JSON.parse(writeIpynb(parseIpynb(text))); + assert.deepStrictEqual(written.cells[0].metadata.tags, ['intro']); + assert.deepStrictEqual(written.cells[1].metadata, { collapsed: false, tags: ['slow'] }); + assert.deepStrictEqual(written.metadata.custom, { keep: 'me' }); + assert.equal(written.cells[4].cell_type, 'raw'); + }); + + it('reads outputs as VS Code has them: stdout and stderr, an error, an image, a page, JSON', () => { + const cells = parseIpynb(text).cells; + const mimes = (index: number) => cells[index].outputs.map((o) => o.items.map((i) => i.mime)); + + assert.deepStrictEqual(mimes(1), [[STDOUT], [STDERR]]); + assert.deepStrictEqual(mimes(2), [['image/png', 'text/plain'], ['text/html', 'application/json', 'text/plain']]); + assert.deepStrictEqual(mimes(3), [[ERROR]]); + assert.equal(cells[3].failed, true); + assert.equal(cells[3].language, 'python'); + assert.equal(cells[1].executionCount, 1); + }); + + it('keeps an error an error, and stderr stderr, when the outputs change', () => { + const notebook = parseIpynb(text); + notebook.cells[1].outputs = [{ items: [textItem(STDERR, 'now an error stream\n')] }, ...notebook.cells[3].outputs]; + notebook.cells[3].outputs = []; + + const written = JSON.parse(writeIpynb(notebook)); + assert.deepStrictEqual(written.cells[1].outputs[0], { output_type: 'stream', name: 'stderr', text: ['now an error stream\n'] }); + assert.equal(written.cells[1].outputs[1].output_type, 'error'); + assert.equal(written.cells[1].outputs[1].ename, 'ZeroDivisionError'); + assert.deepStrictEqual(written.cells[3].outputs, []); + assert.deepStrictEqual(written.cells[0], JSON.parse(text).cells[0]); // untouched cells are as they were + }); + + it('writes a new cell as Jupyter does, with its language', () => { + const notebook = parseIpynb(text); + notebook.cells.push({ id: 'new', kind: 'code', language: 'javascript', source: 'a\nb', failed: false, outputs: [], state: { extra: {}, snapshots: {}, outputsKey: '' } }); + + const cell = JSON.parse(writeIpynb(notebook)).cells[5]; + assert.deepStrictEqual(cell.source, ['a\n', 'b']); + assert.equal(cell.execution_count, null); + assert.equal(cell.metadata.polyglot_notebook.kernelName, 'javascript'); + }); + + it('is rejected when it is not a notebook', () => { + assert.throws(() => parseIpynb('{"nbformat":4}')); + }); +}); diff --git a/vscode-frysharp/test/unit/launcher.test.ts b/vscode-frysharp/test/unit/launcher.test.ts new file mode 100644 index 00000000..a4a59660 --- /dev/null +++ b/vscode-frysharp/test/unit/launcher.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; +import { resolveSidecarLaunch } from '../../src/sidecar/launcher'; + +const ext = path.join('/x', 'ext'); +const packaged = path.join(ext, 'bin', 'sidecar', 'frysharp-sidecar.dll'); +const built = path.join('/x', 'FrySharp.Sidecar', 'bin', 'Debug', 'net10.0', 'frysharp-sidecar.dll'); +const project = path.join('/x', 'FrySharp.Sidecar', 'FrySharp.Sidecar.csproj'); +const have = (...files: string[]) => (file: string) => files.includes(file); + +describe('which sidecar starts', () => { + it('the one the setting names comes first, and a wrong setting is an error, not a fallback', () => { + const launch = resolveSidecarLaunch({ setting: '/my/sidecar.dll', extensionPath: ext, development: false, exists: have('/my/sidecar.dll', packaged) }, '/work'); + assert.deepStrictEqual(launch.args, ['/my/sidecar.dll', '--workspace', '/work']); + assert.throws(() => resolveSidecarLaunch({ setting: '/missing.dll', extensionPath: ext, development: false, exists: have(packaged) }, '/work'), /frysharp\.sidecarPath.*does not exist/); + }); + + it('then the packaged one, whatever else is lying around', () => { + const launch = resolveSidecarLaunch({ extensionPath: ext, development: false, exists: have(packaged, built) }, '/work'); + assert.deepStrictEqual(launch.args, [packaged, '--workspace', '/work']); + }); + + it('an installed extension never runs a build it did not bring', () => { + assert.throws(() => resolveSidecarLaunch({ extensionPath: ext, development: false, exists: have(built, project) }, '/work'), /not part of this installation/); + }); + + it('from the repository: the build there, even over a packaged copy that may be old; else the project by dotnet run; else the packaged one', () => { + const repoExt = path.join('/x', 'vscode-frysharp'); + const repoPackaged = path.join(repoExt, 'bin', 'sidecar', 'frysharp-sidecar.dll'); + const dev = (...files: string[]) => resolveSidecarLaunch({ extensionPath: repoExt, development: true, exists: have(...files) }, '/work'); + + assert.deepStrictEqual(dev(built, project, repoPackaged).args, [built, '--workspace', '/work']); + assert.deepStrictEqual(dev(project, repoPackaged).args, ['run', '--project', project, '--', '--workspace', '/work']); + assert.deepStrictEqual(dev(repoPackaged).args, [repoPackaged, '--workspace', '/work']); + assert.throws(() => dev(), /not part of this installation/); + }); +}); diff --git a/vscode-frysharp/test/unit/lineEditor.test.ts b/vscode-frysharp/test/unit/lineEditor.test.ts new file mode 100644 index 00000000..3c91e034 --- /dev/null +++ b/vscode-frysharp/test/unit/lineEditor.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { LineEditor } from '../../src/run/lineEditor'; + +describe('typing to a running program', () => { + it('shows what is typed, and sends the line on Enter after taking back what it showed', () => { + const editor = new LineEditor(); + + assert.deepStrictEqual(editor.feed('Ada'), { echo: 'Ada', lines: [], interrupt: false }); + // the engine prints "Ada" and a newline itself: the three characters shown are taken back first + assert.deepStrictEqual(editor.feed('\r'), { echo: '\b \b\b \b\b \b', lines: ['Ada'], interrupt: false }); + assert.equal(editor.pending, ''); + }); + + it('takes a character back on Backspace, and does nothing on an empty line', () => { + const editor = new LineEditor(); + editor.feed('Adx'); + + assert.equal(editor.feed('\x7f').echo, '\b \b'); + assert.equal(editor.pending, 'Ad'); + editor.feed('\x7f\x7f'); + assert.equal(editor.feed('\x7f').echo, ''); + }); + + it('sends several lines pasted at once, and the empty line of a bare Enter', () => { + const editor = new LineEditor(); + + assert.deepStrictEqual(editor.feed('one\ntwo\r\nthree\r').lines, ['one', 'two', 'three']); + assert.deepStrictEqual(editor.feed('\r').lines, ['']); + }); + + it('keeps what is typed when it comes in pieces', () => { + const editor = new LineEditor(); + editor.feed('He'); + editor.feed('llo'); + assert.deepStrictEqual(editor.feed('\r').lines, ['Hello']); + }); + + it('says Ctrl+C was pressed, and forgets what was typed', () => { + const editor = new LineEditor(); + editor.feed('half'); + const typed = editor.feed('\x03'); + + assert.equal(typed.interrupt, true); + assert.equal(editor.pending, ''); + }); + + it('ignores arrow keys and other escape sequences, and tab', () => { + const editor = new LineEditor(); + + assert.deepStrictEqual(editor.feed('a\x1b[Ab\x1b[1;5C\x1bOP\tc'), { echo: 'abc', lines: [], interrupt: false }); + assert.equal(editor.pending, 'abc'); + }); + + it('counts a character of more than one code unit as one', () => { + const editor = new LineEditor(); + editor.feed('é😀'); + + assert.equal(editor.feed('\x7f').echo, '\b \b'); + assert.equal(editor.pending, 'é'); + assert.equal(editor.feed('\r').echo, '\b \b'); + }); +}); diff --git a/vscode-frysharp/test/unit/liveOutputs.test.ts b/vscode-frysharp/test/unit/liveOutputs.test.ts new file mode 100644 index 00000000..37618d2a --- /dev/null +++ b/vscode-frysharp/test/unit/liveOutputs.test.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { LiveOutputs, OutputSink, Said } from '../../src/notebook/liveOutputs'; +import { CellOutput, OutputItem, STDOUT, text } from '../../src/notebook/format/model'; +import { unpack } from '../../src/notebook/format/tabs'; +import { PackedCell } from '../../src/shared/outputTabs'; + +// A cell's outputs as the sink would leave them, and what it was asked, in order. +class Recorder implements OutputSink { + readonly calls: string[] = []; + readonly outputs: CellOutput[] = []; + + async append(output: CellOutput) { + this.calls.push('append'); + this.outputs.push({ items: [...output.items] }); + return this.outputs.length - 1; + } + async appendItems(handle: number, items: OutputItem[]) { + this.calls.push('appendItems'); + this.outputs[handle].items.push(...items); + } + async replaceItems(handle: number, items: OutputItem[]) { + this.calls.push('replaceItems'); + this.outputs[handle].items = [...items]; + } + async replaceAll(outputs: CellOutput[]) { + this.calls.push('replaceAll'); + this.outputs.length = 0; + this.outputs.push(...outputs.map((o) => ({ items: [...o.items] }))); + return outputs.map((_, i) => i); + } +} + +const say = (value: string): Said => ({ kind: 'stream', text: value }); +const table = (rows = 2): Said => ({ + kind: 'display', + data: { 'text/html': '
', 'application/vnd.fry.table+json': { title: 'T', columns: ['a'], numeric: [true], rows: Array.from({ length: rows }, (_, i) => [i]), totalRows: rows }, 'text/plain': 'T' } +}); +const chart = (title: string, displayId?: string, update?: boolean): Said => ({ + kind: 'display', + data: { 'text/html': '', 'application/vnd.fry.chart.v1+json': { title }, 'text/plain': 'chart' }, + displayId, + update +}); + +// time held by the test: a timer runs when the test says +function manualClock() { + const pending: Array<() => void> = []; + return { + schedule: (run: () => void) => { + pending.push(run); + return () => { + const at = pending.indexOf(run); + if (at >= 0) pending.splice(at, 1); + }; + }, + tick: () => pending.splice(0).forEach((run) => run()), + get waiting() { + return pending.length; + } + }; +} + +const packedOf = (output: CellOutput): PackedCell => JSON.parse(text(output.items[0])) as PackedCell; + +test('a cell that only prints has its text in one native output, added to as it comes', async () => { + const sink = new Recorder(); + const live = new LiveOutputs(sink, { tabs: true }); + + await live.show(say('a\n')); + await live.show(say('b\n')); + await live.finish(); + + assert.deepStrictEqual(sink.calls, ['append', 'appendItems']); + assert.equal(sink.outputs.length, 1); + assert.equal(sink.outputs[0].items.map(text).join(''), 'a\nb\n'); + assert.equal(live.isTabbed, false); +}); + +test('a cell that shows only tables has each as an output of its own', async () => { + const sink = new Recorder(); + const live = new LiveOutputs(sink, { tabs: true }); + + await live.show(table()); + await live.show(table(3)); + + assert.deepStrictEqual(sink.calls, ['append', 'append']); + assert.equal(live.isTabbed, false); +}); + +test('when a second kind of output comes, everything made so far becomes the one output, and stays one', async () => { + const sink = new Recorder(); + const clock = manualClock(); + const live = new LiveOutputs(sink, { tabs: true, schedule: clock.schedule }); + + await live.show(say('hello\n')); + await live.show(table()); + + assert.equal(live.isTabbed, true); + assert.deepStrictEqual(sink.calls, ['append', 'replaceAll']); + assert.equal(sink.outputs.length, 1); + assert.deepStrictEqual(packedOf(sink.outputs[0]).outputs.map((o) => o.items[0].mime), ['application/vnd.code.notebook.stdout', 'text/html']); + + await live.show(say('more\n')); + await live.show(chart('c')); + clock.tick(); + await live.finish(); + + assert.equal(sink.outputs.length, 1); + const outputs = unpack(sink.outputs[0]); + assert.deepStrictEqual(outputs.map((o) => o.items[0].mime), [STDOUT, 'text/html', STDOUT, 'text/html']); + assert.equal(text(outputs[0].items[0]), 'hello\n'); + assert.equal(text(outputs[2].items[0]), 'more\n'); +}); + +test('text after a table is a console output of its own, in order, and text that comes after it is added to it', async () => { + const sink = new Recorder(); + const live = new LiveOutputs(sink, { tabs: true, schedule: manualClock().schedule }); + + await live.show(say('1\n')); + await live.show(table()); + await live.show(say('2\n')); + await live.show(say('3\n')); + await live.finish(); + + const outputs = unpack(sink.outputs[0]); + assert.equal(outputs.length, 3); + assert.equal(text(outputs[2].items[0]), '2\n3\n'); +}); + +test('a flood of text is written out a few times, not once for each piece', async () => { + const sink = new Recorder(); + const clock = manualClock(); + const live = new LiveOutputs(sink, { tabs: true, schedule: clock.schedule }); + + await live.show(table()); + await live.show(say('x\n')); + for (let i = 0; i < 500; i++) await live.show(say(`line ${i}\n`)); + + assert.equal(clock.waiting, 1); + assert.deepStrictEqual(sink.calls, ['append', 'replaceAll']); // nothing yet: the timer has not run + clock.tick(); + await live.show(say('last\n')); + await live.finish(); + assert.equal(sink.calls.filter((c) => c === 'replaceItems').length, 2); // the one the timer ran, and the last one when the cell ended +}); + +test('while the cell runs a long console carries its last lines and says how many it left off; the end has all of it', async () => { + const sink = new Recorder(); + const clock = manualClock(); + const live = new LiveOutputs(sink, { tabs: true, liveConsoleLines: 5, schedule: clock.schedule }); + + await live.show(table()); + for (let i = 0; i < 20; i++) await live.show(say(`line ${i}\n`)); + clock.tick(); + await live.show(say('wait\n')); + + const during = packedOf(sink.outputs[0]); + assert.equal(during.outputs[1].omitted, 15); + assert.equal(during.outputs[1].items[0].text, 'line 15\nline 16\nline 17\nline 18\nline 19\n'); + + await live.finish(); + const after = unpack(sink.outputs[0]); + assert.equal(text(after[1].items[0]), Array.from({ length: 20 }, (_, i) => `line ${i}\n`).join('') + 'wait\n'); + assert.equal(packedOf(sink.outputs[0]).outputs[1].omitted, undefined); +}); + +test('a chart the program redraws is redrawn where it is, whether the output is native or tabbed', async () => { + const native = new Recorder(); + const live = new LiveOutputs(native, { tabs: true, schedule: manualClock().schedule }); + await live.show(chart('before', 'c1')); + await live.show(chart('after', 'c1', true)); + assert.deepStrictEqual(native.calls, ['append', 'replaceItems']); + assert.match(text(native.outputs[0].items[1]), /after/); + + const sink = new Recorder(); + const tabbed = new LiveOutputs(sink, { tabs: true, schedule: manualClock().schedule }); + await tabbed.show(chart('before', 'c2')); + await tabbed.show(say('text\n')); + await tabbed.show(chart('after', 'c2', true)); + await tabbed.finish(); + const outputs = unpack(sink.outputs[0]); + assert.equal(outputs.length, 2); + assert.match(text(outputs[0].items[1]), /after/); +}); + +test('with the tabs off, a cell with several kinds of output keeps them as outputs one after another', async () => { + const sink = new Recorder(); + const live = new LiveOutputs(sink, { tabs: false }); + + await live.show(say('hello\n')); + await live.show(table()); + await live.show(say('more\n')); + await live.finish(); + + assert.equal(live.isTabbed, false); + assert.deepStrictEqual(sink.calls, ['append', 'append', 'append']); +}); + +test('output that comes while another piece is being shown waits its turn', async () => { + const order: string[] = []; + const slow: OutputSink = { + append: async () => { + order.push('append start'); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push('append end'); + return 0; + }, + appendItems: async () => { + order.push('appendItems'); + }, + replaceItems: async () => undefined, + replaceAll: async () => [0] + }; + const live = new LiveOutputs(slow, { tabs: false }); + + await Promise.all([live.show(say('a')), live.show(say('b'))]); + + assert.deepStrictEqual(order, ['append start', 'append end', 'appendItems']); +}); diff --git a/vscode-frysharp/test/unit/manifest.test.ts b/vscode-frysharp/test/unit/manifest.test.ts new file mode 100644 index 00000000..9ba1d63e --- /dev/null +++ b/vscode-frysharp/test/unit/manifest.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +const root = path.resolve(__dirname, '../../..'); +const manifest = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); +const contributes = manifest.contributes; +const declared = new Set(contributes.commands.map((c: { command: string }) => c.command)); + +const menuCommands = (): string[] => + Object.values(contributes.menus as Record>) + .flat() + .map((item) => item.command) + .filter((command): command is string => command !== undefined); + +describe('the manifest', () => { + it('points at files that exist', () => { + const files: string[] = [ + ...contributes.grammars.map((g: { path: string }) => g.path), + ...contributes.snippets.map((s: { path: string }) => s.path), + ...contributes.walkthroughs.flatMap((w: { steps: Array<{ media: { markdown: string } }> }) => w.steps.map((step) => step.media.markdown)) + ]; + assert.ok(files.length > 0); + for (const file of files) assert.ok(existsSync(path.join(root, file)), `${file} is declared but missing`); + }); + + it('has snippets that are JSON with a prefix and a body', () => { + for (const { path: file } of contributes.snippets as Array<{ path: string }>) { + const snippets = JSON.parse(readFileSync(path.join(root, file), 'utf8')) as Record; + for (const [name, snippet] of Object.entries(snippets)) { + assert.ok(snippet.prefix.startsWith('fry'), `${file}: ${name} has the fry prefix`); + assert.ok(snippet.body.length > 0, `${file}: ${name} has a body`); + } + } + }); + + it('declares every command its menus, keybindings, walkthroughs and activation use', () => { + const used = [ + ...menuCommands(), + ...contributes.keybindings.map((k: { command: string }) => k.command), + ...(manifest.activationEvents as string[]).filter((e) => e.startsWith('onCommand:')).map((e) => e.slice('onCommand:'.length)), + ...contributes.walkthroughs.flatMap((w: { steps: Array<{ completionEvents?: string[] }> }) => w.steps.flatMap((s) => s.completionEvents ?? []).map((e) => e.slice('onCommand:'.length))) + ]; + for (const command of used) assert.ok(declared.has(command), `${command} is used but not declared`); + }); + + it('offers its run buttons by what the engine runs, not by a list of languages in the manifest', () => { + const whens = [ + ...Object.values(contributes.menus as Record>).flat().map((item) => item.when), + ...contributes.keybindings.map((k: { when?: string }) => k.when) + ].filter((when): when is string => when !== undefined); + + assert.deepStrictEqual(whens.filter((when) => /resourceLangId\s*==/.test(when)), []); + assert.ok(whens.some((when) => when.includes('resourceLangId in frysharp.runnableLanguages'))); + }); + + it('declares every setting the code reads', () => { + const properties = Object.keys(contributes.configuration.properties); + const reads: string[] = []; + for (const file of sourceFiles(path.join(root, 'src'))) { + const text = readFileSync(file, 'utf8'); + for (const match of text.matchAll(/getConfiguration\('(frysharp[\w.]*)'\)\s*\.get(?:<[^>]*>)?\('([\w.]+)'/g)) reads.push(`${match[1]}.${match[2]}`); + for (const variable of text.matchAll(/const (\w+) = vscode\.workspace\.getConfiguration\('(frysharp[\w.]*)'\)/g)) { + for (const match of text.matchAll(new RegExp(`\\b${variable[1]}\\.get(?:<[^>]*>)?\\('([\\w.]+)'`, 'g'))) reads.push(`${variable[2]}.${match[1]}`); + } + } + assert.ok(reads.length >= 6, `the check found ${reads.length} reads of settings: it looks for the wrong thing`); + for (const read of reads) assert.ok(properties.includes(read), `${read} is read but not declared`); + }); +}); + +function sourceFiles(folder: string): string[] { + return readdirSync(folder, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? sourceFiles(path.join(folder, entry.name)) : entry.name.endsWith('.ts') ? [path.join(folder, entry.name)] : [])); +} diff --git a/vscode-frysharp/test/unit/outputTabs.test.ts b/vscode-frysharp/test/unit/outputTabs.test.ts new file mode 100644 index 00000000..217f7c37 --- /dev/null +++ b/vscode-frysharp/test/unit/outputTabs.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + applyCarriageReturns, + consoleTextOf, + countLines, + kindOfMimes, + lastLines, + PackedCell, + parseAnsi, + summaryOf, + tabsOf +} from '../../src/shared/outputTabs'; + +const stdout = (text: string) => ({ items: [{ mime: 'application/vnd.code.notebook.stdout', text }] }); +const table = (rows: number) => ({ + items: [ + { mime: 'text/html', text: '
' }, + { mime: 'application/vnd.fry.table+json', text: JSON.stringify({ title: 'T', columns: ['a'], numeric: [true], rows: Array.from({ length: rows }, (_, i) => [i]), totalRows: rows }) }, + { mime: 'text/plain', text: 'T' } + ] +}); + +test('an output is the richest thing it holds: a table with its page and text is a table, a chart with its page is a chart', () => { + assert.equal(kindOfMimes(['text/html', 'application/vnd.fry.table+json', 'text/plain']), 'table'); + assert.equal(kindOfMimes(['text/html', 'application/vnd.fry.chart.v1+json', 'text/plain']), 'chart'); + assert.equal(kindOfMimes(['application/vnd.fry.plot3d.v2+json']), 'plot3d'); + assert.equal(kindOfMimes(['application/vnd.fry.visualizer.v1+json', 'text/plain']), 'visualizer'); + assert.equal(kindOfMimes(['application/vnd.fry.inspector+json', 'text/plain']), 'inspector'); + assert.equal(kindOfMimes(['application/vnd.code.notebook.stderr']), 'console'); + assert.equal(kindOfMimes(['image/png', 'text/plain']), 'image'); + assert.equal(kindOfMimes(['text/markdown', 'text/plain']), 'markdown'); + assert.equal(kindOfMimes(['text/html']), 'html'); + assert.equal(kindOfMimes(['text/plain']), 'console'); + assert.equal(kindOfMimes(['application/vnd.plotly.v1+json']), undefined); +}); + +test('the tabs of a cell are the desktop\'s: Table with its rows, Console with its lines, then All', () => { + const cell: PackedCell = { v: 1, outputs: [stdout('one\ntwo\n'), table(8), stdout('three\n')] }; + + const tabs = tabsOf(cell); + + assert.deepStrictEqual(tabs.map((t) => [t.id, t.label, t.count]), [['table', 'Table', 8], ['console', 'Console', 3], ['all', 'All', undefined]]); + assert.deepStrictEqual(tabs[0].outputs, [1]); + assert.deepStrictEqual(tabs[1].outputs, [0, 2]); + assert.deepStrictEqual(tabs[2].outputs, [0, 1, 2]); +}); + +test('one kind of output has one tab and no All; several outputs of a kind are counted', () => { + assert.deepStrictEqual(tabsOf({ v: 1, outputs: [stdout('x')] }).map((t) => t.id), ['console']); + const charts = tabsOf({ v: 1, outputs: [{ items: [{ mime: 'application/vnd.fry.chart.v1+json', text: '{}' }] }, { items: [{ mime: 'application/vnd.fry.chart.v1+json', text: '{}' }] }, stdout('a')] }); + assert.deepStrictEqual(charts.map((t) => [t.id, t.count]), [['console', 1], ['chart', 2], ['all', undefined]]); +}); + +test('a line count does not count the line break at the end, and a console tab counts lines left off the start', () => { + assert.equal(countLines(''), 0); + assert.equal(countLines('a'), 1); + assert.equal(countLines('a\n'), 1); + assert.equal(countLines('a\nb\n'), 2); + const cell: PackedCell = { v: 1, outputs: [{ items: [{ mime: 'application/vnd.code.notebook.stdout', text: 'c\nd\n' }], omitted: 98 }, table(1)] }; + assert.equal(tabsOf(cell)[1].count, 100); +}); + +test('the console text of an output is what was printed, or the plain text of a display', () => { + assert.equal(consoleTextOf(stdout('hi')), 'hi'); + assert.equal(consoleTextOf({ items: [{ mime: 'text/plain', text: 'plain' }] }), 'plain'); + assert.equal(consoleTextOf({ items: [{ mime: 'application/vnd.code.notebook.stdout', text: 'out' }, { mime: 'application/vnd.code.notebook.stderr', text: 'err' }] }), 'outerr'); +}); + +test('the last lines of a text keep the end and say how many were left off', () => { + assert.deepStrictEqual(lastLines('1\n2\n3\n4\n5\n', 2), { text: '4\n5\n', omitted: 3 }); + assert.deepStrictEqual(lastLines('1\n2\n3', 2), { text: '2\n3', omitted: 1 }); + assert.deepStrictEqual(lastLines('1\n2\n', 5), { text: '1\n2\n', omitted: 0 }); +}); + +test('the summary is a line of what the tabs hold, then the start of the console', () => { + const cell: PackedCell = { v: 1, outputs: [stdout('a\nb\n'), table(3)] }; + + assert.equal(summaryOf(cell), '[Table (3) · Console (2)]\na\nb'); + assert.ok(summaryOf({ v: 1, outputs: [stdout(Array.from({ length: 100 }, (_, i) => `l${i}`).join('\n')), table(1)] }, 5).endsWith('\n…')); +}); + +test('ANSI colours and styles become runs of text, and other escapes are dropped', () => { + const spans = parseAnsi('plain \u001b[1;31mred bold\u001b[0m \u001b[4mline\u001b[24m \u001b[38;5;202morange\u001b[39m \u001b[38;2;1;2;3mrgb\u001b[0m\u001b[2K\u001b[?25h end'); + + assert.deepStrictEqual(spans.map((s) => s.text), ['plain ', 'red bold', ' ', 'line', ' ', 'orange', ' ', 'rgb', ' end']); + assert.equal(spans[1].bold, true); + assert.match(spans[1].fg!, /ansiRed/); + assert.equal(spans[3].underline, true); + assert.equal(spans[4].underline, undefined); + assert.equal(spans[5].fg, 'rgb(255, 95, 0)'); + assert.equal(spans[7].fg, 'rgb(1, 2, 3)'); + assert.equal(spans[8].fg, undefined); + assert.deepStrictEqual(parseAnsi('no escapes').map((s) => s.text), ['no escapes']); + assert.deepStrictEqual(parseAnsi(''), []); +}); + +test('a carriage return redraws a line, as a progress bar does', () => { + assert.equal(applyCarriageReturns('10%\r50%\r100%\ndone\n'), '100%\ndone\n'); + assert.equal(applyCarriageReturns('long line\rshort'), 'shortline'); + assert.equal(applyCarriageReturns('a\r\nb'), 'a\nb'); + assert.equal(applyCarriageReturns('nothing'), 'nothing'); +}); diff --git a/vscode-frysharp/test/unit/protocol.contract.test.ts b/vscode-frysharp/test/unit/protocol.contract.test.ts new file mode 100644 index 00000000..8a0d44d9 --- /dev/null +++ b/vscode-frysharp/test/unit/protocol.contract.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; + +// The extension's protocol.ts mirrors the sidecar's Protocol.cs by hand. This reads both and fails when they drift: +// a field the extension has that the engine does not, a field the engine requires that the extension does not know, +// or a method or notification the extension names that the engine does not have. +const root = path.resolve(__dirname, '../../..'); +const csharp = fs.readFileSync(path.join(root, '..', 'FrySharp.Sidecar', 'Protocol', 'Protocol.cs'), 'utf8'); +const rpc = fs.readFileSync(path.join(root, '..', 'FrySharp.Sidecar', 'Sessions', 'SidecarRpc.cs'), 'utf8'); +const typescript = fs.readFileSync(path.join(root, '..', 'vscode-frysharp', 'src', 'sidecar', 'protocol.ts'), 'utf8'); + +interface Field { + name: string; + optional: boolean; +} + +const camel = (name: string) => name[0].toLowerCase() + name.slice(1); + +function splitTopLevel(text: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const char of text) { + if ('<([{'.includes(char)) depth++; + if ('>)]}'.includes(char)) depth--; + if (char === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + if (current.trim()) parts.push(current); + return parts; +} + +function csharpRecords(): Map { + const records = new Map(); + for (const match of csharp.matchAll(/public sealed record (\w+)\s*\(([^;]*?)\)\s*;/gs)) { + const fields = splitTopLevel(match[2]).map((parameter): Field => { + const [declaration, defaultValue] = parameter.split('='); + const words = declaration.trim().split(/\s+/); + return { name: camel(words[words.length - 1]), optional: defaultValue !== undefined || /\?\s+\w+$/.test(declaration.trim()) }; + }); + records.set(match[1], fields); + } + return records; +} + +function typescriptInterfaces(): Map { + const interfaces = new Map(); + const header = /export interface (\w+)(?:\s+extends\s+\w+)?\s*\{/g; + for (let match = header.exec(typescript); match; match = header.exec(typescript)) { + let depth = 1; + let index = header.lastIndex; + const start = index; + while (depth > 0 && index < typescript.length) { + if (typescript[index] === '{') depth++; + if (typescript[index] === '}') depth--; + index++; + } + const body = typescript.slice(start, index - 1); + // only the top level of the body: fields of nested object types belong to another shape + let level = 0; + let top = ''; + for (const char of body) { + if (char === '{') level++; + if (level === 0) top += char; + if (char === '}') level--; + } + const fields = [...top.matchAll(/^\s*(\w+)(\?)?\s*:/gm)].map((field): Field => ({ name: field[1], optional: field[2] === '?' })); + interfaces.set(match[1], fields); + } + return interfaces; +} + +describe('protocol.ts and Protocol.cs', () => { + const records = csharpRecords(); + const interfaces = typescriptInterfaces(); + + it('read both files (so the checks below mean something)', () => { + assert.ok(records.size > 30, `${records.size} C# records`); + assert.ok(interfaces.size > 10, `${interfaces.size} TypeScript interfaces`); + assert.deepStrictEqual(records.get('ExecuteCellParams')?.map((f) => f.name), ['path', 'cellId', 'source', 'language', 'notebookId', 'workingDirectory', 'executionId']); + }); + + it('every interface of the extension that is a record of the engine has fields the engine has', () => { + const mirrored = [...interfaces.keys()].filter((name) => records.has(name)); + assert.ok(mirrored.length >= 12, mirrored.join(', ')); + + for (const name of mirrored) { + const engine = new Set(records.get(name)!.map((f) => f.name)); + for (const field of interfaces.get(name)!) { + assert.ok(engine.has(field.name), `${name}.${field.name} is in protocol.ts, not in Protocol.cs`); + } + } + }); + + it('every field the engine needs in a request the extension sends is in the extension\'s interface', () => { + for (const name of ['ExecuteCellParams', 'RunStartParams', 'InitializeParams', 'NotebookRef']) { + const ours = new Set(interfaces.get(name)!.map((f) => f.name)); + for (const field of records.get(name)!.filter((f) => !f.optional)) { + assert.ok(ours.has(field.name), `${name}.${field.name} is required by the engine and missing from protocol.ts`); + } + } + }); + + it('every field of an answer or notification the extension reads is optional or there in its interface', () => { + for (const name of ['InitializeResult', 'ExecuteCellResult', 'NotebookOutputNotification', 'CellStateNotification', 'RunStartResult', 'RunOutputNotification', 'RunExitedNotification', 'VariableDto', 'LanguageInfo', 'DiagnosticDto', 'InputRequest', 'CellOutput']) { + const ours = new Set(interfaces.get(name)!.map((f) => f.name)); + for (const field of records.get(name)!) { + assert.ok(ours.has(field.name), `${name}.${field.name} is sent by the engine and missing from protocol.ts`); + } + } + }); + + it('every method and notification the extension names exists in the engine', () => { + const engineMethods = new Set([...rpc.matchAll(/JsonRpcMethod\("([^"]+)"/g)].map((m) => m[1])); + const engineNotifications = new Set([...csharp.matchAll(/const string \w+ = "(fry\/[^"]+)"/g)].map((m) => m[1])); + const sources = ['src/sidecar/sidecarClient.ts', 'src/extension.ts', 'src/notebook/notebookController.ts'].map((file) => fs.readFileSync(path.join(root, '..', 'vscode-frysharp', file), 'utf8')).join('\n'); + + const requested = [...sources.matchAll(/(?:request|sendRequest)(?:<[^>(]*>)?\(\s*'(fry\/[^']+)'/g)].map((m) => m[1]); + const notified = [...sources.matchAll(/on(?:Notification|Request)\(\s*'(fry\/[^']+)'/g)].map((m) => m[1]); + assert.ok(requested.length >= 7, requested.join(', ')); + assert.ok(notified.length >= 5, notified.join(', ')); // four notifications, and the engine's request for input + + for (const method of requested) assert.ok(engineMethods.has(method), `${method} is requested by the extension and is not a method of the engine`); + for (const method of notified) assert.ok(engineNotifications.has(method), `${method} is listened to by the extension and is not a notification of the engine`); + }); +}); diff --git a/vscode-frysharp/test/unit/rendererModels.test.ts b/vscode-frysharp/test/unit/rendererModels.test.ts new file mode 100644 index 00000000..021acacf --- /dev/null +++ b/vscode-frysharp/test/unit/rendererModels.test.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { chooseDrawing } from '../../src/renderer/chooseMime'; +import { assetNames, hexColour, inlineAssets, isFullDocument, themeOf, withHost, withProbe } from '../../src/renderer/htmlPrepare'; +import { canOpen, kindOf, startsOpen, toText } from '../../src/renderer/inspectorModel'; +import { footer, nextSort, numberOf, TableData, toCsv, toJson, visibleRows } from '../../src/renderer/tableModel'; + +const people: TableData = { + title: 'People', + columns: ['Name', 'Age', 'City'], + numeric: [false, true, false], + rows: [['Grace', '45', 'Arlington'], ['Ada', '36', null], ['Linus', '1,200', 'Helsinki, FI'], ['Alan', 'n/a', 'London']] +}; + +describe('table: which rows show, and in what order', () => { + it('shows every row as they came', () => { + assert.deepStrictEqual(visibleRows(people, ''), [0, 1, 2, 3]); + }); + + it('filters on any cell, ignoring case and spaces around the text', () => { + assert.deepStrictEqual(visibleRows(people, ' LON '), [3]); + assert.deepStrictEqual(visibleRows(people, 'a'), [0, 1, 3]); // Linus, 1,200, Helsinki, FI has no "a" + assert.deepStrictEqual(visibleRows(people, 'helsinki'), [2]); + assert.deepStrictEqual(visibleRows(people, 'nothing'), []); + }); + + it('sorts a numeric column by value, with "1,200" a number, and what is not a number after the numbers it can compare', () => { + assert.deepStrictEqual(visibleRows(people, '', { column: 1, direction: 'asc' }).slice(0, 3), [1, 0, 2]); + assert.deepStrictEqual(visibleRows(people, '', { column: 1, direction: 'desc' }), [2, 0, 1, 3]); // "n/a" is last either way + assert.equal(visibleRows(people, '', { column: 1, direction: 'asc' })[3], 3); + }); + + it('sorts a text column by its text, naturally', () => { + assert.deepStrictEqual(visibleRows(people, '', { column: 0, direction: 'asc' }), [1, 3, 0, 2]); + const numbered: TableData = { columns: ['x'], rows: [['item 10'], ['item 2'], ['item 1']] }; + assert.deepStrictEqual(visibleRows(numbered, '', { column: 0, direction: 'asc' }), [2, 1, 0]); + }); + + it('puts an empty cell last whichever way it sorts, and keeps equal rows as they came', () => { + assert.equal(visibleRows(people, '', { column: 2, direction: 'asc' })[3], 1); + assert.equal(visibleRows(people, '', { column: 2, direction: 'desc' })[3], 1); + const same: TableData = { columns: ['k'], rows: [['a'], ['a'], ['a']] }; + assert.deepStrictEqual(visibleRows(same, '', { column: 0, direction: 'desc' }), [0, 1, 2]); + }); + + it('filters and sorts together', () => { + assert.deepStrictEqual(visibleRows(people, 'a', { column: 0, direction: 'desc' }), [0, 3, 1]); // Grace, Alan, Ada + }); + + it('goes ascending, then descending, then as it came, as a header is clicked', () => { + const first = nextSort(undefined, 1); + const second = nextSort(first, 1); + assert.deepStrictEqual([first, second, nextSort(second, 1)], [{ column: 1, direction: 'asc' }, { column: 1, direction: 'desc' }, undefined]); + assert.deepStrictEqual(nextSort(second, 2), { column: 2, direction: 'asc' }); + }); + + it('reads numbers as the engine writes them', () => { + assert.equal(numberOf('1,234.5'), 1234.5); + assert.equal(numberOf('-3'), -3); + assert.equal(numberOf('12 %'), 12); + assert.equal(numberOf('$7'), 7); + assert.equal(numberOf('1e3'), 1000); + assert.equal(numberOf('n/a'), undefined); + assert.equal(numberOf(''), undefined); + assert.equal(numberOf(null), undefined); + assert.equal(numberOf(true), undefined); + }); +}); + +describe('table: copying', () => { + it('writes CSV with quotes where a cell needs them, for the rows shown in the order shown', () => { + assert.equal(toCsv(people, [2, 1]), 'Name,Age,City\nLinus,"1,200","Helsinki, FI"\nAda,36,'); + assert.equal(toCsv({ columns: ['q'], rows: [['say "hi"'], ['two\nlines']] }, [0, 1]), 'q\n"say ""hi"""\n"two\nlines"'); + }); + + it('writes JSON as a list of records, a number a number where the column is numeric', () => { + assert.deepStrictEqual(JSON.parse(toJson(people, [1, 3])), [ + { Name: 'Ada', Age: 36, City: null }, + { Name: 'Alan', Age: 'n/a', City: 'London' } + ]); + }); +}); + +describe('table: the footer', () => { + it('says how many rows show, and when there are more than were sent', () => { + assert.equal(footer(people, 4, false), 'Showing 4 of 4 rows'); + assert.equal(footer(people, 1, true), 'Showing 1 of 4 rows (filtered)'); + assert.equal(footer({ ...people, totalRows: 5000 }, 4, false), 'Showing the first 4 of 5,000 rows'); + }); +}); + +describe('Object Explorer values and nodes', () => { + it('colours a value by its kind', () => { + assert.equal(kindOf({ isNull: true }), 'null'); + assert.equal(kindOf({ value: 'Code', isString: true }), 'string'); + assert.equal(kindOf({ value: '42' }), 'number'); + assert.equal(kindOf({ value: '-3.5e2' }), 'number'); + assert.equal(kindOf({ value: '1,200' }), 'number'); + assert.equal(kindOf({ value: '∞' }), 'number'); + assert.equal(kindOf({ value: 'True' }), 'boolean'); + assert.equal(kindOf({ value: '[ 1, 2, 3 ]' }), 'list'); + assert.equal(kindOf({ value: '2026-10-14' }), 'plain'); + assert.equal(kindOf({ value: '42', isString: true }), 'string'); // the text "42" is text + }); + + it('opens the top node, and those below it unless they close a cycle', () => { + assert.equal(startsOpen(0, {}), true); + assert.equal(startsOpen(1, {}), true); + assert.equal(startsOpen(1, { circular: true }), false); + assert.equal(startsOpen(2, {}), false); + }); + + it('opens whatever has members, a reference back to an object above included, and nothing else', () => { + assert.equal(canOpen({ properties: [] }), false); + assert.equal(canOpen({ properties: [{ name: 'a' }] }), true); + assert.equal(canOpen({ properties: [{ name: 'Name', value: 'x' }] }), true); + }); + + it('writes a node as text, children indented', () => { + const lines = toText({ title: 'People', properties: [{ name: 'Name', value: 'Code', isString: true }, { name: 'Other', isNull: true }, { name: 'Self', child: { title: 'People', circular: true, properties: [] } }] }); + assert.deepStrictEqual(lines, ['People', ' Name = Code', ' Other = ', ' Self:', ' People (circular reference)']); + }); +}); + +describe('what a display is drawn as', () => { + it('takes the richest of what the bundle holds', () => { + assert.deepStrictEqual(chooseDrawing(['text/html', 'application/vnd.fry.table+json', 'text/plain']), { drawing: 'table', mime: 'application/vnd.fry.table+json' }); + assert.deepStrictEqual(chooseDrawing(['text/plain', 'text/html']), { drawing: 'html', mime: 'text/html' }); + assert.deepStrictEqual(chooseDrawing(['application/vnd.fry.inspector+json', 'text/html']), { drawing: 'inspector', mime: 'application/vnd.fry.inspector+json' }); + assert.deepStrictEqual(chooseDrawing(['image/png', 'text/plain']), { drawing: 'image', mime: 'image/png' }); + assert.equal(chooseDrawing(['application/x-unknown']), undefined); + }); +}); + +describe('getting an engine page ready', () => { + const chart = '\nt\n\n
'; + + it('finds the libraries a page names, in order', () => { + assert.deepStrictEqual(assetNames('

'), ['echarts.min.js', 'echarts-gl.min.js']); + assert.deepStrictEqual(assetNames(''), []); + }); + + it('tells a whole page from a piece of one', () => { + assert.equal(isFullDocument(chart), true); + assert.equal(isFullDocument(' '), true); + assert.equal(isFullDocument(''), true); + assert.equal(isFullDocument('

'), false); + }); + + it('puts a library where its tag stands, and cannot be ended early by the library', () => { + const page = inlineAssets(chart, new Map([['echarts.min.js', 'var echarts={};/**/']])); + assert.ok(!page.includes('data-fry-asset')); + assert.ok(page.includes('')); + assert.equal(inlineAssets(chart, new Map()), chart); // no text for it: the tag stays + }); + + it('sets what the editor looks like before the page\'s own scripts', () => { + const page = withHost(chart, { theme: 'dark', background: '#1e1e1e' }); + assert.ok(page.indexOf('window.fryHost={theme:"dark",background:"#1e1e1e"}') < page.indexOf('data-fry-asset')); + assert.ok(withHost('

x

', { theme: 'light' }).startsWith('')); + assert.ok(!withHost('

x

', { theme: 'light', background: 'red;alert(1)' }).includes('background')); + }); + + it('reads the editor\'s theme and colours', () => { + assert.equal(themeOf('vscode-light'), 'light'); + assert.equal(themeOf('vscode-high-contrast-light'), 'light'); + assert.equal(themeOf('vscode-dark'), 'dark'); + assert.equal(themeOf('vscode-high-contrast'), 'dark'); + assert.equal(themeOf(undefined), 'dark'); + assert.equal(hexColour('#1E1E1E'), '#1E1E1E'); + assert.equal(hexColour('#fff'), '#ffffff'); + assert.equal(hexColour('rgb(30, 30, 30)'), '#1e1e1e'); + assert.equal(hexColour('var(--x)'), undefined); + }); +}); + +describe('knowing a chart drew', () => { + it('adds a probe before the end of the page that names its frame', () => { + const page = withProbe('
', 'tok'); + assert.ok(page.indexOf('fryProbe:"tok"') > page.indexOf('
')); + assert.ok(page.indexOf('fryProbe') < page.indexOf('')); + assert.ok(page.includes('window.__fryChart')); + }); + + it('adds it at the end of a page that has no body to end, and cannot be broken out of by its token', () => { + assert.ok(withProbe('

x

', 't').endsWith('')); + assert.ok(withProbe('', '"};alert(1);//').includes('fryProbe:"\\"};alert(1);//"')); // the quote is escaped: it stays inside the string + assert.ok(!withProbe('', '').includes('', data: { 'text/html': '' } }], sources); + + assert.ok(!page.includes('' }]); + assert.ok(!json.includes('<')); + assert.deepStrictEqual(JSON.parse(json), [{ t: '' }]); + }); + + it('writes the title as text, not as markup, and says back what it drew', () => { + const page = resultsHtml([], sources); + assert.match(page, /title\.textContent = item\.title/); + assert.match(page, /postMessage\(\{ type: 'rendered'/); + assert.match(page, /postMessage\(\{ type: 'error'/); + }); +}); diff --git a/vscode-frysharp/test/unit/runLinks.test.ts b/vscode-frysharp/test/unit/runLinks.test.ts new file mode 100644 index 00000000..a96ff88c --- /dev/null +++ b/vscode-frysharp/test/unit/runLinks.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { findLinks } from '../../src/run/runLinks'; + +const place = (line: string) => findLinks(line).map((l) => [line.slice(l.start, l.start + l.length), l.file, l.line, l.column]); + +describe('places a line of output names', () => { + it('finds a .NET error: file(line,column)', () => { + assert.deepStrictEqual(place('Program.cs(12,5): error CS1002: ; expected'), [['Program.cs(12,5)', 'Program.cs', 12, 5]]); + assert.deepStrictEqual(place('src/Models/Order.cs(3,1): warning CS8618'), [['src/Models/Order.cs(3,1)', 'src/Models/Order.cs', 3, 1]]); + }); + + it('finds a Python traceback line', () => { + assert.deepStrictEqual(place(' File "/work/tool.py", line 3, in '), [['File "/work/tool.py", line 3', '/work/tool.py', 3, 1]]); + }); + + it('finds file:line:column, as Node, Go and Rust write it', () => { + assert.deepStrictEqual(place(' at run (/work/app.js:10:15)'), [['/work/app.js:10:15', '/work/app.js', 10, 15]]); + assert.deepStrictEqual(place('./main.go:5:2: undefined: x'), [['./main.go:5:2', './main.go', 5, 2]]); + assert.deepStrictEqual(place(' --> src/main.rs:3:5'), [['src/main.rs:3:5', 'src/main.rs', 3, 5]]); + assert.deepStrictEqual(place('script.py:7'), [['script.py:7', 'script.py', 7, 1]]); + }); + + it('finds a Windows path', () => { + assert.deepStrictEqual(place('C:\\work\\Program.cs(4,9): error CS0103'), [['C:\\work\\Program.cs(4,9)', 'C:\\work\\Program.cs', 4, 9]]); + assert.deepStrictEqual(place('at C:\\work\\app.js:2:3'), [['C:\\work\\app.js:2:3', 'C:\\work\\app.js', 2, 3]]); + }); + + it('finds several in one line, in order', () => { + assert.deepStrictEqual(place('a.cs(1,1) calls b.cs(2,2)').map((l) => l[1]), ['a.cs', 'b.cs']); + }); + + it('does not make a link of a time, a version, a URL or a sentence', () => { + for (const line of ['12:30:45 started', 'version 1.2.3 ready', 'see https://example.com:8080/path', 'Finished in 3.5 s: ok', 'x = 3', 'line 5']) { + assert.deepStrictEqual(place(line), [], line); + } + }); + + it('does not link line 0', () => { + assert.deepStrictEqual(place('thing.cs(0,0)'), []); + }); +}); diff --git a/vscode-frysharp/test/unit/runtime.test.ts b/vscode-frysharp/test/unit/runtime.test.ts new file mode 100644 index 00000000..f67b1fff --- /dev/null +++ b/vscode-frysharp/test/unit/runtime.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { DotnetMissingError, hasRuntime, resolveDotnet } from '../../src/sidecar/runtime'; + +const listing = (...versions: string[]) => versions.map((v) => `Microsoft.NETCore.App ${v} [/usr/share/dotnet/shared/Microsoft.NETCore.App]`).join('\n'); + +describe('the .NET runtime', () => { + it('is found in the output of dotnet --list-runtimes: the shared runtime, version 10 or newer', () => { + assert.equal(hasRuntime(listing('8.0.1', '10.0.0')), true); + assert.equal(hasRuntime(listing('11.0.0-preview.1.12345.1')), true); + assert.equal(hasRuntime(listing('8.0.1', '9.0.4')), false); + assert.equal(hasRuntime('Microsoft.AspNetCore.App 10.0.0 [/x]\nMicrosoft.WindowsDesktop.App 10.0.0 [/x]'), false); + assert.equal(hasRuntime(''), false); + assert.equal(hasRuntime('Microsoft.NETCore.App 10.0.0 [/x]\r\n', 10), true); + }); + + it('takes the dotnet on the PATH when it is new enough (this machine has .NET 10: the tests need it)', async () => { + const asked: string[] = []; + const dotnet = await resolveDotnet({ acquire: async () => (asked.push('acquire'), '/downloaded/dotnet'), log: () => undefined }); + + assert.equal(dotnet, 'dotnet'); + assert.deepStrictEqual(asked, []); // nothing was downloaded + }); + + it('a setting that cannot be run is an error that names it, not a quiet fallback', async () => { + await assert.rejects(resolveDotnet({ setting: '/no/such/dotnet', log: () => undefined }), (error: Error) => error instanceof DotnetMissingError && /frysharp\.dotnetPath.*\/no\/such\/dotnet/.test(error.message)); + }); +}); diff --git a/vscode-frysharp/test/unit/sequencedQueue.test.ts b/vscode-frysharp/test/unit/sequencedQueue.test.ts new file mode 100644 index 00000000..14ed1b0a --- /dev/null +++ b/vscode-frysharp/test/unit/sequencedQueue.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { SequencedQueue } from '../../src/notebook/sequencedQueue'; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); + +describe('handling a run\'s notifications in order', () => { + it('handles them in the order they were made, whatever order they arrive in', async () => { + const seen: string[] = []; + const queue = new SequencedQueue(async (item) => void seen.push(item)); + + queue.push(3, 'c'); + queue.push(1, 'a'); + queue.push(2, 'b'); + + assert.equal(await queue.processed(3), true); + assert.deepStrictEqual(seen, ['a', 'b', 'c']); + }); + + it('handles one at a time: a slow one is finished before the next starts', async () => { + const events: string[] = []; + const queue = new SequencedQueue(async (item) => { + events.push(`start ${item}`); + await tick(); + events.push(`end ${item}`); + }); + + queue.push(1, 'a'); + queue.push(2, 'b'); + await queue.processed(2); + + assert.deepStrictEqual(events, ['start a', 'end a', 'start b', 'end b']); + }); + + it('says when everything up to a number is done, and not before', async () => { + const queue = new SequencedQueue(async () => undefined); + let done = false; + const waiting = queue.processed(3).then((result) => ((done = result), result)); + + queue.push(1, 1); + queue.push(3, 3); + await tick(); + assert.equal(done, false); // 2 has not come + + queue.push(2, 2); + assert.equal(await waiting, true); + }); + + it('gives up waiting for one that never comes, and says so', async () => { + const queue = new SequencedQueue(async () => undefined); + queue.push(1, 1); + assert.equal(await queue.processed(2, 30), false); + }); + + it('is done at once for a run that sent nothing', async () => { + assert.equal(await new SequencedQueue(async () => undefined).processed(0), true); + }); + + it('keeps going when one fails, and ignores a repeat', async () => { + const seen: number[] = []; + const queue = new SequencedQueue(async (item) => { + if (item === 2) throw new Error('cannot show this'); + seen.push(item); + }); + + [1, 2, 3, 3, 1].forEach((n) => queue.push(n, n)); + await queue.processed(3); + + assert.deepStrictEqual(seen, [1, 3]); + }); + + it('handles notifications with no number, from an older engine, in the order they came', async () => { + const seen: string[] = []; + const queue = new SequencedQueue(async (item) => { + await tick(); + seen.push(item); + }); + + queue.push(undefined, 'a'); + queue.push(0, 'b'); + await new Promise((resolve) => setTimeout(resolve, 40)); + + assert.deepStrictEqual(seen, ['a', 'b']); + }); +}); diff --git a/vscode-frysharp/test/unit/serializer.test.ts b/vscode-frysharp/test/unit/serializer.test.ts new file mode 100644 index 00000000..210f7438 --- /dev/null +++ b/vscode-frysharp/test/unit/serializer.test.ts @@ -0,0 +1,141 @@ +import './vscodeStub'; // first: it stands in for 'vscode' before the serializer asks for it +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; +import { outputFromDisplay } from '../../src/notebook/format/outputs'; +import { INSPECTOR, STDERR, STDOUT, TABLE } from '../../src/notebook/format/model'; +import { FryNotebookSerializer, toVsCodeOutput } from '../../src/notebook/notebookSerializer'; +import { CELL_OUTPUT_MIME } from '../../src/notebook/format/tabs'; + +const fixtures = path.resolve(__dirname, '../../../test/fixtures'); +const desktop = fs.readFileSync(path.join(fixtures, 'desktop.frynb'), 'utf8'); +const jupyter = fs.readFileSync(path.join(fixtures, 'jupyter.ipynb'), 'utf8'); +const token = {} as never; +const serializer = new FryNotebookSerializer(); + +const open = (json: string) => serializer.deserializeNotebook(new TextEncoder().encode(json), token); +const save = async (data: Parameters[0]) => new TextDecoder().decode(await serializer.serializeNotebook(data, token)); + +describe('the notebook adapter', () => { + it('opens a desktop notebook and saves it back unchanged', async () => { + const saved = JSON.parse(await save(await open(desktop))); + const original = JSON.parse(desktop); + delete saved.LastModified; + delete original.LastModified; + + assert.deepStrictEqual(saved, original); + }); + + it('opens a Jupyter notebook and saves it back unchanged', async () => { + assert.equal(await save(await open(jupyter)), jupyter); + }); + + it('refuses a file that is not a notebook, instead of opening an empty one that a save would write over it', async () => { + await assert.rejects(open('{ this is not json'), /not a FrySharp or Jupyter notebook/); + await assert.rejects(open('[1, 2]'), /not a FrySharp or Jupyter notebook/); + }); + + it('opens an empty file as a new notebook', async () => { + const data = await open(' \n'); + assert.equal(data.cells.length, 0); + assert.equal(data.metadata?.kernel, 'csharp'); + }); + + it('gives a new cell an id, and a copied cell its own', async () => { + const data = await open(desktop); + const first = data.cells[0]; + const copy = Object.assign(Object.create(Object.getPrototypeOf(first)), first, { metadata: { ...first.metadata } }); + const fresh = Object.assign(Object.create(Object.getPrototypeOf(first)), first, { metadata: undefined }); + data.cells.push(copy, fresh); + + const ids = JSON.parse(await save(data)).Cells.map((c: { Id: string }) => c.Id); + assert.equal(new Set(ids).size, ids.length); + assert.equal(ids[0], 'intro'); + assert.ok(ids.slice(-2).every((id: string) => id.length > 0)); + }); + + it('writes the outputs of a run that came from the engine, and what the desktop reads of them', async () => { + const data = await open(desktop); + const cell = data.cells.find((c) => c.metadata?.id === 'text')!; + cell.outputs = [ + toVsCodeOutput(outputFromDisplay({ 'text/html': '
', [TABLE]: { title: 'T', columns: ['A'], numeric: [true], rows: [['1']], totalRows: 1, totalColumns: 1 }, 'text/plain': 'T' })) + ]; + cell.executionSummary = { executionOrder: 9, success: true, timing: { startTime: 1000, endTime: 1042 } }; + + const written = JSON.parse(await save(data)).Cells.find((c: { Id: string }) => c.Id === 'text'); + assert.equal(written.ExecutionCount, 9); + assert.equal(written.ExecutionTimeText, '42 ms'); + assert.equal(written.OutputText, ''); + assert.equal(written.TableSnapshot.Title, 'T'); + assert.equal(written.TableSnapshot.Columns[0].IsNumeric, true); + assert.equal(written.HtmlContent, '
'); + }); + + it('writes a failed run as a failed cell and keeps the language of a cell that is not the notebook\'s', async () => { + const data = await open(desktop); + const cell = data.cells.find((c) => c.metadata?.id === 'text')!; + cell.executionSummary = { executionOrder: 2, success: false }; + cell.languageId = 'javascript'; + + const written = JSON.parse(await save(data)).Cells.find((c: { Id: string }) => c.Id === 'text'); + assert.equal(written.HasError, true); + assert.equal(written.Language, 'javascript'); + }); +}); + +describe('an output from the engine', () => { + it('puts the page first and the text last, and decodes an image', () => { + const output = outputFromDisplay({ 'text/plain': 'x', [INSPECTOR]: { title: 't', properties: [] }, 'image/png': 'iVBORw0KGgo=', 'text/html': '' }); + + assert.deepStrictEqual(output.items.map((i) => i.mime), ['text/html', INSPECTOR, 'image/png', 'text/plain']); + assert.equal(output.items[2].data[0], 0x89); // the PNG signature, not the base64 text + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(output.items[1].data)), { title: 't', properties: [] }); + }); + + it('keeps the metadata an image came with', () => { + assert.deepStrictEqual(outputFromDisplay({ 'image/png': 'AA==' }, { 'image/png': { width: 3, height: 2 } }).metadata, { 'image/png': { width: 3, height: 2 } }); + assert.equal(outputFromDisplay({ 'text/plain': 'x' }, {}).metadata, undefined); + }); + + it('is not what a stream is: text a cell prints stays stdout and stderr', () => { + assert.ok([STDOUT, STDERR].every((mime) => mime.startsWith('application/vnd.code.notebook.'))); + }); +}); + + +describe('a cell with several kinds of output, shown as tabs', () => { + const tabbed = new FryNotebookSerializer('frynb', () => true); + const openTabbed = (json: string) => tabbed.deserializeNotebook(new TextEncoder().encode(json), token); + const saveTabbed = async (data: Parameters[0]) => new TextDecoder().decode(await tabbed.serializeNotebook(data, token)); + + it('opens the desktop notebook\'s text-and-table cell as one output, and saves it back unchanged', async () => { + const data = await openTabbed(desktop); + const cell = data.cells.find((c) => c.metadata?.id === 'table')!; + + assert.equal(cell.outputs!.length, 1); + assert.equal(cell.outputs![0].items[0].mime, CELL_OUTPUT_MIME); + // a cell with one kind of output is left as it is + assert.equal(data.cells.find((c) => c.metadata?.id === 'text')!.outputs!.length, 1); + assert.equal(data.cells.find((c) => c.metadata?.id === 'text')!.outputs![0].items[0].mime, STDOUT); + + const saved = JSON.parse(await saveTabbed(data)); + const original = JSON.parse(desktop); + delete saved.LastModified; + delete original.LastModified; + assert.deepStrictEqual(saved, original); + }); + + it('is the same file whether the tabs are on or off', async () => { + const withTabs = JSON.parse(await saveTabbed(await openTabbed(desktop))); + const without = JSON.parse(await save(await open(desktop))); + delete withTabs.LastModified; + delete without.LastModified; + + assert.deepStrictEqual(withTabs, without); + }); + + it('opens a Jupyter notebook with tabs and saves it back byte for byte', async () => { + assert.equal(await saveTabbed(await openTabbed(jupyter)), jupyter); + }); +}); diff --git a/vscode-frysharp/test/unit/sidecarClient.test.ts b/vscode-frysharp/test/unit/sidecarClient.test.ts new file mode 100644 index 00000000..3b0a4f35 --- /dev/null +++ b/vscode-frysharp/test/unit/sidecarClient.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; +import { SidecarClient, SidecarState } from '../../src/sidecar/sidecarClient'; +import { resolveSidecarLaunch } from '../../src/sidecar/launcher'; + +// The client against the real engine (FrySharp.Sidecar, built with `dotnet build FrySharp.Sidecar`): start, ask, kill, +// ask again. FRY_REQUIRE_SIDECAR=1 makes a missing build a failure rather than a skip. +const dll = path.resolve(__dirname, '../../../../FrySharp.Sidecar/bin/Debug/net10.0/frysharp-sidecar.dll'); +const skip = fs.existsSync(dll) || process.env.FRY_REQUIRE_SIDECAR === '1' ? false : 'Build the sidecar first: dotnet build FrySharp.Sidecar'; + +function newClient(workspace: string, log: string[] = []) { + return new SidecarClient({ + log: (message) => log.push(message), + prepare: async () => { + const launch = resolveSidecarLaunch({ setting: dll, extensionPath: '', development: false, exists: fs.existsSync }, workspace); + return { command: 'dotnet', args: launch.args, cwd: workspace }; + } + }); +} + +const workspace = () => fs.mkdtempSync(path.join(os.tmpdir(), 'frysharp-client-')); + +describe('the sidecar client, against the real engine', { skip }, () => { + it('starts when first asked, once, however many ask at the same time; and says what the engine can do', async () => { + const client = newClient(workspace()); + const states: SidecarState[] = []; + client.onDidChangeState((state) => states.push(state)); + try { + assert.equal(client.state, 'stopped'); + const [a, b] = await Promise.all([client.ensureStarted(), client.ensureStarted()]); + + assert.strictEqual(a, b); + assert.deepStrictEqual(states, ['starting', 'ready']); + assert.ok(client.languages.some((language) => language.id === 'csharp' && language.canRunCells)); + } finally { + client.dispose(); + } + }); + + it('runs a cell of a notebook it names by id, with the output in a notification', async () => { + const client = newClient(workspace()); + const output: string[] = []; + client.onNotebookOutput((n) => n.output.text && output.push(n.output.text)); + try { + const result = await client.executeCell({ path: 'nb.frynb', cellId: 'a', source: 'Console.WriteLine("from a held notebook");', language: 'csharp', notebookId: 'file:///x/nb.frynb' }); + + assert.equal(result.success, true); + assert.ok(output.join('').includes('from a held notebook')); + } finally { + client.dispose(); + } + }); + + it('notices that the engine died, says the notebooks\' state is gone, and starts it again on the next request', async () => { + const log: string[] = []; + const client = newClient(workspace(), log); + const lost: string[] = []; + client.onDidLoseState(({ reason }) => lost.push(reason)); + try { + await client.executeCell({ path: '', cellId: '1', source: 'int kept = 1;', language: 'csharp', notebookId: 'nb' }); + const pid = (client as unknown as { child: { pid: number; kill(): void } }).child.pid; + + process.kill(pid, 'SIGKILL'); + await waitFor(() => client.state === 'crashed'); + assert.equal(lost.length, 1); + assert.match(lost[0], /stopped unexpectedly/); + + // the next request starts a new engine: with a new, empty notebook + const again = await client.executeCell({ path: '', cellId: '2', source: 'Console.WriteLine(kept);', language: 'csharp', notebookId: 'nb' }); + assert.equal(again.success, false); + assert.equal(client.state, 'ready'); + assert.equal(lost.length, 1); // said once, when it happened, not again when it was started + } finally { + client.dispose(); + } + }); + + it('gives every request a deadline, and a cell none', async () => { + const client = newClient(workspace()); + try { + await client.ensureStarted(); + const slow = client.request('fry/notebook/execute', { path: '', cellId: 's', source: 'System.Threading.Thread.Sleep(4000);', language: 'csharp', notebookId: 'slow' }, { timeoutMs: 300 }); + await assert.rejects(slow, /did not answer fry\/notebook\/execute within/); + + const cell = await client.executeCell({ path: '', cellId: 'c', source: 'System.Threading.Thread.Sleep(600);', language: 'csharp', notebookId: 'slow2' }); + assert.equal(cell.success, true); + } finally { + client.dispose(); + } + }); + + it('restart() stops the engine and starts another', async () => { + const client = newClient(workspace()); + try { + await client.ensureStarted(); + const first = (client as unknown as { child: { pid: number } }).child.pid; + const lost: string[] = []; + client.onDidLoseState(({ reason }) => lost.push(reason)); + await client.restart(); + + assert.equal(lost.length, 0); // asked for, so nothing to warn about + assert.equal(client.state, 'ready'); + assert.notEqual((client as unknown as { child: { pid: number } }).child.pid, first); + } finally { + client.dispose(); + } + }); + + it('does not start again once disposed', async () => { + const client = newClient(workspace()); + client.dispose(); + await assert.rejects(client.ensureStarted(), /disposed/); + }); +}); + +async function waitFor(condition: () => boolean, ms = 10_000): Promise { + const until = Date.now() + ms; + while (!condition()) { + if (Date.now() > until) throw new Error('timed out waiting'); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} diff --git a/vscode-frysharp/test/unit/statusAndVariables.test.ts b/vscode-frysharp/test/unit/statusAndVariables.test.ts new file mode 100644 index 00000000..e015b347 --- /dev/null +++ b/vscode-frysharp/test/unit/statusAndVariables.test.ts @@ -0,0 +1,36 @@ +import './vscodeStub'; // first: it stands in for 'vscode' before the modules ask for it +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { describeEngine } from '../../src/notebook/statusBar'; +import { preview } from '../../src/notebook/variablesTree'; + +describe('the engine in the status bar', () => { + it('says what state it is in', () => { + assert.match(describeEngine('stopped', 0).text, /FrySharp$/); + assert.match(describeEngine('starting', 0).text, /starting/); + assert.match(describeEngine('ready', 0).text, /ready/); + assert.match(describeEngine('crashed', 0).text, /stopped/); + }); + + it('says a cell is running, and how many when more than one is', () => { + assert.match(describeEngine('ready', 1).text, /running$/); + assert.match(describeEngine('ready', 3).text, /running \(3\)/); + }); + + it('says what stopping loses', () => { + assert.match(describeEngine('crashed', 0).tooltip, /variables are gone/); + }); +}); + +describe('a variable\'s value in a line of the tree', () => { + it('is on one line, whatever it was', () => { + assert.equal(preview('line one\n line two\r\n\tline three'), 'line one line two line three'); + }); + + it('is cut where there is no more room, and says so', () => { + const cut = preview('x'.repeat(200), 20); + assert.equal(cut.length, 20); + assert.ok(cut.endsWith('…')); + assert.equal(preview('short'), 'short'); + }); +}); diff --git a/vscode-frysharp/test/unit/tabs.test.ts b/vscode-frysharp/test/unit/tabs.test.ts new file mode 100644 index 00000000..7927f288 --- /dev/null +++ b/vscode-frysharp/test/unit/tabs.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { CELL_OUTPUT_MIME, canPack, forShowing, pack, unpack, unpackAll } from '../../src/notebook/format/tabs'; +import { CellOutput, jsonItem, STDERR, STDOUT, TABLE, textItem } from '../../src/notebook/format/model'; +import { PackedCell } from '../../src/shared/outputTabs'; + +const stdout = (value: string): CellOutput => ({ items: [textItem(STDOUT, value)] }); +const table: CellOutput = { items: [textItem('text/html', '
'), jsonItem(TABLE, { title: 'T', columns: ['a'], numeric: [true], rows: [[1], [2]], totalRows: 2 }), textItem('text/plain', 'T')] }; +const png: CellOutput = { items: [{ mime: 'image/png', data: Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0, 255, 128]) }, textItem('text/plain', '')], metadata: { 'image/png': { width: 4, height: 2 } } }; + +test('console text and a table are two kinds, so they are one output; one kind of output is left as it is', () => { + assert.equal(canPack([stdout('a'), table]), true); + assert.equal(canPack([stdout('a'), stdout('b')]), false); + assert.equal(canPack([table]), false); + assert.equal(canPack([]), false); +}); + +test('an output of a kind the tabs cannot draw is not packed (it has a way of drawing it already)', () => { + const widget: CellOutput = { items: [textItem('application/vnd.jupyter.widget-view+json', '{}')] }; + assert.equal(canPack([stdout('a'), widget]), false); + assert.equal(canPack([stdout('a'), table, widget]), false); +}); + +test('an error after the outputs stays after the tabs, as VS Code shows it; an error among them means they are left as they are', () => { + const error: CellOutput = { items: [textItem('application/vnd.code.notebook.error', '{"name":"E","message":"m"}')] }; + + assert.equal(canPack([stdout('a'), table, error]), true); + const shown = forShowing([stdout('a'), table, error], true); + assert.equal(shown.length, 2); + assert.equal(shown[0].items[0].mime, CELL_OUTPUT_MIME); + assert.deepStrictEqual(shown[1], error); + assert.deepStrictEqual(unpackAll(shown), [stdout('a'), table, error]); + + // an error with only one kind before it: nothing to tab + assert.equal(canPack([stdout('a'), error]), false); + assert.equal(canPack([stdout('a'), error, table]), false); + assert.deepStrictEqual(forShowing([stdout('a'), error, table], true), [stdout('a'), error, table]); +}); + +test('packing and unpacking give back the same outputs: items, bytes, metadata and order', () => { + const outputs = [stdout('one\ntwo ✓ \u65e5\u672c\n'), table, png, { items: [textItem(STDERR, 'oops\n')] }, stdout('after')]; + + const packed = pack(outputs); + + assert.equal(packed.items[0].mime, CELL_OUTPUT_MIME); + assert.equal(packed.items[1].mime, 'text/plain'); + assert.deepStrictEqual(unpack(packed), outputs); +}); + +test('bytes that are not text are kept as they are', () => { + const odd: CellOutput = { items: [{ mime: 'application/vnd.code.notebook.stdout', data: Uint8Array.from([0x61, 0xff, 0xfe, 0x62]) }] }; + + assert.deepStrictEqual(unpack(pack([odd, table])), [odd, table]); +}); + +test('while a cell runs, a console is cut to its last lines and says how many it left off; the full text is still what unpacks when nothing was cut', () => { + const lines = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n') + '\n'; + + const live = JSON.parse(new TextDecoder().decode(pack([stdout(lines), table], { maxConsoleLines: 10 }).items[0].data)) as PackedCell; + + assert.equal(live.outputs[0].omitted, 40); + assert.equal(live.outputs[0].items[0].text, Array.from({ length: 10 }, (_, i) => `line ${i + 40}`).join('\n') + '\n'); + assert.equal(live.outputs[1].omitted, undefined); + assert.deepStrictEqual(unpack(pack([stdout('short\n'), table], { maxConsoleLines: 10 })), [stdout('short\n'), table]); +}); + +test('an output that is not packed unpacks to itself, and a damaged one is left as it is', () => { + assert.deepStrictEqual(unpack(stdout('x')), [stdout('x')]); + const damaged: CellOutput = { items: [textItem(CELL_OUTPUT_MIME, '{not json')] }; + assert.deepStrictEqual(unpack(damaged), [damaged]); + assert.deepStrictEqual(unpackAll([stdout('a'), pack([stdout('b'), table])]), [stdout('a'), stdout('b'), table]); +}); + +test('outputs are shown as one when there is more than one kind and the setting is on, else as they are', () => { + const outputs = [stdout('a'), table]; + assert.equal(forShowing(outputs, true).length, 1); + assert.deepStrictEqual(forShowing(outputs, false), outputs); + assert.deepStrictEqual(forShowing([stdout('a')], true), [stdout('a')]); +}); diff --git a/vscode-frysharp/test/unit/vscodeStub.ts b/vscode-frysharp/test/unit/vscodeStub.ts new file mode 100644 index 00000000..2015316d --- /dev/null +++ b/vscode-frysharp/test/unit/vscodeStub.ts @@ -0,0 +1,36 @@ +// Just enough of the 'vscode' module for the serializer's adapter to run in plain Node. The real API is exercised by the +// Extension Host tests; this keeps the adapter's own logic (ids, metadata, summaries) under test where Node is enough. +import Module = require('node:module'); + +class NotebookCellData { + metadata: { [key: string]: any } | undefined; + outputs: NotebookCellOutput[] | undefined; + executionSummary: { executionOrder?: number; success?: boolean; timing?: { startTime: number; endTime: number } } | undefined; + constructor(public kind: number, public value: string, public languageId: string) {} +} + +class NotebookData { + metadata: { [key: string]: any } | undefined; + constructor(public cells: NotebookCellData[]) {} +} + +class NotebookCellOutputItem { + constructor(public data: Uint8Array, public mime: string) {} +} + +class NotebookCellOutput { + constructor(public items: NotebookCellOutputItem[], public metadata?: { [key: string]: any }) {} +} + +export const vscodeStub = { + NotebookCellKind: { Markup: 1, Code: 2 }, + NotebookCellData, + NotebookData, + NotebookCellOutput, + NotebookCellOutputItem +}; + +const load = (Module as any)._load; +(Module as any)._load = function (request: string, ...rest: unknown[]) { + return request === 'vscode' ? vscodeStub : load.call(this, request, ...rest); +}; diff --git a/vscode-frysharp/tsconfig.json b/vscode-frysharp/tsconfig.json index fa4c18ce..bddd158a 100644 --- a/vscode-frysharp/tsconfig.json +++ b/vscode-frysharp/tsconfig.json @@ -3,7 +3,9 @@ "module": "commonjs", "target": "ES2022", "outDir": "out", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "sourceMap": true, "rootDir": "src", "strict": true, @@ -11,5 +13,10 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src/**/*"] + "include": [ + "src/**/*" + ], + "exclude": [ + "src/renderer" + ] }