├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── deploy-cleanup.yml │ └── deploy.yml ├── .gitignore ├── .nvmrc ├── .vscode ├── extensions.json └── launch.json ├── Directory.Build.props ├── Directory.Packages.props ├── DotNetLab.sln ├── LICENSE ├── README.md ├── docs └── screenshots │ ├── csharp.png │ └── razor.png ├── dotnet.config ├── eng ├── CopyDotNetDTs.targets └── build.sh ├── global.json ├── nuget.config ├── src ├── App │ ├── App.csproj │ ├── App.razor │ ├── Constants.cs │ ├── CustomComponentBase.cs │ ├── Lab │ │ ├── Compressor.cs │ │ ├── InitialCode.cs │ │ ├── InputOutputCache.cs │ │ ├── LanguageServices.cs │ │ ├── Page.razor │ │ ├── Page.razor.css │ │ ├── Page.razor.js │ │ ├── SavedState.cs │ │ ├── ScreenInfo.cs │ │ ├── Settings.razor │ │ ├── Settings.razor.cs │ │ ├── Settings.razor.js │ │ ├── TemplateCache.cs │ │ ├── UpdateInfo.cs │ │ └── WorkerController.cs │ ├── Layout │ │ └── MainLayout.razor │ ├── Logging.cs │ ├── Npm │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ │ ├── index.js │ │ │ └── vim-mode.js │ │ └── webpack.config.js │ ├── Program.cs │ ├── Properties │ │ ├── AssemblyInfo.cs │ │ └── launchSettings.json │ ├── Utils │ │ ├── Monaco │ │ │ ├── BlazorMonacoInterop.cs │ │ │ ├── CompletionItemProviderAsync.cs │ │ │ └── MonacoUtil.cs │ │ └── StringUtil.cs │ ├── _Imports.razor │ └── wwwroot │ │ ├── DotNetLab.App.lib.module.js │ │ ├── css │ │ └── app.css │ │ ├── favicon.png │ │ ├── index.html │ │ ├── js │ │ ├── BlazorMonacoInterop.js │ │ └── WorkerController.js │ │ ├── manifest.webmanifest │ │ ├── service-worker.js │ │ └── service-worker.published.js ├── Compiler │ ├── Api.cs │ ├── Compiler.cs │ ├── Compiler.csproj │ ├── RefAssemblyMetadata.cs │ └── Utils.cs ├── RazorAccess │ ├── RazorAccess.csproj │ └── RazorAccessors.cs ├── RoslynAccess │ ├── DiagnosticDescription.cs │ ├── RoslynAccess.csproj │ └── RoslynAccessors.cs ├── RoslynWorkspaceAccess │ ├── RoslynWorkspaceAccess.csproj │ └── RoslynWorkspaceAccessors.cs ├── Server │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ └── Server.csproj ├── Shared │ ├── Executor.cs │ ├── ICompiler.cs │ ├── Properties │ │ └── AssemblyInfo.cs │ ├── RefAssemblies.cs │ ├── Sequence.cs │ ├── Shared.csproj │ └── Util.cs ├── Worker │ ├── Executor.cs │ ├── Imports.cs │ ├── Lab │ │ ├── AssemblyDownloader.cs │ │ ├── AzDoDownloader.cs │ │ ├── CompilerDependencyProvider.cs │ │ ├── CompilerProxy.cs │ │ ├── DependencyRegistry.cs │ │ ├── LabWorkerJsonContext.cs │ │ ├── LanguageServices.cs │ │ ├── NuGetDownloader.cs │ │ └── SdkDownloader.cs │ ├── Program.cs │ ├── Properties │ │ ├── AssemblyInfo.cs │ │ └── launchSettings.json │ ├── Utils │ │ ├── MonacoConversions.cs │ │ ├── SimpleConsoleLoggerProvider.cs │ │ └── WebcilUtil.cs │ ├── Worker.csproj │ ├── WorkerInterop.cs │ ├── WorkerServices.cs │ └── wwwroot │ │ ├── index.html │ │ ├── interop.js │ │ └── main.js └── WorkerApi │ ├── InputMessage.cs │ ├── Lab │ └── CompilerDependency.cs │ ├── OutputMessage.cs │ ├── Utils │ ├── MonacoEditor.cs │ ├── NetUtil.cs │ ├── SimpleAzDoUtil.cs │ ├── SimpleMonacoConversions.cs │ ├── SimpleNuGetUtil.cs │ ├── ThrowingTraceListener.cs │ └── VersionUtil.cs │ ├── WorkerApi.csproj │ └── WorkerJsonContext.cs └── test └── UnitTests ├── CompilerProxyTests.cs ├── CompressorTests.cs ├── InputOutputCacheTests.cs ├── TemplateCacheTests.cs └── UnitTests.csproj /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jjonescz 2 | custom: "https://www.paypal.me/janjonescz" 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [ main ] 7 | push: 8 | branches: [ main ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 5 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-dotnet@v4 19 | with: 20 | global-json-file: global.json 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version-file: .nvmrc 25 | 26 | - name: Install workloads 27 | run: dotnet workload install wasm-tools wasm-experimental 28 | 29 | - name: Build 30 | run: dotnet build -warnaserror -p:TreatWarningsAsErrors=true 31 | 32 | - name: Test 33 | run: dotnet test --no-build 34 | -------------------------------------------------------------------------------- /.github/workflows/deploy-cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Deploy cleanup 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | simulate_fork_pr: 7 | description: 'Simulate fork for this PR number' 8 | pull_request: 9 | types: [ closed ] 10 | branches: [ main ] 11 | 12 | permissions: 13 | contents: write 14 | pull-requests: read 15 | 16 | jobs: 17 | deploy-cleanup: 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 5 20 | 21 | if: > 22 | inputs.simulate_fork_pr || 23 | github.event.pull_request.head.repo.full_name != github.repository 24 | 25 | concurrency: 26 | group: deploy-cleanup-${{ github.event.inputs.simulate_fork_pr || github.event.number }} 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Remove internal branch 32 | run: | 33 | git config --global url."https://user:${{ secrets.GITHUB_TOKEN }}@github".insteadOf https://github 34 | git push origin --delete deploy/${{ inputs.simulate_fork_pr || github.event.number }} 35 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy info 2 | 3 | # If there is a PR from a fork which has been approved to run GitHub Actions, 4 | # that PR's head is pushed to a branch directly in the repo (not the fork), 5 | # so it's deployed by Cloudflare Pages as a branch preview. 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | simulate_fork_pr: 11 | description: 'Simulate fork for this PR number' 12 | pull_request: 13 | branches: [ main ] 14 | 15 | permissions: 16 | contents: write 17 | pull-requests: read 18 | 19 | jobs: 20 | deploy-info: 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 5 23 | 24 | if: > 25 | inputs.simulate_fork_pr || 26 | github.event.pull_request.head.repo.full_name != github.repository 27 | 28 | concurrency: 29 | group: deploy-${{ github.event.inputs.simulate_fork_pr || github.event.number }} 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | 34 | - name: Push to internal branch 35 | run: | 36 | git fetch origin ${{ github.sha }} 37 | git push "https://user:${{ secrets.PUSH_GITHUB_TOKEN }}@github.com/${{ github.repository }}" ${{ github.sha }}:refs/heads/deploy/${{ inputs.simulate_fork_pr || github.event.number }} 38 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.5.1 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "zignd.html-css-class-completion" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Server", 6 | "type": "blazorwasm", 7 | "request": "launch", 8 | "hosted": true, 9 | "program": "${workspaceFolder}/src/Server/bin/Debug/net10.0/DotNetLab.Server.dll", 10 | "cwd": "${workspaceFolder}/src/App" 11 | }, 12 | { 13 | "name": "App", 14 | "type": "blazorwasm", 15 | "request": "launch", 16 | "cwd": "${workspaceFolder}/src/App" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | enable 5 | enable 6 | DotNetLab 7 | DotNetLab.$([System.IO.Path]::GetFileName(`$(MSBuildProjectDirectory)`)) 8 | true 9 | $(Features);use-roslyn-tokenizer=true 10 | false 11 | preview 12 | 13 | 17 | false 18 | false 19 | 20 | 21 | Framework 22 | 23 | 24 | 25 | 26 | runtime; build; native; contentfiles; analyzers; buildtransitive 27 | all 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | true 5 | 6 | $(NoWarn);NU1507 7 | 10.0.0-preview.4.25258.110 8 | $(NetCoreVersion) 9 | 5.0.0-1.25252.6 10 | 4.11.8 11 | 6.13.1 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /DotNetLab.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "App", "src\App\App.csproj", "{FCA5EE33-745E-4DF5-A250-04D5260AD806}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RazorAccess", "src\RazorAccess\RazorAccess.csproj", "{E3B85FCE-4793-4C91-916B-0D3199EEC20B}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{494F0FD9-0786-45A4-B487-98AA456EB07F}" 13 | ProjectSection(SolutionItems) = preProject 14 | .gitignore = .gitignore 15 | Directory.Build.props = Directory.Build.props 16 | Directory.Packages.props = Directory.Packages.props 17 | global.json = global.json 18 | nuget.config = nuget.config 19 | README.md = README.md 20 | EndProjectSection 21 | EndProject 22 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{AC28F7A2-BCAD-48A8-B498-78E802B8872D}" 23 | EndProject 24 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "test\UnitTests\UnitTests.csproj", "{DA01142C-555F-4EF7-9E71-C5F988D5C3D7}" 25 | EndProject 26 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoslynAccess", "src\RoslynAccess\RoslynAccess.csproj", "{2C13EAB4-7ACC-4F8D-9E41-E37C25E4DEBD}" 27 | EndProject 28 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compiler", "src\Compiler\Compiler.csproj", "{E92EE2C2-BA41-40A2-A5FF-2A9924FBE091}" 29 | EndProject 30 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "src\Shared\Shared.csproj", "{581F062C-ED0F-4051-A4A5-6390FCD17163}" 31 | EndProject 32 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server", "src\Server\Server.csproj", "{EAC815BD-261D-499A-BC07-CA7E98A737CC}" 33 | EndProject 34 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Worker", "src\Worker\Worker.csproj", "{54CE35B1-FB3F-4300-B03C-C1D5D6531F79}" 35 | EndProject 36 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerApi", "src\WorkerApi\WorkerApi.csproj", "{743FF486-D9DF-44B5-92C3-6698411EA546}" 37 | EndProject 38 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eng", "eng", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" 39 | ProjectSection(SolutionItems) = preProject 40 | eng\build.sh = eng\build.sh 41 | eng\CopyDotNetDTs.targets = eng\CopyDotNetDTs.targets 42 | EndProjectSection 43 | EndProject 44 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoslynWorkspaceAccess", "src\RoslynWorkspaceAccess\RoslynWorkspaceAccess.csproj", "{38AFCFD1-0219-4A85-8AD8-C513820C32CF}" 45 | EndProject 46 | Global 47 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 48 | Debug|Any CPU = Debug|Any CPU 49 | Release|Any CPU = Release|Any CPU 50 | EndGlobalSection 51 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 52 | {FCA5EE33-745E-4DF5-A250-04D5260AD806}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {FCA5EE33-745E-4DF5-A250-04D5260AD806}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {FCA5EE33-745E-4DF5-A250-04D5260AD806}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {FCA5EE33-745E-4DF5-A250-04D5260AD806}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {E3B85FCE-4793-4C91-916B-0D3199EEC20B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {E3B85FCE-4793-4C91-916B-0D3199EEC20B}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {E3B85FCE-4793-4C91-916B-0D3199EEC20B}.Release|Any CPU.ActiveCfg = Release|Any CPU 59 | {E3B85FCE-4793-4C91-916B-0D3199EEC20B}.Release|Any CPU.Build.0 = Release|Any CPU 60 | {DA01142C-555F-4EF7-9E71-C5F988D5C3D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {DA01142C-555F-4EF7-9E71-C5F988D5C3D7}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {DA01142C-555F-4EF7-9E71-C5F988D5C3D7}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {DA01142C-555F-4EF7-9E71-C5F988D5C3D7}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {2C13EAB4-7ACC-4F8D-9E41-E37C25E4DEBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {2C13EAB4-7ACC-4F8D-9E41-E37C25E4DEBD}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {2C13EAB4-7ACC-4F8D-9E41-E37C25E4DEBD}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {2C13EAB4-7ACC-4F8D-9E41-E37C25E4DEBD}.Release|Any CPU.Build.0 = Release|Any CPU 68 | {E92EE2C2-BA41-40A2-A5FF-2A9924FBE091}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 69 | {E92EE2C2-BA41-40A2-A5FF-2A9924FBE091}.Debug|Any CPU.Build.0 = Debug|Any CPU 70 | {E92EE2C2-BA41-40A2-A5FF-2A9924FBE091}.Release|Any CPU.ActiveCfg = Release|Any CPU 71 | {E92EE2C2-BA41-40A2-A5FF-2A9924FBE091}.Release|Any CPU.Build.0 = Release|Any CPU 72 | {581F062C-ED0F-4051-A4A5-6390FCD17163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 73 | {581F062C-ED0F-4051-A4A5-6390FCD17163}.Debug|Any CPU.Build.0 = Debug|Any CPU 74 | {581F062C-ED0F-4051-A4A5-6390FCD17163}.Release|Any CPU.ActiveCfg = Release|Any CPU 75 | {581F062C-ED0F-4051-A4A5-6390FCD17163}.Release|Any CPU.Build.0 = Release|Any CPU 76 | {EAC815BD-261D-499A-BC07-CA7E98A737CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 77 | {EAC815BD-261D-499A-BC07-CA7E98A737CC}.Debug|Any CPU.Build.0 = Debug|Any CPU 78 | {EAC815BD-261D-499A-BC07-CA7E98A737CC}.Release|Any CPU.ActiveCfg = Release|Any CPU 79 | {EAC815BD-261D-499A-BC07-CA7E98A737CC}.Release|Any CPU.Build.0 = Release|Any CPU 80 | {54CE35B1-FB3F-4300-B03C-C1D5D6531F79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 81 | {54CE35B1-FB3F-4300-B03C-C1D5D6531F79}.Debug|Any CPU.Build.0 = Debug|Any CPU 82 | {54CE35B1-FB3F-4300-B03C-C1D5D6531F79}.Release|Any CPU.ActiveCfg = Release|Any CPU 83 | {54CE35B1-FB3F-4300-B03C-C1D5D6531F79}.Release|Any CPU.Build.0 = Release|Any CPU 84 | {743FF486-D9DF-44B5-92C3-6698411EA546}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 85 | {743FF486-D9DF-44B5-92C3-6698411EA546}.Debug|Any CPU.Build.0 = Debug|Any CPU 86 | {743FF486-D9DF-44B5-92C3-6698411EA546}.Release|Any CPU.ActiveCfg = Release|Any CPU 87 | {743FF486-D9DF-44B5-92C3-6698411EA546}.Release|Any CPU.Build.0 = Release|Any CPU 88 | {38AFCFD1-0219-4A85-8AD8-C513820C32CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 89 | {38AFCFD1-0219-4A85-8AD8-C513820C32CF}.Debug|Any CPU.Build.0 = Debug|Any CPU 90 | {38AFCFD1-0219-4A85-8AD8-C513820C32CF}.Release|Any CPU.ActiveCfg = Release|Any CPU 91 | {38AFCFD1-0219-4A85-8AD8-C513820C32CF}.Release|Any CPU.Build.0 = Release|Any CPU 92 | EndGlobalSection 93 | GlobalSection(SolutionProperties) = preSolution 94 | HideSolutionNode = FALSE 95 | EndGlobalSection 96 | GlobalSection(NestedProjects) = preSolution 97 | {FCA5EE33-745E-4DF5-A250-04D5260AD806} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 98 | {E3B85FCE-4793-4C91-916B-0D3199EEC20B} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 99 | {DA01142C-555F-4EF7-9E71-C5F988D5C3D7} = {AC28F7A2-BCAD-48A8-B498-78E802B8872D} 100 | {2C13EAB4-7ACC-4F8D-9E41-E37C25E4DEBD} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 101 | {E92EE2C2-BA41-40A2-A5FF-2A9924FBE091} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 102 | {581F062C-ED0F-4051-A4A5-6390FCD17163} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 103 | {EAC815BD-261D-499A-BC07-CA7E98A737CC} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 104 | {54CE35B1-FB3F-4300-B03C-C1D5D6531F79} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 105 | {743FF486-D9DF-44B5-92C3-6698411EA546} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 106 | {38AFCFD1-0219-4A85-8AD8-C513820C32CF} = {CAAE4B1C-41E0-4C18-B9D3-513F5135B7A2} 107 | EndGlobalSection 108 | GlobalSection(ExtensibilityGlobals) = postSolution 109 | SolutionGuid = {AC88F4C4-40B7-4FD4-B0DF-0F911BE53F8C} 110 | EndGlobalSection 111 | EndGlobal 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jan Jones and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # .NET Lab 2 | 3 | (aka Razor Lab, formerly also DotNetInternals) 4 | 5 | C# and Razor compiler playground in the browser via Blazor WebAssembly. https://lab.razor.fyi/ 6 | 7 | | [C#](https://lab.razor.fyi/#csharp) | [Razor](https://lab.razor.fyi/#razor) | 8 | |:-:|:-:| 9 | | ![C# screenshot](docs/screenshots/csharp.png) | ![Razor screenshot](docs/screenshots/razor.png) | 10 | 11 | ## Features 12 | 13 | - Razor/CSHTML to generated C# code / IR / Syntax Tree / Errors. 14 | - C# to IL / Syntax / decompiled-C# / Errors / Execution console output. 15 | - Any Roslyn/Razor compiler version (NuGet official builds or CI builds given PR number / branch / build number). 16 | - Offline support (PWA). 17 | - VSCode Monaco Editor. 18 | - Multiple input sources (especially useful for interlinked Razor components). 19 | - C# Language Services (completions, live diagnostics). 20 | - Configuring any C# options (e.g., LangVersion, Features, OptimizationLevel, AllowUnsafe). 21 | 22 | ## Development 23 | 24 | The recommended startup app for development is `src/Server`. 25 | 26 | To hit breakpoints, it is recommended to turn off the worker (in app settings). 27 | 28 | - `src/App`: the WebAssembly app. 29 | - `cd src/App; dotnet watch` - `src/Server` is better for development though. 30 | - `src/Compiler`: self-contained project referencing Roslyn/Razor. 31 | It's reloaded at runtime with a user-chosen version of Roslyn/Razor. 32 | It should be small (for best reloading perf). It can reference shared code 33 | which does not depend on Roslyn/Razor from elsewhere (e.g., `Shared.csproj`). 34 | - `src/RazorAccess`: `internal` access to Razor DLLs (via fake assembly name). 35 | - `src/RoslynAccess`: `internal` access to Roslyn Compiler DLLs (via fake assembly name). 36 | - `src/RoslynWorkspaceAccess`: `internal` access to Roslyn Workspace DLLs (via fake assembly name). 37 | - `src/Server`: a Blazor Server entrypoint for easier development of the App 38 | (it has better tooling support for hot reload and debugging). 39 | - `cd src/Server; dotnet watch` 40 | - `src/Shared`: code used by `Compiler` that does not depend on Roslyn/Razor. 41 | - `src/Worker`: an app loaded in a web worker (a separate process in the browser), 42 | so it does all the CPU-intensive work to avoid lagging the user interface. 43 | - `src/WorkerApi`: shared code between `Worker` and `App`. 44 | This is in preparation for making the worker independent of the app, 45 | so the app can be optimized (trimming, NativeAOT) and the worker can be loaded more lazily. 46 | - `test/UnitTests` 47 | - `dotnet test` 48 | 49 | ## Attribution 50 | 51 | - Logo: [OpenMoji](https://openmoji.org/library/emoji-1FAD9-200D-1F7EA/) 52 | - Style: [Fluent UI](https://www.fluentui-blazor.net/) 53 | - Icons: [Fluent UI Icons](https://github.com/microsoft/fluentui-system-icons) 54 | 55 | ## Related work 56 | 57 | Razor REPLs: 58 | - https://blazorrepl.telerik.com/ 59 | - https://netcorerepl.telerik.com/ 60 | - https://try.mudblazor.com/snippet 61 | - https://blazorfiddle.com/ 62 | - https://try.fluentui-blazor.net/snippet 63 | 64 | C# REPLs: 65 | - https://dotnetfiddle.net/ 66 | - https://onecompiler.com/csharp 67 | - https://www.programiz.com/csharp-programming/online-compiler/ 68 | 69 | C# compiler playgrounds: 70 | - https://sharplab.io/ 71 | - https://godbolt.org/ 72 | 73 | XAML REPLs: 74 | - https://playground.platform.uno/ 75 | 76 | Web IDEs: 77 | - https://github.com/Mythetech/Apollo 78 | - https://github.com/Luthetus/Luthetus.Ide 79 | - https://github.com/knervous/intellisage 80 | -------------------------------------------------------------------------------- /docs/screenshots/csharp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjonescz/DotNetLab/9696f50dc5e9ce5db03ae7950f781c5f46e252d2/docs/screenshots/csharp.png -------------------------------------------------------------------------------- /docs/screenshots/razor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjonescz/DotNetLab/9696f50dc5e9ce5db03ae7950f781c5f46e252d2/docs/screenshots/razor.png -------------------------------------------------------------------------------- /dotnet.config: -------------------------------------------------------------------------------- 1 | [dotnet.test:runner] 2 | name = "Microsoft.Testing.Platform" 3 | -------------------------------------------------------------------------------- /eng/CopyDotNetDTs.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <_DotnetDTsPath Include="%(RuntimePack.PackageDirectory)\runtimes\browser-wasm\native\dotnet.d.ts" Condition="'%(RuntimePack.Identity)' == 'Microsoft.NETCore.App.Runtime.Mono.browser-wasm'" /> 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /eng/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | curl -sSL https://builds.dotnet.microsoft.com/dotnet/scripts/v1/dotnet-install.sh > dotnet-install.sh 4 | chmod +x dotnet-install.sh 5 | ./dotnet-install.sh --jsonfile global.json --install-dir ./dotnet 6 | ./dotnet/dotnet --version 7 | ./dotnet/dotnet workload install wasm-tools wasm-experimental 8 | ./dotnet/dotnet publish -o output src/App 9 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.100-preview.4.25258.110", 4 | "workloadVersion": "10.0.100-preview.4.25263.1" 5 | } 6 | } -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/App/App.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | true 5 | service-worker-assets.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/App/App.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | Not found • .NET Lab 8 | 9 |

Sorry, there's nothing at this address.

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /src/App/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab; 2 | 3 | /// 4 | /// Keep in sync with :root in app.css. 5 | /// 6 | internal static class AppColors 7 | { 8 | public static readonly string CustomDark = "#8967AA"; 9 | } 10 | 11 | internal static class MonacoConstants 12 | { 13 | public static readonly string MarkersOwner = "Lab"; 14 | } 15 | -------------------------------------------------------------------------------- /src/App/CustomComponentBase.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab; 2 | 3 | public abstract class CustomComponentBase : ComponentBase 4 | { 5 | protected async Task RefreshAsync() 6 | { 7 | _ = InvokeAsync(StateHasChanged); 8 | await Task.Yield(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/App/Lab/Compressor.cs: -------------------------------------------------------------------------------- 1 | using ProtoBuf; 2 | using System.Buffers.Text; 3 | using System.IO.Compression; 4 | 5 | namespace DotNetLab.Lab; 6 | 7 | internal static class Compressor 8 | { 9 | public static string Compress(SavedState input) 10 | { 11 | using var ms = new MemoryStream(); 12 | using (var compressor = new DeflateStream(ms, CompressionLevel.Optimal)) 13 | { 14 | Serializer.Serialize(compressor, input); 15 | } 16 | return Base64Url.EncodeToString(ms.ToArray()); 17 | } 18 | 19 | public static SavedState Uncompress(string slug) 20 | { 21 | try 22 | { 23 | var bytes = Base64Url.DecodeFromChars(slug); 24 | using var ms = new MemoryStream(bytes); 25 | using var compressor = new DeflateStream(ms, CompressionMode.Decompress); 26 | return Serializer.Deserialize(compressor); 27 | } 28 | catch (Exception ex) 29 | { 30 | return new SavedState 31 | { 32 | Inputs = 33 | [ 34 | new InputCode { FileName = "(error)", Text = ex.ToString() }, 35 | ], 36 | }; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/App/Lab/InitialCode.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab.Lab; 2 | 3 | internal sealed record InitialCode 4 | { 5 | public static readonly InitialCode CSharp = new("Program.cs", """ 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Collections.Immutable; 9 | using System.Diagnostics; 10 | using System.Diagnostics.CodeAnalysis; 11 | using System.Linq; 12 | using System.Threading; 13 | using System.Threading.Tasks; 14 | 15 | class Program 16 | { 17 | static void Main() 18 | { 19 | Console.WriteLine("Hello."); 20 | } 21 | } 22 | 23 | """); 24 | 25 | public static readonly InitialCode Razor = new("TestComponent.razor", """ 26 |
@Param
27 | @if (Param == 0) 28 | { 29 | 30 | } 31 | 32 | @code { 33 | [Parameter] public int Param { get; set; } 34 | } 35 | 36 | """); 37 | 38 | // https://github.com/dotnet/aspnetcore/blob/036ec9ec2ffbfe927f9eb7622dfff122c634ccbb/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/_Imports.razor 39 | public static readonly InitialCode RazorImports = new("_Imports.razor", """ 40 | @using System.Net.Http 41 | @using System.Net.Http.Json 42 | @using Microsoft.AspNetCore.Components.Authorization 43 | @using Microsoft.AspNetCore.Components.Forms 44 | @using Microsoft.AspNetCore.Components.Routing 45 | @using Microsoft.AspNetCore.Components.Web 46 | @using static Microsoft.AspNetCore.Components.Web.RenderMode 47 | @using Microsoft.AspNetCore.Components.Web.Virtualization 48 | @using Microsoft.JSInterop 49 | 50 | """); 51 | 52 | public static readonly InitialCode Cshtml = new("TestPage.cshtml", """ 53 | @page 54 | @using System.ComponentModel.DataAnnotations 55 | @model PageModel 56 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 57 | 58 |
59 | Name: 60 | 61 | 62 |
63 | 64 | @functions { 65 | public class PageModel 66 | { 67 | public Customer Customer { get; set; } = new(); 68 | } 69 | 70 | public class Customer 71 | { 72 | public int Id { get; set; } 73 | 74 | [Required, StringLength(10)] 75 | public string Name { get; set; } = ""; 76 | } 77 | } 78 | 79 | """); 80 | 81 | // IMPORTANT: Keep in sync with `Compiler.Compile`. 82 | public static readonly InitialCode Configuration = new("Configuration.cs", """ 83 | Config.CSharpParseOptions(options => options 84 | .WithLanguageVersion(LanguageVersion.Preview) 85 | .WithFeatures([new("use-roslyn-tokenizer", "true")]) 86 | ); 87 | 88 | Config.CSharpCompilationOptions(options => options 89 | .WithAllowUnsafe(true) 90 | .WithNullableContextOptions(NullableContextOptions.Enable) 91 | .WithOptimizationLevel(OptimizationLevel.Debug) 92 | ); 93 | 94 | """); 95 | 96 | public InitialCode(string suggestedFileName, string textTemplate) 97 | { 98 | SuggestedFileName = suggestedFileName; 99 | TextTemplate = textTemplate; 100 | } 101 | 102 | public string SuggestedFileName { get; } 103 | public string TextTemplate { get; } 104 | 105 | public string SuggestedFileNameWithoutExtension => Path.GetFileNameWithoutExtension(SuggestedFileName); 106 | public string SuggestedFileExtension => Path.GetExtension(SuggestedFileName); 107 | 108 | public string GetFinalFileName(string suffix) 109 | { 110 | return string.IsNullOrEmpty(suffix) 111 | ? SuggestedFileName 112 | : SuggestedFileNameWithoutExtension + suffix + SuggestedFileExtension; 113 | } 114 | 115 | public InputCode ToInputCode(string? finalFileName = null) 116 | { 117 | finalFileName ??= SuggestedFileName; 118 | 119 | return new() 120 | { 121 | FileName = finalFileName, 122 | Text = finalFileName == SuggestedFileName 123 | ? TextTemplate 124 | : TextTemplate.Replace( 125 | SuggestedFileNameWithoutExtension, 126 | Path.GetFileNameWithoutExtension(finalFileName), 127 | StringComparison.Ordinal), 128 | }; 129 | } 130 | 131 | public SavedState ToSavedState() 132 | { 133 | return new() 134 | { 135 | Inputs = [ToInputCode()], 136 | }; 137 | } 138 | 139 | public CompilationInput ToCompilationInput() 140 | { 141 | return new(new([ToInputCode()])); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/App/Lab/InputOutputCache.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Hashing; 2 | using System.Net.Http.Json; 3 | using System.Runtime.InteropServices; 4 | using System.Text.Json; 5 | 6 | namespace DotNetLab.Lab; 7 | 8 | /// 9 | /// Caches input/output pairs on a server, so that sharing and opening a lab link loads fast 10 | /// (no need to wait for the initial compilation which can be slow). 11 | /// 12 | internal sealed class InputOutputCache(HttpClient client, ILogger logger) 13 | { 14 | private static readonly string endpoint = "https://vsinsertions.azurewebsites.net/api/cache"; 15 | 16 | public async Task StoreAsync(SavedState state, CompiledAssembly output) 17 | { 18 | try 19 | { 20 | var key = SlugToCacheKey(state.ToCacheSlug()); 21 | var response = await client.PostAsync( 22 | $"{endpoint}/add/{key}", 23 | new StringContent(JsonSerializer.Serialize(output, WorkerJsonContext.Default.CompiledAssembly), Encoding.UTF8, "text/plain")); 24 | response.EnsureSuccessStatusCode(); 25 | } 26 | catch (Exception e) 27 | { 28 | logger.LogError(e, "Failed to store."); 29 | } 30 | } 31 | 32 | public async Task<(CompiledAssembly Output, DateTimeOffset Timestamp)?> LoadAsync(SavedState state) 33 | { 34 | try 35 | { 36 | var key = SlugToCacheKey(state.ToCacheSlug()); 37 | var response = await client.PostAsync($"{endpoint}/get/{key}", content: null); 38 | response.EnsureSuccessStatusCode(); 39 | 40 | if (!response.Headers.TryGetValues("X-Timestamp", out var values) || 41 | !values.Any() || 42 | !DateTimeOffset.TryParse(values.First(), out var timestamp)) 43 | { 44 | logger.LogError("No timestamp. Headers: {Headers}", response.Headers.Select(p => $"{p.Key}: ({p.Value.JoinToString(", ")})").JoinToString(", ")); 45 | return null; 46 | } 47 | 48 | if (await response.Content.ReadFromJsonAsync(WorkerJsonContext.Default.Options) is not { } output) 49 | { 50 | logger.LogError("No output."); 51 | return null; 52 | } 53 | 54 | return (output, timestamp); 55 | } 56 | catch (Exception e) 57 | { 58 | logger.LogError(e, "Failed to load."); 59 | } 60 | 61 | return null; 62 | } 63 | 64 | private static string SlugToCacheKey(string slug) 65 | { 66 | Span hash = stackalloc byte[sizeof(ulong) * 2]; 67 | int bytesWritten = XxHash128.Hash(MemoryMarshal.AsBytes(slug.AsSpan()), hash); 68 | Debug.Assert(bytesWritten == hash.Length); 69 | return string.Create(hash.Length * 2, hash, static (destination, hash) => toHex(hash, destination)); 70 | 71 | static void toHex(ReadOnlySpan source, Span destination) 72 | { 73 | int i = 0; 74 | foreach (var b in source) 75 | { 76 | destination[i++] = hexChar(b >> 4); 77 | destination[i++] = hexChar(b & 0xF); 78 | } 79 | } 80 | 81 | static char hexChar(int x) => (char)((x <= 9) ? (x + '0') : (x + ('a' - 10))); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/App/Lab/LanguageServices.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco.Editor; 2 | using BlazorMonaco.Languages; 3 | using Microsoft.JSInterop; 4 | using System.Runtime.Versioning; 5 | 6 | namespace DotNetLab.Lab; 7 | 8 | [SupportedOSPlatform("browser")] 9 | internal sealed class LanguageServices( 10 | ILoggerFactory loggerFactory, 11 | ILogger logger, 12 | IJSRuntime jsRuntime, 13 | WorkerController worker, 14 | BlazorMonacoInterop blazorMonacoInterop) 15 | { 16 | private Dictionary modelUrlToFileName = []; 17 | private IDisposable? completionProvider; 18 | private string? currentModelUrl; 19 | private DebounceInfo completionDebounce = new(new CancellationTokenSource()); 20 | private DebounceInfo diagnosticsDebounce = new(new CancellationTokenSource()); 21 | 22 | public bool Enabled => completionProvider != null; 23 | 24 | private static Task DebounceAsync(ref DebounceInfo info, TIn args, TOut fallback, Func> handler, CancellationToken cancellationToken) 25 | { 26 | TimeSpan wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - info.Timestamp); 27 | info.CancellationTokenSource.Cancel(); 28 | info = new(CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); 29 | 30 | return debounceAsync(wait, info.CancellationTokenSource.Token, args, fallback, handler, cancellationToken); 31 | 32 | static async Task debounceAsync(TimeSpan wait, CancellationToken debounceToken, TIn args, TOut fallback, Func> handler, CancellationToken userToken) 33 | { 34 | try 35 | { 36 | if (wait > TimeSpan.Zero) 37 | { 38 | await Task.Delay(wait, debounceToken); 39 | } 40 | 41 | debounceToken.ThrowIfCancellationRequested(); 42 | 43 | return await handler(args, userToken); 44 | } 45 | catch (OperationCanceledException) 46 | { 47 | return fallback; 48 | } 49 | } 50 | } 51 | 52 | private static void Debounce(ref DebounceInfo info, T args, Func handler, Action errorHandler) 53 | { 54 | DebounceAsync(ref info, (args, handler), 0, static async (args, cancellationToken) => 55 | { 56 | await args.handler(args.args); 57 | return 0; 58 | }, 59 | CancellationToken.None) 60 | .ContinueWith(t => 61 | { 62 | if (t.IsFaulted) 63 | { 64 | errorHandler(t.Exception); 65 | } 66 | }); 67 | } 68 | 69 | public Task EnableAsync(bool enable) 70 | { 71 | if (enable) 72 | { 73 | return RegisterAsync(); 74 | } 75 | else 76 | { 77 | Unregister(); 78 | return Task.CompletedTask; 79 | } 80 | } 81 | 82 | private async Task RegisterAsync() 83 | { 84 | if (completionProvider != null) 85 | { 86 | return; 87 | } 88 | 89 | var cSharpLanguageSelector = new LanguageSelector("csharp"); 90 | completionProvider = await blazorMonacoInterop.RegisterCompletionProviderAsync(cSharpLanguageSelector, new(loggerFactory.CreateLogger()) 91 | { 92 | TriggerCharacters = [" ", "(", "=", "#", ".", "<", "[", "{", "\"", "/", ":", ">", "~"], 93 | ProvideCompletionItemsFunc = (modelUri, position, context, cancellationToken) => 94 | { 95 | return DebounceAsync( 96 | ref completionDebounce, 97 | (worker, modelUri, position, context), 98 | """{"suggestions":[],"isIncomplete":true}""", 99 | static (args, cancellationToken) => args.worker.ProvideCompletionItemsAsync(args.modelUri, args.position, args.context), 100 | cancellationToken); 101 | }, 102 | ResolveCompletionItemFunc = (completionItem, cancellationToken) => worker.ResolveCompletionItemAsync(completionItem), 103 | }); 104 | } 105 | 106 | private void Unregister() 107 | { 108 | completionProvider?.Dispose(); 109 | completionProvider = null; 110 | } 111 | 112 | public void OnDidChangeWorkspace(ImmutableArray models, bool updateDiagnostics = true) 113 | { 114 | if (!Enabled) 115 | { 116 | return; 117 | } 118 | 119 | modelUrlToFileName = models.ToDictionary(m => m.Uri, m => m.FileName); 120 | worker.OnDidChangeWorkspace(models); 121 | 122 | if (updateDiagnostics) 123 | { 124 | UpdateDiagnostics(); 125 | } 126 | } 127 | 128 | public void OnDidChangeModel(ModelChangedEvent args) 129 | { 130 | if (!Enabled) 131 | { 132 | return; 133 | } 134 | 135 | currentModelUrl = args.NewModelUrl; 136 | worker.OnDidChangeModel(modelUri: currentModelUrl); 137 | UpdateDiagnostics(); 138 | } 139 | 140 | public void OnDidChangeModelContent(ModelContentChangedEvent args) 141 | { 142 | if (!Enabled) 143 | { 144 | return; 145 | } 146 | 147 | worker.OnDidChangeModelContent(args); 148 | UpdateDiagnostics(); 149 | } 150 | 151 | private void UpdateDiagnostics() 152 | { 153 | if (currentModelUrl == null || 154 | !modelUrlToFileName.TryGetValue(currentModelUrl, out var currentModelFileName) || 155 | !currentModelFileName.IsCSharpFileName()) 156 | { 157 | return; 158 | } 159 | 160 | Debounce(ref diagnosticsDebounce, (worker, jsRuntime, currentModelUrl), static async args => 161 | { 162 | var (worker, jsRuntime, currentModelUrl) = args; 163 | var markers = await worker.GetDiagnosticsAsync(); 164 | var model = await BlazorMonaco.Editor.Global.GetModel(jsRuntime, currentModelUrl); 165 | await BlazorMonaco.Editor.Global.SetModelMarkers(jsRuntime, model, MonacoConstants.MarkersOwner, markers.ToList()); 166 | }, 167 | (ex) => 168 | { 169 | logger.LogError(ex, "Updating diagnostics failed"); 170 | }); 171 | } 172 | } 173 | 174 | internal readonly struct DebounceInfo(CancellationTokenSource cts) 175 | { 176 | public CancellationTokenSource CancellationTokenSource { get; } = cts; 177 | public DateTime Timestamp { get; } = DateTime.UtcNow; 178 | } 179 | -------------------------------------------------------------------------------- /src/App/Lab/Page.razor.css: -------------------------------------------------------------------------------- 1 | textarea { 2 | resize: none; 3 | font-family: monospace; 4 | } 5 | 6 | .no-wrap { 7 | white-space: pre; 8 | } 9 | 10 | .vim-status-bar { 11 | height: 1.5em; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | } 16 | 17 | ::deep fluent-tabs::part(activeIndicator) { 18 | width: 100%; 19 | } 20 | -------------------------------------------------------------------------------- /src/App/Lab/Page.razor.js: -------------------------------------------------------------------------------- 1 | export function registerEventListeners(dotNetObj) { 2 | const keyDownHandler = (e) => { 3 | if (e.ctrlKey && e.key === 's') { 4 | e.preventDefault(); 5 | dotNetObj.invokeMethodAsync('CompileAndRenderAsync'); 6 | } 7 | }; 8 | 9 | document.addEventListener('keydown', keyDownHandler); 10 | 11 | return () => { 12 | document.removeEventListener('keydown', keyDownHandler); 13 | }; 14 | } 15 | 16 | export function saveMonacoEditorViewState(editorId) { 17 | const result = blazorMonaco.editor.getEditor(editorId)?.saveViewState(); 18 | return { Inner: result ? DotNet.createJSObjectReference(result) : null }; 19 | } 20 | 21 | export function restoreMonacoEditorViewState(editorId, state) { 22 | blazorMonaco.editor.getEditor(editorId)?.restoreViewState(state); 23 | } 24 | -------------------------------------------------------------------------------- /src/App/Lab/ScreenInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices.JavaScript; 2 | using System.Runtime.Versioning; 3 | 4 | namespace DotNetLab.Lab; 5 | 6 | [SupportedOSPlatform("browser")] 7 | internal static partial class ScreenInfo 8 | { 9 | public static event Action? Updated; 10 | 11 | public static bool IsNarrowScreen { get; private set; } 12 | 13 | [JSExport] 14 | public static void SetNarrowScreen(bool value) 15 | { 16 | IsNarrowScreen = value; 17 | Updated?.Invoke(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/App/Lab/Settings.razor.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices.JavaScript; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace DotNetLab.Lab; 6 | 7 | internal static partial class SettingsInterop 8 | { 9 | [JSImport("checkForUpdates", "Settings")] 10 | public static partial Task CheckForUpdatesAsync(); 11 | } 12 | 13 | [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] 14 | [JsonSerializable(typeof(GitHubCommitResponse))] 15 | [JsonSerializable(typeof(GitHubBranchCommitsResponse))] 16 | internal sealed partial class SettingsJsonContext : JsonSerializerContext; 17 | 18 | internal sealed class GitHubCommitResponse 19 | { 20 | public required CommitData Commit { get; init; } 21 | 22 | public sealed class CommitData 23 | { 24 | public required AuthorData Author { get; init; } 25 | public required string Message { get; init; } 26 | } 27 | 28 | public sealed class AuthorData 29 | { 30 | public required DateTimeOffset Date { get; init; } 31 | } 32 | } 33 | 34 | internal sealed class GitHubBranchCommitsResponse 35 | { 36 | public ImmutableArray Tags { get; init; } 37 | } 38 | -------------------------------------------------------------------------------- /src/App/Lab/Settings.razor.js: -------------------------------------------------------------------------------- 1 | export async function checkForUpdates() { 2 | const registration = await navigator.serviceWorker.getRegistration(); 3 | await registration?.update(); 4 | } 5 | -------------------------------------------------------------------------------- /src/App/Lab/UpdateInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices.JavaScript; 2 | using System.Runtime.Versioning; 3 | 4 | namespace DotNetLab.Lab; 5 | 6 | [SupportedOSPlatform("browser")] 7 | internal static partial class UpdateInfo 8 | { 9 | public static bool UpdateIsDownloading { get; private set; } 10 | 11 | [MemberNotNullWhen(returnValue: true, nameof(LoadUpdate))] 12 | public static bool UpdateIsAvailable => LoadUpdate is not null; 13 | 14 | public static Action? LoadUpdate { get; private set; } 15 | 16 | public static event Action? UpdateStatusChanged; 17 | 18 | [JSExport] 19 | public static void UpdateDownloading() 20 | { 21 | Console.WriteLine("Update is downloading"); 22 | UpdateIsDownloading = true; 23 | UpdateStatusChanged?.Invoke(); 24 | } 25 | 26 | [JSExport] 27 | public static void UpdateAvailable([JSMarshalAs] Action loadUpdate) 28 | { 29 | Console.WriteLine("Update is available"); 30 | LoadUpdate = loadUpdate; 31 | UpdateStatusChanged?.Invoke(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/App/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 | @Body 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/App/Logging.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab; 2 | 3 | internal static class Logging 4 | { 5 | public static LogLevel LogLevel { get; set; } = LogLevel.Information; 6 | 7 | public static void LogErrorAndAssert(this ILogger logger, string message) 8 | { 9 | logger.LogError(message); 10 | Debug.Fail(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/App/Npm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npm", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "webpack --config ./webpack.config.js --mode production" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "description": "", 13 | "devDependencies": { 14 | "@babel/core": "7.24.9", 15 | "babel-loader": "9.1.3", 16 | "css-loader": "7.1.2", 17 | "style-loader": "4.0.0", 18 | "webpack": "5.93.0", 19 | "webpack-cli": "5.1.4" 20 | }, 21 | "dependencies": { 22 | "monaco-vim": "0.4.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/App/Npm/src/index.js: -------------------------------------------------------------------------------- 1 | import { enableVimMode } from './vim-mode.js'; 2 | 3 | export function EnableVimMode(editorId, statusBarId) { 4 | return enableVimMode(editorId, statusBarId); 5 | } 6 | -------------------------------------------------------------------------------- /src/App/Npm/src/vim-mode.js: -------------------------------------------------------------------------------- 1 | import { initVimMode } from 'monaco-vim'; 2 | 3 | export function enableVimMode(editorId, statusBarId) { 4 | const editor = window.blazorMonaco.editors.find((e) => e.id === editorId).editor; 5 | const statusBar = document.getElementById(statusBarId); 6 | 7 | return initVimMode(editor, statusBar); 8 | } 9 | -------------------------------------------------------------------------------- /src/App/Npm/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.(js|jsx)$/, 8 | exclude: /node_modules/, 9 | use: { 10 | loader: "babel-loader" 11 | }, 12 | }, 13 | { 14 | test: /\.css$/i, 15 | use: ["style-loader", "css-loader"], 16 | }, 17 | ] 18 | }, 19 | output: { 20 | path: path.resolve(__dirname, '../wwwroot/js'), 21 | filename: "jslib.js", 22 | library: "jslib" 23 | } 24 | }; -------------------------------------------------------------------------------- /src/App/Program.cs: -------------------------------------------------------------------------------- 1 | using Blazored.LocalStorage; 2 | using DotNetLab; 3 | using DotNetLab.Lab; 4 | using Microsoft.AspNetCore.Components.Web; 5 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 6 | using Microsoft.FluentUI.AspNetCore.Components; 7 | using System.Runtime.Versioning; 8 | 9 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 10 | builder.RootComponents.Add("#app"); 11 | builder.RootComponents.Add("head::after"); 12 | 13 | builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); 14 | builder.Services.AddBlazoredLocalStorage(); 15 | builder.Services.AddFluentUIComponents(); 16 | 17 | builder.Services.AddScoped(); 18 | builder.Services.AddScoped(); 19 | builder.Services.AddScoped(); 20 | builder.Services.AddScoped(); 21 | builder.Services.AddScoped(); 22 | 23 | builder.Logging.AddFilter("DotNetLab.*", 24 | static (logLevel) => logLevel >= Logging.LogLevel); 25 | 26 | if (builder.HostEnvironment.IsDevelopment()) 27 | { 28 | Logging.LogLevel = LogLevel.Debug; 29 | } 30 | 31 | var host = builder.Build(); 32 | 33 | host.Services.GetRequiredService>() 34 | .LogInformation("Environment: {Environment}", builder.HostEnvironment.Environment); 35 | 36 | await host.RunAsync(); 37 | 38 | [SupportedOSPlatform("browser")] 39 | partial class Program; 40 | -------------------------------------------------------------------------------- /src/App/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("DotNetLab.UnitTests")] 4 | -------------------------------------------------------------------------------- /src/App/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 9 | "applicationUrl": "http://localhost:5126", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | }, 14 | "https": { 15 | "commandName": "Project", 16 | "dotnetRunMessages": true, 17 | "launchBrowser": true, 18 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 19 | "applicationUrl": "https://localhost:7283;http://localhost:5126", 20 | "environmentVariables": { 21 | "ASPNETCORE_ENVIRONMENT": "Development" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/App/Utils/Monaco/BlazorMonacoInterop.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco.Languages; 2 | using Microsoft.JSInterop; 3 | using System.Runtime.InteropServices.JavaScript; 4 | using System.Runtime.Versioning; 5 | using System.Text.Json; 6 | 7 | namespace DotNetLab; 8 | 9 | [SupportedOSPlatform("browser")] 10 | internal sealed partial class BlazorMonacoInterop 11 | { 12 | private const string moduleName = nameof(BlazorMonacoInterop); 13 | 14 | private readonly Lazy initialize = new(() => JSHost.ImportAsync(moduleName, "../js/BlazorMonacoInterop.js")); 15 | 16 | private Task EnsureInitializedAsync() => initialize.Value; 17 | 18 | [JSImport("registerCompletionProvider", moduleName)] 19 | private static partial JSObject RegisterCompletionProvider( 20 | string language, 21 | string[]? triggerCharacters, 22 | [JSMarshalAs] object completionItemProvider); 23 | 24 | [JSImport("dispose", moduleName)] 25 | private static partial void DisposeDisposable(JSObject disposable); 26 | 27 | [JSImport("onCancellationRequested", moduleName)] 28 | private static partial void OnCancellationRequested(JSObject token, [JSMarshalAs] Action callback); 29 | 30 | [JSExport] 31 | internal static async Task ProvideCompletionItemsAsync( 32 | [JSMarshalAs] object completionItemProviderReference, 33 | string modelUri, 34 | string position, 35 | string context, 36 | JSObject token) 37 | { 38 | var completionItemProvider = ((DotNetObjectReference)completionItemProviderReference).Value; 39 | string json = await completionItemProvider.ProvideCompletionItemsAsync( 40 | modelUri, 41 | JsonSerializer.Deserialize(position, BlazorMonacoJsonContext.Default.Position)!, 42 | JsonSerializer.Deserialize(context, BlazorMonacoJsonContext.Default.CompletionContext)!, 43 | ToCancellationToken(token)); 44 | return json; 45 | } 46 | 47 | [JSExport] 48 | internal static async Task ResolveCompletionItemAsync( 49 | [JSMarshalAs] object completionItemProviderReference, 50 | string item, 51 | JSObject token) 52 | { 53 | var completionItemProvider = ((DotNetObjectReference)completionItemProviderReference).Value; 54 | string? json = await completionItemProvider.ResolveCompletionItemAsync( 55 | JsonSerializer.Deserialize(item, BlazorMonacoJsonContext.Default.MonacoCompletionItem)!, 56 | ToCancellationToken(token)); 57 | return json; 58 | } 59 | 60 | public async Task RegisterCompletionProviderAsync( 61 | LanguageSelector language, 62 | CompletionItemProviderAsync completionItemProvider) 63 | { 64 | await EnsureInitializedAsync(); 65 | JSObject disposable = RegisterCompletionProvider( 66 | JsonSerializer.Serialize(language, BlazorMonacoJsonContext.Default.LanguageSelector), 67 | completionItemProvider.TriggerCharacters, 68 | DotNetObjectReference.Create(completionItemProvider)); 69 | return new Disposable(disposable); 70 | } 71 | 72 | public static CancellationToken ToCancellationToken(JSObject token) 73 | { 74 | // No need to initialize, we already must have called other APIs. 75 | var cts = new CancellationTokenSource(); 76 | OnCancellationRequested(token, cts.Cancel); 77 | return cts.Token; 78 | } 79 | 80 | private sealed class Disposable(JSObject disposable) : IDisposable 81 | { 82 | public void Dispose() 83 | { 84 | DisposeDisposable(disposable); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/App/Utils/Monaco/CompletionItemProviderAsync.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco; 2 | using BlazorMonaco.Languages; 3 | using System.Runtime.Versioning; 4 | 5 | namespace DotNetLab; 6 | 7 | /// 8 | /// 9 | /// 10 | [SupportedOSPlatform("browser")] 11 | internal sealed class CompletionItemProviderAsync(ILogger logger) 12 | { 13 | public delegate Task ProvideCompletionItemsDelegate(string modelUri, Position position, CompletionContext context, CancellationToken cancellationToken); 14 | 15 | public delegate Task ResolveCompletionItemDelegate(MonacoCompletionItem completionItem, CancellationToken cancellationToken); 16 | 17 | public ILogger Logger { get; } = logger; 18 | 19 | public string[]? TriggerCharacters { get; init; } 20 | 21 | public required ProvideCompletionItemsDelegate ProvideCompletionItemsFunc { get; init; } 22 | 23 | public required ResolveCompletionItemDelegate ResolveCompletionItemFunc { get; init; } 24 | 25 | public Task ProvideCompletionItemsAsync(string modelUri, Position position, CompletionContext context, CancellationToken cancellationToken) 26 | { 27 | return ProvideCompletionItemsFunc(modelUri, position, context, cancellationToken); 28 | } 29 | 30 | public Task ResolveCompletionItemAsync(MonacoCompletionItem completionItem, CancellationToken cancellationToken) 31 | { 32 | return ResolveCompletionItemFunc.Invoke(completionItem, cancellationToken); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/App/Utils/Monaco/MonacoUtil.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco.Editor; 2 | using Microsoft.JSInterop; 3 | 4 | namespace DotNetLab; 5 | 6 | internal static class MonacoUtil 7 | { 8 | public static Task GetTextAsync(this TextModel model) 9 | { 10 | return model.GetValue(EndOfLinePreference.TextDefined, preserveBOM: true); 11 | } 12 | 13 | public static async ValueTask SaveViewStateAsync(this Editor editor, IJSObjectReference module) 14 | { 15 | var result = await module.InvokeAsync("saveMonacoEditorViewState", editor.Id); 16 | return result; 17 | } 18 | 19 | public static async ValueTask RestoreViewStateAsync(this Editor editor, MonacoEditorViewState viewState, IJSObjectReference module) 20 | { 21 | if (viewState.Inner is { } inner) 22 | { 23 | await module.InvokeVoidAsync("restoreMonacoEditorViewState", editor.Id, inner); 24 | } 25 | } 26 | } 27 | 28 | internal readonly record struct MonacoEditorViewState(IJSObjectReference? Inner) : IAsyncDisposable 29 | { 30 | public ValueTask DisposeAsync() 31 | { 32 | return Inner is { } inner ? inner.DisposeAsync() : default; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/App/Utils/StringUtil.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab; 2 | 3 | internal static class StringUtil 4 | { 5 | public static string GetFirstLine(this string text) 6 | { 7 | foreach (var line in text.AsSpan().EnumerateLines()) 8 | { 9 | return line.ToString(); 10 | } 11 | 12 | return text; 13 | } 14 | 15 | public static string SeparateThousands(this int number) 16 | { 17 | return number.ToString("N0"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/App/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Collections.Immutable 2 | @using System.Diagnostics 3 | @using System.Net.Http 4 | @using System.Net.Http.Json 5 | @using System.Runtime.InteropServices.JavaScript 6 | @using System.Text.Json 7 | @using System.Text.Json.Serialization 8 | @using Microsoft.AspNetCore.Components.Forms 9 | @using Microsoft.AspNetCore.Components.Routing 10 | @using Microsoft.AspNetCore.Components.Web 11 | @using Microsoft.AspNetCore.Components.Web.Virtualization 12 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 13 | @using Microsoft.CodeAnalysis 14 | @using Microsoft.FluentUI.AspNetCore.Components 15 | @using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; 16 | @using Microsoft.JSInterop 17 | @using Blazored.LocalStorage 18 | @using BlazorMonaco 19 | @using BlazorMonaco.Editor 20 | @using BlazorMonaco.Languages 21 | @using DotNetLab 22 | @using DotNetLab.Layout 23 | @inherits CustomComponentBase 24 | -------------------------------------------------------------------------------- /src/App/wwwroot/DotNetLab.App.lib.module.js: -------------------------------------------------------------------------------- 1 | export async function afterStarted(blazor) { 2 | /** @type {import('./dotnet').RuntimeAPI} */ 3 | const runtime = blazor.runtime; 4 | 5 | const dotNetExports = await runtime.getAssemblyExports('DotNetLab.App.dll'); 6 | 7 | globalThis.DotNetLab = dotNetExports.DotNetLab; 8 | 9 | // When a new service worker version is activated 10 | // (after user clicks "Refresh" which sends 'skipWaiting' message to the worker), 11 | // reload the page so the new service worker is used to load all the assets. 12 | let refreshing = false; 13 | navigator.serviceWorker.addEventListener('controllerchange', () => { 14 | // Prevent inifinite refresh loop when "Update on Reload" is enabled in DevTools. 15 | if (refreshing) { 16 | return; 17 | } 18 | 19 | refreshing = true; 20 | window.location.reload(); 21 | }); 22 | 23 | // Check whether service worker has an update available. 24 | (async () => { 25 | if (location.hostname === 'localhost') { 26 | return; 27 | } 28 | 29 | const registration = await navigator.serviceWorker.getRegistration(); 30 | if (!registration) { 31 | return; 32 | } 33 | 34 | if (!navigator.serviceWorker.controller) { 35 | // No service worker controlling the page, so the new service worker 36 | // will be automatically activated immediately, we don't need to do anything. 37 | return; 38 | } 39 | 40 | if (registration.waiting) { 41 | updateAvailable(); 42 | return; 43 | } 44 | 45 | if (registration.installing) { 46 | updateDownloading(); 47 | return; 48 | } 49 | 50 | registration.addEventListener('updatefound', updateDownloading); 51 | 52 | function updateDownloading() { 53 | dotNetExports.DotNetLab.Lab.UpdateInfo.UpdateDownloading(); 54 | 55 | registration.installing.addEventListener('statechange', (event) => { 56 | if (event.target.state === 'installed') { 57 | updateAvailable(); 58 | } 59 | }) 60 | } 61 | 62 | function updateAvailable() { 63 | dotNetExports.DotNetLab.Lab.UpdateInfo.UpdateAvailable(() => { 64 | registration.waiting.postMessage('skipWaiting'); 65 | }); 66 | } 67 | })(); 68 | 69 | // Notify the app when the screen is narrow or wide. 70 | (async () => { 71 | const mediaQuery = window.matchMedia("(max-width: 600px)"); 72 | mediaQuery.addEventListener('change', reportMediaQuery); 73 | reportMediaQuery(mediaQuery); 74 | 75 | /** 76 | * @param {MediaQueryList | MediaQueryListEvent} e 77 | */ 78 | function reportMediaQuery(e) { 79 | dotNetExports.DotNetLab.Lab.ScreenInfo.SetNarrowScreen(e.matches); 80 | } 81 | })(); 82 | } 83 | -------------------------------------------------------------------------------- /src/App/wwwroot/css/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Keep in sync with `AppColors` in `Constants.cs`. */ 3 | --custom-light: #B399C8; 4 | --custom-dark: #8967AA; 5 | } 6 | 7 | .lab-header { 8 | background-color: light-dark(var(--custom-light), var(--custom-dark)) !important; 9 | } 10 | 11 | .lab-header > .header-gutters { 12 | display: flex !important; 13 | flex-flow: row wrap; 14 | justify-content: space-between; 15 | column-gap: 0.5rem; 16 | row-gap: 0.5rem; 17 | } 18 | 19 | .lab-header > .header-gutters > div { 20 | display: flex; 21 | flex-flow: row wrap; 22 | justify-content: center; 23 | align-items: center; 24 | column-gap: 0.5rem; 25 | row-gap: 0.5rem; 26 | } 27 | 28 | html, body { 29 | --body-font: "Segoe UI Variable", "Segoe UI", sans-serif; 30 | font-family: var(--body-font); 31 | font-size: var(--type-ramp-base-font-size); 32 | line-height: var(--type-ramp-base-line-height); 33 | height: 100%; 34 | } 35 | 36 | body { 37 | display: flex; 38 | flex-direction: column; 39 | } 40 | 41 | #blazor-error-ui { 42 | background: light-dark(var(--custom-light), var(--custom-dark)); 43 | bottom: 0; 44 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 45 | box-sizing: border-box; 46 | display: none; 47 | left: 0; 48 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 49 | position: fixed; 50 | width: 100%; 51 | z-index: 1000; 52 | } 53 | 54 | #blazor-error-ui .dismiss { 55 | cursor: pointer; 56 | position: absolute; 57 | right: 0.75rem; 58 | top: 0.5rem; 59 | } 60 | 61 | .blazor-error-boundary { 62 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 63 | padding: 1rem 1rem 1rem 3.7rem; 64 | color: white; 65 | } 66 | 67 | .blazor-error-boundary::after { 68 | content: "An error has occurred." 69 | } 70 | 71 | .loading-progress { 72 | position: relative; 73 | display: block; 74 | width: 8rem; 75 | height: 8rem; 76 | margin: 20vh auto 1rem auto; 77 | } 78 | 79 | .loading-progress .filling { 80 | transform: scaleY(var(--blazor-load-percentage, 0%)); 81 | transform-box: fill-box; 82 | transform-origin: bottom; 83 | } 84 | 85 | .loading-progress-text { 86 | text-align: center; 87 | font-weight: bold; 88 | font-size: 1.5em; 89 | } 90 | 91 | .loading-progress-text:after { 92 | content: var(--blazor-load-percentage-text, "0%"); 93 | } 94 | 95 | .loading-info { 96 | margin-top: 1em; 97 | text-align: center; 98 | } 99 | 100 | code { 101 | color: #c02d76; 102 | } 103 | 104 | /* 105 | Causes Monaco Editor to resize properly. 106 | See https://github.com/microsoft/monaco-editor/issues/3512. 107 | */ 108 | .monaco-editor-container { 109 | width: 99%; 110 | height: 99%; 111 | } 112 | -------------------------------------------------------------------------------- /src/App/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjonescz/DotNetLab/9696f50dc5e9ce5db03ae7950f781c5f46e252d2/src/App/wwwroot/favicon.png -------------------------------------------------------------------------------- /src/App/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | .NET Lab 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 | 44 |
45 |

.NET Lab

46 | GitHub repo 47 | 48 |
49 |
50 | 51 |
52 | An unhandled error has occurred. 53 | Reload 54 | 🗙 55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/App/wwwroot/js/BlazorMonacoInterop.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} language 3 | * @param {string[] | undefined} triggerCharacters 4 | */ 5 | export function registerCompletionProvider(language, triggerCharacters, completionItemProvider) { 6 | return monaco.languages.registerCompletionItemProvider(JSON.parse(language), { 7 | triggerCharacters: triggerCharacters, 8 | provideCompletionItems: async (model, position, context, token) => { 9 | /** @type {monaco.languages.CompletionList} */ 10 | const result = JSON.parse(await globalThis.DotNetLab.BlazorMonacoInterop.ProvideCompletionItemsAsync( 11 | completionItemProvider, decodeURI(model.uri.toString()), JSON.stringify(position), JSON.stringify(context), token)); 12 | 13 | for (const item of result.suggestions) { 14 | // `insertText` is missing if it's equal to `label` to save bandwidth 15 | // but monaco editor expects it to be always present. 16 | item.insertText ??= item.label; 17 | 18 | // These are the same for all completion items. 19 | item.range = result.range; 20 | item.commitCharacters = result.commitCharacters; 21 | } 22 | 23 | return result; 24 | }, 25 | resolveCompletionItem: async (completionItem, token) => { 26 | const json = await globalThis.DotNetLab.BlazorMonacoInterop.ResolveCompletionItemAsync( 27 | completionItemProvider, JSON.stringify(completionItem), token); 28 | return json ? JSON.parse(json) : completionItem; 29 | }, 30 | }); 31 | } 32 | 33 | /** 34 | * @param {monaco.IDisposable} disposable 35 | */ 36 | export function dispose(disposable) { 37 | disposable.dispose(); 38 | } 39 | 40 | /** 41 | * @param {monaco.CancellationToken} token 42 | * @param {() => void} callback 43 | */ 44 | export function onCancellationRequested(token, callback) { 45 | token.onCancellationRequested(callback); 46 | } 47 | -------------------------------------------------------------------------------- /src/App/wwwroot/js/WorkerController.js: -------------------------------------------------------------------------------- 1 | export function createWorker(scriptUrl, messageHandler, errorHandler) { 2 | const worker = new Worker(scriptUrl, { type: 'module' }); 3 | worker.addEventListener('message', (e) => { messageHandler(e.data); }); 4 | worker.addEventListener('error', (e) => { console.error(e); errorHandler(e.message ?? `${e.error ?? e}`); }); 5 | worker.addEventListener('messageerror', () => { errorHandler('message error'); }); 6 | return worker; 7 | } 8 | 9 | /** 10 | * @param {Worker} worker 11 | * @param {string} message 12 | */ 13 | export function postMessage(worker, message) { 14 | worker.postMessage(message); 15 | } 16 | 17 | /** 18 | * @param {Worker} worker 19 | */ 20 | export function disposeWorker(worker) { 21 | worker.terminate(); 22 | } 23 | -------------------------------------------------------------------------------- /src/App/wwwroot/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": ".NET Lab", 3 | "short_name": ".NET Lab", 4 | "id": "./", 5 | "start_url": "./", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#03173d", 9 | "prefer_related_applications": false, 10 | "icons": [ 11 | { 12 | "src": "favicon.png", 13 | "type": "image/png", 14 | "sizes": "512x512" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/App/wwwroot/service-worker.js: -------------------------------------------------------------------------------- 1 | // In development, always fetch from the network and do not enable offline support. 2 | // This is because caching would make development more difficult (changes would not 3 | // be reflected on the first load after each change). 4 | self.addEventListener('fetch', () => { }); 5 | -------------------------------------------------------------------------------- /src/App/wwwroot/service-worker.published.js: -------------------------------------------------------------------------------- 1 | // Caution! Be sure you understand the caveats before publishing an application with 2 | // offline support. See https://aka.ms/blazor-offline-considerations 3 | 4 | // Some reload logic inspired by https://stackoverflow.com/a/50535316. 5 | 6 | /// 7 | 8 | const worker = /** @type {ServiceWorkerGlobalScope} */ (self); 9 | 10 | worker.importScripts('./service-worker-assets.js'); 11 | worker.addEventListener('install', event => event.waitUntil(onInstall(event))); 12 | worker.addEventListener('activate', event => event.waitUntil(onActivate(event))); 13 | worker.addEventListener('fetch', event => event.respondWith(onFetch(event))); 14 | worker.addEventListener('message', event => onMessage(event)); 15 | 16 | const cacheNamePrefix = 'offline-cache-'; 17 | const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; 18 | const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm$/, /\.html$/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/, /\.ttf$/ ]; 19 | const offlineAssetsExclude = [ /^service-worker\.js$/ ]; 20 | 21 | // Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. 22 | const base = "/"; 23 | const baseUrl = new URL(base, self.origin); 24 | const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href); 25 | 26 | async function onInstall() { 27 | console.info('Service worker: Install'); 28 | 29 | // Fetch and cache all matching items from the assets manifest 30 | const assetsRequests = self.assetsManifest.assets 31 | .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) 32 | .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) 33 | .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); 34 | const cache = await caches.open(cacheName); 35 | await cache.addAll(assetsRequests); 36 | 37 | // Clean responses. 38 | // Removes `redirected` flag so the response is servable by the service worker. 39 | // https://stackoverflow.com/a/45440505/9080566 40 | // https://github.com/dotnet/aspnetcore/issues/33872 41 | // Also avoids other inexplicable failures when serving responses from the service worker. 42 | for (const request of assetsRequests) { 43 | const response = await cache.match(request); 44 | const clonedResponse = response.clone(); 45 | const responseData = await clonedResponse.arrayBuffer(); 46 | const cleanedResponse = new Response(responseData, { 47 | headers: { 48 | 'content-type': clonedResponse.headers.get('content-type') ?? '', 49 | 'content-length': responseData.byteLength.toString(), 50 | }, 51 | }); 52 | await cache.put(request, cleanedResponse); 53 | } 54 | } 55 | 56 | async function onActivate() { 57 | console.info('Service worker: Activate'); 58 | 59 | // Delete unused caches 60 | const cacheKeys = await caches.keys(); 61 | await Promise.all(cacheKeys 62 | .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) 63 | .map(key => caches.delete(key))); 64 | } 65 | 66 | /** 67 | * @param {FetchEvent} event 68 | */ 69 | async function onFetch(event) { 70 | // If there is only one remaining client that is navigating 71 | // (e.g., being refreshed using the broswer reload button), 72 | // and there is a new version of the service worker waiting, 73 | // force active the new version and reload the page (so it uses the new version). 74 | if (event.request.mode === 'navigate' && 75 | event.request.method === 'GET' && 76 | worker.registration.waiting && 77 | (await worker.clients.matchAll()).length < 2 78 | ) { 79 | worker.registration.waiting.postMessage('skipWaiting'); 80 | return new Repsonse('', { headers: { Refresh: '0' } }); 81 | } 82 | 83 | let cachedResponse = null; 84 | if (event.request.method === 'GET') { 85 | // For all navigation requests, try to serve index.html from cache, 86 | // unless that request is for an offline resource. 87 | // If you need some URLs to be server-rendered, edit the following check to exclude those URLs 88 | const shouldServeIndexHtml = event.request.mode === 'navigate' 89 | && !manifestUrlList.some(url => url === event.request.url); 90 | 91 | if (shouldServeIndexHtml) { 92 | console.debug(`Service worker: serving index.html for ${event.request.url}`); 93 | } 94 | 95 | const request = shouldServeIndexHtml ? 'index.html' : event.request; 96 | 97 | const cache = await caches.open(cacheName); 98 | // We ignore search query (so our pre-cached `app.css` matches request `app.css?v=2`), 99 | // we have pre-cached the latest versions of all static assets anyway. 100 | cachedResponse = await cache.match(request, { ignoreSearch: true }); 101 | } 102 | 103 | if (!cachedResponse) { 104 | console.debug(`Service worker: cache miss for ${event.request.url}`); 105 | } 106 | 107 | return cachedResponse || fetch(event.request); 108 | } 109 | 110 | /** 111 | * @param {ExtendableMessageEvent} event 112 | */ 113 | function onMessage(event) { 114 | if (event.data === 'skipWaiting') { 115 | worker.skipWaiting(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Compiler/Api.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis.CSharp; 2 | 3 | namespace DotNetLab; 4 | 5 | public static class Config 6 | { 7 | private static readonly List> cSharpParseOptions = new(); 8 | private static readonly List> cSharpCompilationOptions = new(); 9 | 10 | internal static void Reset() 11 | { 12 | cSharpParseOptions.Clear(); 13 | cSharpCompilationOptions.Clear(); 14 | } 15 | 16 | public static void CSharpParseOptions(Func configure) 17 | { 18 | cSharpParseOptions.Add(configure); 19 | } 20 | 21 | public static void CSharpCompilationOptions(Func configure) 22 | { 23 | cSharpCompilationOptions.Add(configure); 24 | } 25 | 26 | internal static CSharpParseOptions ConfigureCSharpParseOptions(CSharpParseOptions options) => Configure(options, cSharpParseOptions); 27 | 28 | internal static CSharpCompilationOptions ConfigureCSharpCompilationOptions(CSharpCompilationOptions options) => Configure(options, cSharpCompilationOptions); 29 | 30 | private static T Configure(T options, List> configureList) 31 | { 32 | foreach (var configure in configureList) 33 | { 34 | options = configure(options); 35 | } 36 | 37 | return options; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Compiler/Compiler.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Compiler/RefAssemblyMetadata.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | 3 | namespace DotNetLab; 4 | 5 | public static class RefAssemblyMetadata 6 | { 7 | private static readonly Lazy> all = new(GetAll); 8 | 9 | private static ImmutableArray GetAll() 10 | { 11 | var all = RefAssemblies.All; 12 | var builder = ImmutableArray.CreateBuilder(all.Length); 13 | 14 | foreach (var assembly in all) 15 | { 16 | builder.Add(AssemblyMetadata.CreateFromImage(assembly.Bytes) 17 | .GetReference(filePath: assembly.FileName, display: assembly.Name)); 18 | } 19 | 20 | return builder.DrainToImmutable(); 21 | } 22 | 23 | public static ImmutableArray All => all.Value; 24 | } 25 | -------------------------------------------------------------------------------- /src/RazorAccess/RazorAccess.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Microsoft.CodeAnalysis.Razor.Test 5 | $(PkgMicrosoft_DotNet_Arcade_Sdk)\tools\snk\AspNetCore.snk 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/RazorAccess/RazorAccessors.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Razor.Language; 2 | using Microsoft.AspNetCore.Razor.Language.Intermediate; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.NET.Sdk.Razor.SourceGenerators; 5 | using System.Runtime.CompilerServices; 6 | 7 | namespace DotNetLab; 8 | 9 | public static class RazorAccessors 10 | { 11 | public static RazorProjectItem CreateSourceGeneratorProjectItem( 12 | string basePath, 13 | string filePath, 14 | string relativePhysicalPath, 15 | AdditionalText additionalText, 16 | string? cssScope) 17 | { 18 | var ctor = typeof(SourceGeneratorProjectItem).GetConstructors().First(); 19 | object? fileKind = ctor.GetParameters()[3].ParameterType.IsEnum 20 | ? GetFileKindFromPath(additionalText.Path) 21 | : null; // will be automatically determined from file path 22 | 23 | return (RazorProjectItem)ctor.Invoke([ 24 | /* basePath: */ basePath, 25 | /* filePath: */ filePath, 26 | /* relativePhysicalPath: */ relativePhysicalPath, 27 | /* fileKind: */ fileKind, 28 | /* additionalText: */ additionalText, 29 | /* cssScope: */ cssScope]); 30 | } 31 | 32 | /// 33 | /// Wrapper to avoid s in the caller during JITing 34 | /// even though the method is not actually called. 35 | /// 36 | [MethodImpl(MethodImplOptions.NoInlining)] 37 | private static object GetFileKindFromPath(string filePath) 38 | { 39 | return FileKinds.GetFileKindFromPath(filePath); 40 | } 41 | 42 | public static string Serialize(this DocumentIntermediateNode node) 43 | { 44 | var formatter = new DebuggerDisplayFormatter(); 45 | formatter.FormatTree(node); 46 | return formatter.ToString(); 47 | } 48 | 49 | public static string Serialize(this RazorSyntaxTree tree) 50 | { 51 | return tree.Root.SerializedValue; 52 | } 53 | } 54 | 55 | public sealed class VirtualRazorProjectFileSystemProxy 56 | { 57 | private readonly VirtualRazorProjectFileSystem inner = new(); 58 | 59 | public RazorProjectFileSystem Inner => inner; 60 | 61 | public void Add(RazorProjectItem item) => inner.Add(item); 62 | } 63 | -------------------------------------------------------------------------------- /src/RoslynAccess/RoslynAccess.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Microsoft.CodeAnalysis.CSharp.Test.Utilities 5 | $(PkgMicrosoft_DotNet_Arcade_Sdk)\tools\snk\35MSSharedLib1024.snk 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/RoslynAccess/RoslynAccessors.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp; 3 | using Microsoft.CodeAnalysis.Test.Utilities; 4 | 5 | namespace DotNetLab; 6 | 7 | file enum TriviaKind 8 | { 9 | Leading, 10 | Trailing, 11 | } 12 | 13 | public static class RoslynAccessors 14 | { 15 | public static string Dump(this CSharpSyntaxNode node) 16 | { 17 | return node.Dump(); 18 | } 19 | 20 | public static string DumpExtended(this CSharpSyntaxNode node) 21 | { 22 | return TreeDumper.DumpCompact(nodeOrTokenToTree(node)); 23 | 24 | static TreeDumperNode nodeOrTokenToTree(SyntaxNodeOrToken nodeOrToken) 25 | { 26 | string text = nodeOrToken.Kind().ToString(); 27 | 28 | if (nodeOrToken.AsNode(out var node)) 29 | { 30 | return new TreeDumperNode(text, null, node.ChildNodesAndTokens().Select(nodeOrTokenToTree)); 31 | } 32 | 33 | return new TreeDumperNode(text + " " + stringOrMissing(nodeOrToken), null, 34 | [ 35 | ..triviaNode(TriviaKind.Leading, nodeOrToken.GetLeadingTrivia()), 36 | ..triviaNode(TriviaKind.Trailing, nodeOrToken.GetTrailingTrivia()), 37 | ]); 38 | } 39 | 40 | static IEnumerable triviaNode(TriviaKind kind, SyntaxTriviaList triviaList) 41 | { 42 | if (!triviaList.Any()) 43 | { 44 | return []; 45 | } 46 | 47 | var text = kind switch 48 | { 49 | TriviaKind.Leading => "LeadingTrivia", 50 | TriviaKind.Trailing => "TrailingTrivia", 51 | _ => throw new ArgumentOutOfRangeException(paramName: nameof(kind), message: kind.ToString()), 52 | }; 53 | 54 | return [new TreeDumperNode(text, null, triviaList.Select(triviaToTree))]; 55 | } 56 | 57 | static TreeDumperNode triviaToTree(SyntaxTrivia trivia) 58 | { 59 | return new TreeDumperNode($""" 60 | {trivia.Kind()} "{withoutNewLines(trivia.ToString())}" 61 | """, null, 62 | trivia.GetStructure() is { } structure ? [nodeOrTokenToTree(structure)] : []); 63 | } 64 | 65 | static string stringOrMissing(SyntaxNodeOrToken nodeOrToken) 66 | { 67 | if (!nodeOrToken.IsMissing) 68 | { 69 | return $""" 70 | "{withoutNewLines(nodeOrToken.ToString())}" 71 | """; 72 | } 73 | 74 | return ""; 75 | } 76 | 77 | static string withoutNewLines(string s) 78 | { 79 | return s.ReplaceLineEndings("⏎"); 80 | } 81 | } 82 | 83 | public static string GetDiagnosticsText(this IEnumerable actual) 84 | { 85 | var sb = new StringBuilder(); 86 | var e = actual.GetEnumerator(); 87 | for (int i = 0; e.MoveNext(); i++) 88 | { 89 | Diagnostic d = e.Current; 90 | string message = ((IFormattable)d).ToString(null, CultureInfo.InvariantCulture); 91 | 92 | if (i > 0) 93 | { 94 | sb.AppendLine(","); 95 | } 96 | 97 | sb.Append("// "); 98 | sb.AppendLine(message); 99 | var l = d.Location; 100 | if (l.IsInSource) 101 | { 102 | sb.Append("// "); 103 | sb.AppendLine(l.SourceTree.GetText().Lines.GetLineFromPosition(l.SourceSpan.Start).ToString()); 104 | } 105 | 106 | var description = new DiagnosticDescription(d, errorCodeOnly: false); 107 | sb.Append(description.ToString()); 108 | } 109 | return sb.ToString(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/RoslynWorkspaceAccess/RoslynWorkspaceAccess.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Microsoft.CodeAnalysis.Workspaces.Test.Utilities 5 | $(PkgMicrosoft_DotNet_Arcade_Sdk)\tools\snk\35MSSharedLib1024.snk 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/RoslynWorkspaceAccess/RoslynWorkspaceAccessors.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis.Internal.Log; 2 | 3 | namespace DotNetLab; 4 | 5 | public static class RoslynWorkspaceAccessors 6 | { 7 | public static void SetLogger(Action logger) 8 | { 9 | Logger.SetLogger(new RoslynLogger(logger)); 10 | } 11 | } 12 | 13 | internal sealed class RoslynLogger(Action logger) : ILogger 14 | { 15 | public bool IsEnabled(FunctionId functionId) 16 | { 17 | return true; 18 | } 19 | 20 | public void Log(FunctionId functionId, LogMessage logMessage) 21 | { 22 | logger($"{logMessage.LogLevel} {functionId} {logMessage.GetMessage()}"); 23 | } 24 | 25 | public void LogBlockStart(FunctionId functionId, LogMessage logMessage, int uniquePairId, CancellationToken cancellationToken) 26 | { 27 | logger($"{logMessage.LogLevel} {functionId} start({uniquePairId}) {logMessage.GetMessage()}"); 28 | } 29 | 30 | public void LogBlockEnd(FunctionId functionId, LogMessage logMessage, int uniquePairId, int delta, CancellationToken cancellationToken) 31 | { 32 | string suffix = cancellationToken.IsCancellationRequested ? " cancelled" : string.Empty; 33 | logger($"{logMessage.LogLevel} {functionId}{suffix} end({uniquePairId}, {delta}ms) {logMessage.GetMessage()}"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Server/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | var app = builder.Build(); 4 | 5 | if (app.Environment.IsDevelopment()) 6 | { 7 | app.UseWebAssemblyDebugging(); 8 | } 9 | 10 | app.UseBlazorFrameworkFiles(); 11 | app.UseStaticFiles(); 12 | app.MapFallbackToFile("index.html"); 13 | 14 | app.Run(); 15 | -------------------------------------------------------------------------------- /src/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:43772", 7 | "sslPort": 44301 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 16 | "applicationUrl": "http://localhost:5116", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "https": { 22 | "commandName": "Project", 23 | "dotnetRunMessages": true, 24 | "launchBrowser": true, 25 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 26 | "applicationUrl": "https://localhost:7171;http://localhost:5116", 27 | "environmentVariables": { 28 | "ASPNETCORE_ENVIRONMENT": "Development" 29 | } 30 | }, 31 | "IIS Express": { 32 | "commandName": "IISExpress", 33 | "launchBrowser": true, 34 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 35 | "environmentVariables": { 36 | "ASPNETCORE_ENVIRONMENT": "Development" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Server/Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Shared/Executor.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.Web; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | using System.Runtime.Loader; 5 | 6 | namespace DotNetLab; 7 | 8 | public static class Executor 9 | { 10 | public static string Execute(MemoryStream emitStream) 11 | { 12 | var alc = new AssemblyLoadContext(nameof(Executor)); 13 | try 14 | { 15 | var assembly = alc.LoadFromStream(emitStream); 16 | 17 | var entryPoint = assembly.EntryPoint 18 | ?? throw new ArgumentException("No entry point found in the assembly."); 19 | 20 | int exitCode = 0; 21 | Util.CaptureConsoleOutput( 22 | () => 23 | { 24 | try 25 | { 26 | exitCode = InvokeEntryPoint(entryPoint); 27 | } 28 | catch (TargetInvocationException e) 29 | { 30 | Console.Error.WriteLine($"Unhandled exception. {e.InnerException ?? e}"); 31 | exitCode = unchecked((int)0xE0434352); 32 | } 33 | }, 34 | out string stdout, out string stderr); 35 | 36 | return $"Exit code: {exitCode}\nStdout:\n{stdout}\nStderr:\n{stderr}"; 37 | } 38 | catch (Exception ex) 39 | { 40 | return ex.ToString(); 41 | } 42 | } 43 | 44 | public static int InvokeEntryPoint(MethodInfo entryPoint) 45 | { 46 | var parameters = entryPoint.GetParameters().Length == 0 47 | ? null 48 | : new object[] { Array.Empty() }; 49 | return entryPoint.Invoke(null, parameters) is int e ? e : 0; 50 | } 51 | 52 | public static async Task RenderComponentToHtmlAsync(MemoryStream emitStream, string componentTypeName) 53 | { 54 | var alc = new AssemblyLoadContext(nameof(RenderComponentToHtmlAsync)); 55 | var assembly = alc.LoadFromStream(emitStream); 56 | var componentType = assembly.GetType(componentTypeName) 57 | ?? throw new InvalidOperationException($"Cannot find component '{componentTypeName}' in the assembly."); 58 | 59 | var services = new ServiceCollection(); 60 | services.AddLogging(); 61 | var serviceProvider = services.BuildServiceProvider(); 62 | var loggerFactory = serviceProvider.GetRequiredService(); 63 | var renderer = new HtmlRenderer(serviceProvider, loggerFactory); 64 | var html = await renderer.Dispatcher.InvokeAsync(async () => 65 | { 66 | var output = await renderer.RenderComponentAsync(componentType); 67 | return output.ToHtmlString(); 68 | }); 69 | return html; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Shared/ICompiler.cs: -------------------------------------------------------------------------------- 1 | using ProtoBuf; 2 | using System.Runtime.Loader; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace DotNetLab; 6 | 7 | public interface ICompiler 8 | { 9 | CompiledAssembly Compile( 10 | CompilationInput input, 11 | ImmutableDictionary>? assemblies, 12 | ImmutableDictionary>? builtInAssemblies, 13 | AssemblyLoadContext alc); 14 | } 15 | 16 | public sealed record CompilationInput 17 | { 18 | public CompilationInput(Sequence inputs) 19 | { 20 | Inputs = inputs; 21 | } 22 | 23 | public Sequence Inputs { get; } 24 | public string? Configuration { get; init; } 25 | public RazorToolchain RazorToolchain { get; init; } 26 | public RazorStrategy RazorStrategy { get; init; } 27 | } 28 | 29 | public enum RazorToolchain 30 | { 31 | InternalApi, 32 | SourceGeneratorOrInternalApi, 33 | SourceGenerator, 34 | } 35 | 36 | public enum RazorStrategy 37 | { 38 | Runtime, 39 | DesignTime, 40 | } 41 | 42 | [ProtoContract] 43 | public sealed record InputCode 44 | { 45 | [ProtoMember(1)] 46 | public required string FileName { get; init; } 47 | [ProtoMember(2)] 48 | public required string Text { get; init; } 49 | 50 | public string FileExtension => Path.GetExtension(FileName); 51 | } 52 | 53 | public enum DiagnosticDataSeverity 54 | { 55 | Info, 56 | Warning, 57 | Error, 58 | } 59 | 60 | public sealed record DiagnosticData( 61 | string? FilePath, 62 | DiagnosticDataSeverity Severity, 63 | string Id, 64 | string? HelpLinkUri, 65 | string Message, 66 | int StartLineNumber, 67 | int StartColumn, 68 | int EndLineNumber, 69 | int EndColumn 70 | ); 71 | 72 | /// 73 | /// 74 | /// Should always serialize to the same JSON (e.g., no unsorted dictionaries) 75 | /// because that is used in template cache tests. 76 | /// Should be also compatible between versions of DotNetLab if possible 77 | /// (because the JSON-serialized values are cached). 78 | /// 79 | /// 80 | public sealed record CompiledAssembly( 81 | ImmutableSortedDictionary Files, 82 | ImmutableArray GlobalOutputs, 83 | int NumWarnings, 84 | int NumErrors, 85 | ImmutableArray Diagnostics, 86 | string BaseDirectory) 87 | { 88 | /// 89 | /// Number of entries in (from the beginning) which belong to the special configuration file. 90 | /// 91 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] 92 | public int ConfigDiagnosticCount { get; init; } 93 | 94 | public static readonly string DiagnosticsOutputType = "errors"; 95 | public static readonly string DiagnosticsOutputLabel = "Error List"; 96 | 97 | public static CompiledAssembly Fail(string output) 98 | { 99 | return new( 100 | BaseDirectory: "/", 101 | Files: ImmutableSortedDictionary.Empty, 102 | Diagnostics: [], 103 | GlobalOutputs: 104 | [ 105 | new() 106 | { 107 | Type = DiagnosticsOutputType, 108 | Label = DiagnosticsOutputLabel, 109 | EagerText = output, 110 | }, 111 | ], 112 | NumErrors: 1, 113 | NumWarnings: 0); 114 | } 115 | 116 | public CompiledFileOutput? GetGlobalOutput(string type) 117 | { 118 | return GlobalOutputs.FirstOrDefault(o => o.Type == type); 119 | } 120 | 121 | public CompiledFileOutput GetRequiredGlobalOutput(string type) 122 | { 123 | return GetGlobalOutput(type) 124 | ?? throw new InvalidOperationException($"Global output of type '{type}' not found."); 125 | } 126 | } 127 | 128 | public sealed record CompiledFile(ImmutableArray Outputs) 129 | { 130 | public CompiledFileOutput? GetOutput(string type) 131 | { 132 | return Outputs.FirstOrDefault(o => o.Type == type); 133 | } 134 | 135 | public CompiledFileOutput GetRequiredOutput(string type) 136 | { 137 | return GetOutput(type) 138 | ?? throw new InvalidOperationException($"Output of type '{type}' not found."); 139 | } 140 | } 141 | 142 | public sealed class CompiledFileOutput 143 | { 144 | private object? text; 145 | 146 | public required string Type { get; init; } 147 | public required string Label { get; init; } 148 | public int Priority { get; init; } 149 | public string? Language { get; init; } 150 | 151 | public string? EagerText 152 | { 153 | get 154 | { 155 | if (text is string eagerText) 156 | { 157 | return eagerText; 158 | } 159 | 160 | if (text is ValueTask { IsCompletedSuccessfully: true, Result: var taskResult }) 161 | { 162 | text = taskResult; 163 | return taskResult; 164 | } 165 | 166 | return null; 167 | } 168 | init 169 | { 170 | text ??= value; 171 | } 172 | } 173 | 174 | public Func> LazyText 175 | { 176 | init 177 | { 178 | text ??= value; 179 | } 180 | } 181 | 182 | public ValueTask GetTextAsync(Func>? outputFactory) 183 | { 184 | if (EagerText is { } eagerText) 185 | { 186 | return new(eagerText); 187 | } 188 | 189 | if (text is null) 190 | { 191 | if (outputFactory is null) 192 | { 193 | throw new InvalidOperationException($"For uncached lazy texts, {nameof(outputFactory)} must be provided."); 194 | } 195 | 196 | var output = outputFactory(); 197 | text = output; 198 | return output; 199 | } 200 | 201 | if (text is ValueTask valueTask) 202 | { 203 | return valueTask; 204 | } 205 | 206 | if (text is Func> factory) 207 | { 208 | var result = factory(); 209 | text = result; 210 | return result; 211 | } 212 | 213 | throw new InvalidOperationException($"Unrecognized {nameof(CompiledFileOutput)}.{nameof(text)}: {text?.GetType().FullName ?? "null"}"); 214 | } 215 | 216 | internal void SetEagerText(string? value) 217 | { 218 | text = value; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/Shared/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("DotNetLab.UnitTests")] 4 | -------------------------------------------------------------------------------- /src/Shared/RefAssemblies.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace DotNetLab; 4 | 5 | public static class RefAssemblies 6 | { 7 | private static readonly Lazy> all = new(GetAll); 8 | 9 | private static ImmutableArray GetAll() 10 | { 11 | var names = typeof(RefAssemblies).Assembly.GetManifestResourceNames(); 12 | var builder = ImmutableArray.CreateBuilder(names.Length); 13 | 14 | foreach (var name in names) 15 | { 16 | var stream = typeof(RefAssemblies).Assembly.GetManifestResourceStream(name) 17 | ?? throw new InvalidOperationException($"Did not find resource '{name}'."); 18 | var bytes = new byte[stream.Length]; 19 | stream.ReadExactly(bytes, 0, bytes.Length); 20 | builder.Add(new() 21 | { 22 | Name = name, 23 | FileName = name + ".dll", 24 | Bytes = ImmutableCollectionsMarshal.AsImmutableArray(bytes), 25 | }); 26 | } 27 | 28 | return builder.DrainToImmutable(); 29 | } 30 | 31 | public static ImmutableArray All => all.Value; 32 | } 33 | 34 | public readonly struct RefAssembly 35 | { 36 | public required string Name { get; init; } 37 | public required string FileName { get; init; } 38 | public required ImmutableArray Bytes { get; init; } 39 | } 40 | -------------------------------------------------------------------------------- /src/Shared/Sequence.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace DotNetLab; 4 | 5 | /// 6 | /// An equatable . 7 | /// 8 | /// 9 | /// Beware of implementing , 10 | /// that would make use collection converter 11 | /// instead of object converter (see also https://github.com/dotnet/runtime/issues/63791), 12 | /// i.e., would be ignored and we would need to implement a custom serializer. 13 | /// 14 | [method: JsonConstructor] 15 | public readonly struct Sequence(ImmutableArray value) : IEquatable> 16 | { 17 | public ImmutableArray Value { get; } = value; 18 | 19 | public bool Equals(Sequence other) 20 | { 21 | if (Value.IsDefault) 22 | { 23 | return other.Value.IsDefault; 24 | } 25 | 26 | if (other.Value.IsDefault) 27 | { 28 | return false; 29 | } 30 | 31 | var comparer = EqualityComparer.Default; 32 | var enu1 = Value.GetEnumerator(); 33 | var enu2 = other.Value.GetEnumerator(); 34 | while (true) 35 | { 36 | bool has1 = enu1.MoveNext(); 37 | bool has2 = enu2.MoveNext(); 38 | 39 | if (has1 != has2) 40 | { 41 | return false; 42 | } 43 | 44 | if (!has1 && !has2) 45 | { 46 | return true; 47 | } 48 | 49 | if (!comparer.Equals(enu1.Current, enu2.Current)) 50 | { 51 | return false; 52 | } 53 | } 54 | } 55 | 56 | public override bool Equals(object? obj) 57 | { 58 | return obj is Sequence sequence && Equals(sequence); 59 | } 60 | 61 | public static bool operator ==(Sequence left, Sequence right) 62 | { 63 | return left.Equals(right); 64 | } 65 | 66 | public static bool operator !=(Sequence left, Sequence right) 67 | { 68 | return !(left == right); 69 | } 70 | 71 | public override int GetHashCode() 72 | { 73 | var hash = new HashCode(); 74 | foreach (var item in Value) 75 | { 76 | hash.Add(item); 77 | } 78 | return hash.ToHashCode(); 79 | } 80 | 81 | public override string ToString() 82 | { 83 | return Value.IsDefault ? string.Empty : Value.JoinToString(", "); 84 | } 85 | 86 | public static implicit operator ImmutableArray(Sequence sequence) 87 | { 88 | return sequence.Value; 89 | } 90 | 91 | public static implicit operator Sequence(ImmutableArray value) 92 | { 93 | return new(value); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Shared/Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | <_RefAssemblyToEmbed Include="$(NuGetPackageRoot)\microsoft.netcore.app.ref\$(NetCoreVersion)\ref\*\*.dll" /> 16 | <_RefAssemblyToEmbed Include="$(NuGetPackageRoot)\microsoft.aspnetcore.app.ref\$(AspNetCoreVersion)\ref\*\*.dll" /> 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Shared/Util.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace DotNetLab; 4 | 5 | public static class Util 6 | { 7 | public static void AddRange(this ICollection collection, IEnumerable items) 8 | { 9 | foreach (var item in items) 10 | { 11 | collection.Add(item); 12 | } 13 | } 14 | 15 | public static void AddRange(this ICollection collection, ReadOnlySpan items) 16 | { 17 | foreach (var item in items) 18 | { 19 | collection.Add(item); 20 | } 21 | } 22 | 23 | public static void CaptureConsoleOutput(Action action, out string stdout, out string stderr) 24 | { 25 | using var stdoutWriter = new StringWriter(); 26 | using var stderrWriter = new StringWriter(); 27 | var originalOut = Console.Out; 28 | var originalError = Console.Error; 29 | Console.SetOut(stdoutWriter); 30 | Console.SetError(stderrWriter); 31 | try 32 | { 33 | action(); 34 | } 35 | finally 36 | { 37 | stdout = stdoutWriter.ToString(); 38 | stderr = stderrWriter.ToString(); 39 | Console.SetOut(originalOut); 40 | Console.SetError(originalError); 41 | } 42 | } 43 | 44 | public static async IAsyncEnumerable Concat(this IAsyncEnumerable a, IEnumerable b) 45 | { 46 | await foreach (var item in a) 47 | { 48 | yield return item; 49 | } 50 | foreach (var item in b) 51 | { 52 | yield return item; 53 | } 54 | } 55 | 56 | public static async IAsyncEnumerable Concat(this IAsyncEnumerable a, IEnumerable> b) 57 | { 58 | await foreach (var item in a) 59 | { 60 | yield return item; 61 | } 62 | foreach (var item in b) 63 | { 64 | yield return await item; 65 | } 66 | } 67 | 68 | /// 69 | /// Use in a block to ensure it doesn't contain any s. 70 | /// 71 | public static R EnsureSync() => default; 72 | 73 | public static bool IsCSharpFileName(this string fileName) => fileName.IsCSharpFileName(out _); 74 | 75 | public static bool IsCSharpFileName(this string fileName, out bool script) 76 | { 77 | return (script = fileName.EndsWith(".csx", StringComparison.OrdinalIgnoreCase)) || 78 | fileName.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); 79 | } 80 | 81 | public static bool IsRazorFileName(this string fileName) 82 | { 83 | return fileName.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) || 84 | fileName.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase); 85 | } 86 | 87 | public static string JoinToString(this IEnumerable source, string separator) 88 | { 89 | return string.Join(separator, source); 90 | } 91 | 92 | public static string JoinToString(this IEnumerable source, string separator, string quote) 93 | { 94 | return string.Join(separator, source.Select(x => $"{quote}{x}{quote}")); 95 | } 96 | 97 | public static async Task> SelectAsync(this IEnumerable source, Func> selector) 98 | { 99 | var results = new List(source.TryGetNonEnumeratedCount(out var count) ? count : 0); 100 | foreach (var item in source) 101 | { 102 | results.Add(await selector(item)); 103 | } 104 | return results; 105 | } 106 | 107 | public static async Task> SelectAsArrayAsync(this IEnumerable source, Func> selector) 108 | { 109 | var results = ImmutableArray.CreateBuilder(source.TryGetNonEnumeratedCount(out var count) ? count : 0); 110 | foreach (var item in source) 111 | { 112 | results.Add(await selector(item)); 113 | } 114 | return results.DrainToImmutable(); 115 | } 116 | 117 | public static IEnumerable SelectNonNull(this IEnumerable source, Func selector) 118 | { 119 | foreach (var item in source) 120 | { 121 | if (selector(item) is TResult result) 122 | { 123 | yield return result; 124 | } 125 | } 126 | } 127 | 128 | public static async Task> SelectNonNullAsync(this IEnumerable source, Func> selector) 129 | { 130 | var results = new List(source.TryGetNonEnumeratedCount(out var count) ? count : 0); 131 | foreach (var item in source) 132 | { 133 | if (await selector(item) is TResult result) 134 | { 135 | results.Add(result); 136 | } 137 | } 138 | return results; 139 | } 140 | 141 | public static async Task> ToDictionaryAsync( 142 | this IAsyncEnumerable source, 143 | Func keySelector, 144 | Func valueSelector) 145 | where TKey : notnull 146 | { 147 | var dictionary = new Dictionary(); 148 | await foreach (var item in source) 149 | { 150 | dictionary.Add(keySelector(item), valueSelector(item)); 151 | } 152 | return dictionary; 153 | } 154 | 155 | public static async Task> ToImmutableArrayAsync(this IAsyncEnumerable source) 156 | { 157 | var builder = ImmutableArray.CreateBuilder(); 158 | await foreach (var item in source) 159 | { 160 | builder.Add(item); 161 | } 162 | return builder.ToImmutable(); 163 | } 164 | 165 | public static async Task> ToImmutableDictionaryAsync( 166 | this IEnumerable source, 167 | Func keySelector, 168 | Func> valueSelector) 169 | where TKey : notnull 170 | { 171 | var builder = ImmutableDictionary.CreateBuilder(); 172 | 173 | foreach (var item in source) 174 | { 175 | builder.Add(keySelector(item), await valueSelector(item)); 176 | } 177 | 178 | return builder.ToImmutable(); 179 | } 180 | 181 | public static IEnumerable TryConcat(this ImmutableArray? a, ImmutableArray? b) 182 | { 183 | return [.. (a ?? []), .. (b ?? [])]; 184 | } 185 | 186 | public static T? TryAt(this IReadOnlyList list, int index) 187 | { 188 | if (index < 0 || index >= list.Count) 189 | { 190 | return default; 191 | } 192 | 193 | return list[index]; 194 | } 195 | 196 | public static InvalidOperationException Unexpected(T value, [CallerArgumentExpression(nameof(value))] string name = "") 197 | { 198 | return new($"Unexpected {name}='{value}' of type '{value?.GetType().FullName ?? "null"}'."); 199 | } 200 | 201 | public static T Unreachable() 202 | { 203 | throw new InvalidOperationException($"Unreachable '{typeof(T)}'."); 204 | } 205 | 206 | public static string WithoutSuffix(this string s, string suffix) 207 | { 208 | return s.EndsWith(suffix, StringComparison.Ordinal) ? s[..^suffix.Length] : s; 209 | } 210 | } 211 | 212 | public readonly ref struct R 213 | { 214 | public void Dispose() { } 215 | } 216 | -------------------------------------------------------------------------------- /src/Worker/Executor.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco.Editor; 2 | using DotNetLab.Lab; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace DotNetLab; 6 | 7 | public sealed class WorkerExecutor(IServiceProvider services) : WorkerInputMessage.IExecutor 8 | { 9 | public Task HandleAsync(WorkerInputMessage.Ping message) 10 | { 11 | return NoOutput.AsyncInstance; 12 | } 13 | 14 | public async Task HandleAsync(WorkerInputMessage.Compile message) 15 | { 16 | var compiler = services.GetRequiredService(); 17 | return await compiler.CompileAsync(message.Input); 18 | } 19 | 20 | public async Task HandleAsync(WorkerInputMessage.GetOutput message) 21 | { 22 | var compiler = services.GetRequiredService(); 23 | var result = await compiler.CompileAsync(message.Input); 24 | if (message.File is null) 25 | { 26 | return await result.GetRequiredGlobalOutput(message.OutputType).GetTextAsync(outputFactory: null); 27 | } 28 | else 29 | { 30 | return result.Files.TryGetValue(message.File, out var file) 31 | ? await file.GetRequiredOutput(message.OutputType).GetTextAsync(outputFactory: null) 32 | : throw new InvalidOperationException($"File '{message.File}' not found."); 33 | } 34 | } 35 | 36 | public async Task HandleAsync(WorkerInputMessage.UseCompilerVersion message) 37 | { 38 | var compilerDependencyProvider = services.GetRequiredService(); 39 | return await compilerDependencyProvider.UseAsync(message.CompilerKind, message.Version, message.Configuration); 40 | } 41 | 42 | public async Task HandleAsync(WorkerInputMessage.GetCompilerDependencyInfo message) 43 | { 44 | var compilerDependencyProvider = services.GetRequiredService(); 45 | return await compilerDependencyProvider.GetLoadedInfoAsync(message.CompilerKind); 46 | } 47 | 48 | public async Task HandleAsync(WorkerInputMessage.GetSdkInfo message) 49 | { 50 | var sdkDownloader = services.GetRequiredService(); 51 | return await sdkDownloader.GetInfoAsync(message.VersionToLoad); 52 | } 53 | 54 | public Task HandleAsync(WorkerInputMessage.ProvideCompletionItems message) 55 | { 56 | var languageServices = services.GetRequiredService(); 57 | return languageServices.ProvideCompletionItemsAsync(message.ModelUri, message.Position, message.Context); 58 | } 59 | 60 | public Task HandleAsync(WorkerInputMessage.ResolveCompletionItem message) 61 | { 62 | var languageServices = services.GetRequiredService(); 63 | return languageServices.ResolveCompletionItemAsync(message.Item); 64 | } 65 | 66 | public async Task HandleAsync(WorkerInputMessage.OnDidChangeWorkspace message) 67 | { 68 | var languageServices = services.GetRequiredService(); 69 | await languageServices.OnDidChangeWorkspaceAsync(message.Models); 70 | return NoOutput.Instance; 71 | } 72 | 73 | public Task HandleAsync(WorkerInputMessage.OnDidChangeModel message) 74 | { 75 | var languageServices = services.GetRequiredService(); 76 | languageServices.OnDidChangeModel(modelUri: message.ModelUri); 77 | return NoOutput.AsyncInstance; 78 | } 79 | 80 | public async Task HandleAsync(WorkerInputMessage.OnDidChangeModelContent message) 81 | { 82 | var languageServices = services.GetRequiredService(); 83 | await languageServices.OnDidChangeModelContentAsync(message.Args); 84 | return NoOutput.Instance; 85 | } 86 | 87 | public Task> HandleAsync(WorkerInputMessage.GetDiagnostics message) 88 | { 89 | var languageServices = services.GetRequiredService(); 90 | return languageServices.GetDiagnosticsAsync(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Worker/Imports.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices.JavaScript; 2 | 3 | namespace DotNetLab; 4 | 5 | internal sealed partial class Imports 6 | { 7 | private const string ModuleName = "worker-imports.js"; 8 | 9 | [JSImport("registerOnMessage", ModuleName)] 10 | public static partial void RegisterOnMessage([JSMarshalAs>] Action handler); 11 | 12 | [JSImport("postMessage", ModuleName)] 13 | public static partial void PostMessage(string message); 14 | } 15 | -------------------------------------------------------------------------------- /src/Worker/Lab/AssemblyDownloader.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Frozen; 2 | using System.Runtime.InteropServices; 3 | using System.Text.Json; 4 | 5 | namespace DotNetLab.Lab; 6 | 7 | internal sealed class AssemblyDownloader 8 | { 9 | private readonly HttpClient client; 10 | private readonly Func bootConfigProvider; 11 | private readonly Lazy> fingerprintedFileNames; 12 | 13 | public AssemblyDownloader(HttpClient client, Func bootConfigProvider) 14 | { 15 | this.client = client; 16 | this.bootConfigProvider = bootConfigProvider; 17 | fingerprintedFileNames = new(GetFingerprintedFileNames); 18 | } 19 | 20 | private FrozenDictionary GetFingerprintedFileNames() 21 | { 22 | var config = bootConfigProvider(); 23 | 24 | if (config == null) 25 | { 26 | return FrozenDictionary.Empty; 27 | } 28 | 29 | return config.Resources.Assembly.Keys.ToFrozenDictionary(n => config.Resources.Fingerprinting[n], n => n); 30 | } 31 | 32 | public async Task> DownloadAsync(string assemblyFileNameWithoutExtension) 33 | { 34 | var fingerprintedFileNames = this.fingerprintedFileNames.Value; 35 | 36 | var fileName = $"{assemblyFileNameWithoutExtension}.wasm"; 37 | if (fingerprintedFileNames.TryGetValue(fileName, out var fingerprintedFileName)) 38 | { 39 | fileName = fingerprintedFileName; 40 | } 41 | 42 | var bytes = await client.GetByteArrayAsync($"_framework/{fileName}"); 43 | return ImmutableCollectionsMarshal.AsImmutableArray(bytes); 44 | } 45 | } 46 | 47 | internal sealed class DotNetBootConfig 48 | { 49 | public required DotNetBootConfigResources Resources { get; init; } 50 | 51 | public static DotNetBootConfig GetFromRuntime() 52 | { 53 | string json = WorkerInterop.GetDotNetConfig(); 54 | return JsonSerializer.Deserialize(json, LabWorkerJsonContext.Default.DotNetBootConfig)!; 55 | } 56 | } 57 | 58 | internal sealed class DotNetBootConfigResources 59 | { 60 | public required IReadOnlyDictionary Fingerprinting { get; init; } 61 | public required IReadOnlyDictionary Assembly { get; init; } 62 | } 63 | -------------------------------------------------------------------------------- /src/Worker/Lab/CompilerDependencyProvider.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab.Lab; 2 | 3 | /// 4 | /// Provides compiler dependencies into the . 5 | /// 6 | /// 7 | /// Uses plugins. 8 | /// Each plugin can handle one or more s. 9 | /// 10 | internal sealed class CompilerDependencyProvider( 11 | DependencyRegistry dependencyRegistry, 12 | BuiltInCompilerProvider builtInProvider, 13 | IEnumerable resolvers) 14 | { 15 | private readonly Dictionary loaded = new(); 16 | 17 | public async Task GetLoadedInfoAsync(CompilerKind compilerKind) 18 | { 19 | var dependency = loaded.TryGetValue(compilerKind, out var result) 20 | ? result.Loaded 21 | : builtInProvider.GetBuiltInDependency(compilerKind); 22 | return await dependency.Info(); 23 | } 24 | 25 | /// 26 | /// if the same version is already used. 27 | /// 28 | public async Task UseAsync(CompilerKind compilerKind, string? version, BuildConfiguration configuration) 29 | { 30 | if (loaded.TryGetValue(compilerKind, out var dependency)) 31 | { 32 | if (dependency.UserInput.Version == version && 33 | dependency.UserInput.Configuration == configuration) 34 | { 35 | return false; 36 | } 37 | } 38 | else if (version == null && configuration == default) 39 | { 40 | return false; 41 | } 42 | 43 | var info = CompilerInfo.For(compilerKind); 44 | 45 | var task = findOrThrowAsync(); 46 | 47 | // First update the dependency registry so compilation does not start before the search completes. 48 | dependencyRegistry.Set(info, async () => await (await task).Assemblies()); 49 | 50 | await task; 51 | 52 | return true; 53 | 54 | async Task findOrThrowAsync() 55 | { 56 | bool any = false; 57 | List? errors = null; 58 | CompilerDependency? found = await findAsync(); 59 | 60 | if (!any) 61 | { 62 | throw new InvalidOperationException($"Nothing could be parsed out of the specified version '{version}'."); 63 | } 64 | 65 | if (found is null) 66 | { 67 | throw new InvalidOperationException($"Specified version was not found.\n{errors?.JoinToString("\n")}"); 68 | } 69 | 70 | var userInput = new CompilerDependencyUserInput 71 | { 72 | Version = version, 73 | Configuration = configuration, 74 | }; 75 | 76 | loaded[compilerKind] = (userInput, found); 77 | 78 | return found; 79 | 80 | async Task findAsync() 81 | { 82 | foreach (var specifier in CompilerVersionSpecifier.Parse(version)) 83 | { 84 | any = true; 85 | foreach (var plugin in resolvers) 86 | { 87 | try 88 | { 89 | if (await plugin.TryResolveCompilerAsync(info, specifier, configuration) is { } dependency) 90 | { 91 | return dependency; 92 | } 93 | } 94 | catch (Exception ex) 95 | { 96 | errors ??= new(); 97 | errors.Add($"{plugin.GetType().Name}: {ex.Message}"); 98 | } 99 | } 100 | } 101 | 102 | return null; 103 | } 104 | } 105 | } 106 | } 107 | 108 | internal interface ICompilerDependencyResolver 109 | { 110 | /// 111 | /// if the is not supported by this resolver. 112 | /// An exception is thrown if the is supported but the resolution fails. 113 | /// 114 | Task TryResolveCompilerAsync( 115 | CompilerInfo info, 116 | CompilerVersionSpecifier specifier, 117 | BuildConfiguration configuration); 118 | } 119 | 120 | internal sealed class BuiltInCompilerProvider : ICompilerDependencyResolver 121 | { 122 | private readonly ImmutableDictionary builtIn = LoadBuiltIn(); 123 | 124 | private static ImmutableDictionary LoadBuiltIn() 125 | { 126 | return ImmutableDictionary.CreateRange(Enum.GetValues() 127 | .Select(kind => KeyValuePair.Create(kind, createOne(kind)))); 128 | 129 | static CompilerDependency createOne(CompilerKind compilerKind) 130 | { 131 | var specifier = new CompilerVersionSpecifier.BuiltIn(); 132 | var info = CompilerInfo.For(compilerKind); 133 | return new() 134 | { 135 | Info = () => Task.FromResult(new CompilerDependencyInfo(assemblyName: info.AssemblyNames[0]) 136 | { 137 | VersionSpecifier = specifier, 138 | Configuration = BuildConfiguration.Release, 139 | }), 140 | Assemblies = () => Task.FromResult(ImmutableArray.Empty), 141 | }; 142 | } 143 | } 144 | 145 | public CompilerDependency GetBuiltInDependency(CompilerKind compilerKind) 146 | { 147 | return builtIn.TryGetValue(compilerKind, out var result) 148 | ? result 149 | : throw new InvalidOperationException($"Built-in compiler {compilerKind} was not found."); 150 | } 151 | 152 | public Task TryResolveCompilerAsync( 153 | CompilerInfo info, 154 | CompilerVersionSpecifier specifier, 155 | BuildConfiguration configuration) 156 | { 157 | if (specifier is CompilerVersionSpecifier.BuiltIn) 158 | { 159 | return Task.FromResult(GetBuiltInDependency(info.CompilerKind)); 160 | } 161 | 162 | return Task.FromResult(null); 163 | } 164 | } 165 | 166 | internal sealed class CompilerDependencyUserInput 167 | { 168 | public required string? Version { get; init; } 169 | public required BuildConfiguration Configuration { get; init; } 170 | } 171 | 172 | internal sealed class CompilerDependency 173 | { 174 | public required Func> Info { get; init; } 175 | public required Func>> Assemblies { get; init; } 176 | } 177 | -------------------------------------------------------------------------------- /src/Worker/Lab/CompilerProxy.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.Extensions.Options; 4 | using System.Runtime.InteropServices; 5 | using System.Runtime.Loader; 6 | 7 | namespace DotNetLab.Lab; 8 | 9 | internal sealed record CompilerProxyOptions 10 | { 11 | public bool AssembliesAreAlwaysInDllFormat { get; set; } 12 | } 13 | 14 | /// 15 | /// Can load our compiler project with any given Roslyn/Razor compiler version as dependency. 16 | /// 17 | internal sealed class CompilerProxy( 18 | IOptions options, 19 | ILogger logger, 20 | DependencyRegistry dependencyRegistry, 21 | AssemblyDownloader assemblyDownloader, 22 | CompilerLoaderServices loaderServices, 23 | IServiceProvider serviceProvider) 24 | { 25 | public static readonly string CompilerAssemblyName = "DotNetLab.Compiler"; 26 | 27 | private LoadedCompiler? loaded; 28 | private int iteration; 29 | 30 | public async Task CompileAsync(CompilationInput input) 31 | { 32 | try 33 | { 34 | if (loaded is null || dependencyRegistry.Iteration != iteration) 35 | { 36 | var previousIteration = dependencyRegistry.Iteration; 37 | var currentlyLoaded = await LoadCompilerAsync(); 38 | 39 | if (dependencyRegistry.Iteration == previousIteration) 40 | { 41 | loaded = currentlyLoaded; 42 | iteration = dependencyRegistry.Iteration; 43 | } 44 | else 45 | { 46 | Debug.Assert(loaded is not null); 47 | } 48 | } 49 | 50 | if (input.Configuration is not null && loaded.DllAssemblies is null) 51 | { 52 | var assemblies = loaded.Assemblies ?? await LoadAssembliesAsync(); 53 | loaded.DllAssemblies = assemblies.ToImmutableDictionary(p => p.Key, p => p.Value.GetDataAsDll()); 54 | 55 | var builtInAssemblies = await LoadAssembliesAsync(builtIn: true); 56 | loaded.BuiltInDllAssemblies = builtInAssemblies.ToImmutableDictionary(p => p.Key, p => p.Value.GetDataAsDll()); 57 | } 58 | 59 | using var _ = loaded.LoadContext.EnterContextualReflection(); 60 | var result = loaded.Compiler.Compile(input, loaded.DllAssemblies, loaded.BuiltInDllAssemblies, loaded.LoadContext); 61 | 62 | if (loaded.LoadContext is CompilerLoader { LastFailure: { } failure }) 63 | { 64 | loaded = null; 65 | throw new InvalidOperationException( 66 | $"Failed to load '{failure.AssemblyName}'.", failure.Exception); 67 | } 68 | 69 | return result; 70 | } 71 | catch (Exception ex) 72 | { 73 | logger.LogError(ex, "Failed to compile."); 74 | return CompiledAssembly.Fail(ex.ToString()); 75 | } 76 | } 77 | 78 | private async Task> LoadAssembliesAsync(bool builtIn = false) 79 | { 80 | var assemblies = ImmutableDictionary.CreateBuilder(); 81 | 82 | if (!builtIn) 83 | { 84 | await foreach (var dep in dependencyRegistry.GetAssembliesAsync()) 85 | { 86 | if (assemblies.ContainsKey(dep.Name)) 87 | { 88 | logger.LogWarning("Assembly already loaded from another dependency: {Name}", dep.Name); 89 | } 90 | 91 | assemblies[dep.Name] = dep; 92 | } 93 | } 94 | 95 | // All assemblies depending on Roslyn/Razor need to be reloaded 96 | // to avoid type mismatches between assemblies from different contexts. 97 | // If they are not loaded from the registry, we will reload the built-in ones. 98 | // We preload all built-in ones that our Compiler project depends on here 99 | // (we cannot do that inside the AssemblyLoadContext because of async). 100 | IEnumerable names = 101 | [ 102 | CompilerAssemblyName, 103 | ..CompilerInfo.Roslyn.AssemblyNames, 104 | ..CompilerInfo.Razor.AssemblyNames, 105 | "Microsoft.CodeAnalysis.CSharp.Test.Utilities", // RoslynAccess project produces this assembly 106 | "Microsoft.CodeAnalysis.Razor.Test", // RazorAccess project produces this assembly 107 | ]; 108 | foreach (var name in names) 109 | { 110 | if (!assemblies.ContainsKey(name)) 111 | { 112 | var assembly = await LoadAssemblyAsync(name); 113 | assemblies.Add(name, assembly); 114 | } 115 | } 116 | 117 | logger.LogDebug("Available assemblies ({Count}): {Assemblies}", 118 | assemblies.Count, 119 | assemblies.Keys.JoinToString(", ")); 120 | 121 | return assemblies.ToImmutableDictionary(); 122 | } 123 | 124 | private async Task LoadAssemblyAsync(string name) 125 | { 126 | return new() 127 | { 128 | Name = name, 129 | Data = await assemblyDownloader.DownloadAsync(name), 130 | Format = options.Value.AssembliesAreAlwaysInDllFormat ? AssemblyDataFormat.Dll : AssemblyDataFormat.Webcil, 131 | }; 132 | } 133 | 134 | private async Task LoadCompilerAsync() 135 | { 136 | ImmutableDictionary? assemblies = null; 137 | 138 | AssemblyLoadContext alc; 139 | if (dependencyRegistry.IsEmpty) 140 | { 141 | // Load the built-in compiler. 142 | alc = AssemblyLoadContext.Default; 143 | } 144 | else 145 | { 146 | assemblies ??= await LoadAssembliesAsync(); 147 | alc = new CompilerLoader(loaderServices, assemblies, dependencyRegistry.Iteration); 148 | } 149 | 150 | using var _ = alc.EnterContextualReflection(); 151 | Assembly compilerAssembly = alc.LoadFromAssemblyName(new(CompilerAssemblyName)); 152 | Type compilerType = compilerAssembly.GetType(CompilerAssemblyName)!; 153 | var compiler = (ICompiler)ActivatorUtilities.CreateInstance(serviceProvider, compilerType)!; 154 | return new() { LoadContext = alc, Compiler = compiler, Assemblies = assemblies }; 155 | } 156 | 157 | private sealed class LoadedCompiler 158 | { 159 | public required AssemblyLoadContext LoadContext { get; init; } 160 | public required ICompiler Compiler { get; init; } 161 | public required ImmutableDictionary? Assemblies { get; init; } 162 | public ImmutableDictionary>? DllAssemblies { get; set; } 163 | public ImmutableDictionary>? BuiltInDllAssemblies { get; set; } 164 | } 165 | } 166 | 167 | internal readonly record struct AssemblyLoadFailure 168 | { 169 | public required AssemblyName AssemblyName { get; init; } 170 | public required Exception Exception { get; init; } 171 | } 172 | 173 | internal sealed record CompilerLoaderServices( 174 | ILogger Logger); 175 | 176 | internal sealed class CompilerLoader( 177 | CompilerLoaderServices services, 178 | IReadOnlyDictionary knownAssemblies, 179 | int iteration) 180 | : AssemblyLoadContext(nameof(CompilerLoader) + iteration) 181 | { 182 | private readonly Dictionary loadedAssemblies = new(); 183 | 184 | /// 185 | /// In production in WebAssembly, the loader exceptions aren't propagated to the caller. 186 | /// Hence this is used to fail the compilation when assembly loading fails. 187 | /// 188 | public AssemblyLoadFailure? LastFailure { get; set; } 189 | 190 | protected override Assembly? Load(AssemblyName assemblyName) 191 | { 192 | try 193 | { 194 | return LoadCore(assemblyName); 195 | } 196 | catch (Exception ex) 197 | { 198 | services.Logger.LogError(ex, "Failed to load {AssemblyName}.", assemblyName); 199 | LastFailure = new() { AssemblyName = assemblyName, Exception = ex }; 200 | throw; 201 | } 202 | } 203 | 204 | private Assembly? LoadCore(AssemblyName assemblyName) 205 | { 206 | if (assemblyName.Name is { } name) 207 | { 208 | if (loadedAssemblies.TryGetValue(name, out var loaded)) 209 | { 210 | services.Logger.LogDebug("✔️ {AssemblyName}", assemblyName); 211 | 212 | return loaded; 213 | } 214 | 215 | if (knownAssemblies.TryGetValue(name, out var loadedAssembly)) 216 | { 217 | services.Logger.LogDebug("▶️ {AssemblyName}", assemblyName); 218 | 219 | var bytes = ImmutableCollectionsMarshal.AsArray(loadedAssembly.Data)!; 220 | loaded = LoadFromStream(new MemoryStream(bytes)); 221 | loadedAssemblies.Add(name, loaded); 222 | return loaded; 223 | } 224 | 225 | services.Logger.LogDebug("➖ {AssemblyName}", assemblyName); 226 | 227 | loaded = Default.LoadFromAssemblyName(assemblyName); 228 | loadedAssemblies.Add(name, loaded); 229 | return loaded; 230 | } 231 | 232 | return null; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/Worker/Lab/DependencyRegistry.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab.Lab; 2 | 3 | /// 4 | /// Decides which DLLs are loaded (e.g., the built-in Roslyn DLLs 5 | /// or the user-specified version downloaded from NuGet). 6 | /// 7 | /// 8 | /// This class does not do the actual loading. 9 | /// Instead it's consulted by 10 | /// when it needs to load compiler DLLs or referenced DLLs. 11 | /// 12 | internal sealed class DependencyRegistry 13 | { 14 | private readonly Dictionary>>> dependencies = new(); 15 | 16 | /// 17 | /// Can be used to detect changes. 18 | /// 19 | public int Iteration { get; private set; } 20 | 21 | public bool IsEmpty => dependencies.Count == 0; 22 | 23 | public async IAsyncEnumerable GetAssembliesAsync() 24 | { 25 | foreach (var group in dependencies.Values) 26 | { 27 | foreach (var assembly in await group()) 28 | { 29 | yield return assembly; 30 | } 31 | } 32 | } 33 | 34 | public void Set(object key, Func>> group) 35 | { 36 | dependencies[key] = group; 37 | Iteration++; 38 | } 39 | 40 | public void Remove(object key) 41 | { 42 | if (dependencies.Remove(key)) 43 | { 44 | Iteration++; 45 | } 46 | } 47 | } 48 | 49 | internal enum AssemblyDataFormat 50 | { 51 | Dll, 52 | Webcil, 53 | } 54 | 55 | internal sealed class LoadedAssembly 56 | { 57 | /// 58 | /// File name without the extension. 59 | /// 60 | public required string Name { get; init; } 61 | public required ImmutableArray Data { get; init; } 62 | public required AssemblyDataFormat Format { get; init; } 63 | 64 | public ImmutableArray GetDataAsDll() 65 | { 66 | return Format switch 67 | { 68 | AssemblyDataFormat.Dll => Data, 69 | AssemblyDataFormat.Webcil => WebcilUtil.WebcilToDll(Data), 70 | _ => throw new InvalidOperationException($"Unknown assembly format: {Format}"), 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Worker/Lab/LabWorkerJsonContext.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace DotNetLab.Lab; 5 | 6 | [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] 7 | [JsonSerializable(typeof(DotNetBootConfig))] 8 | [JsonSerializable(typeof(ProductCommit))] 9 | internal sealed partial class LabWorkerJsonContext : JsonSerializerContext; 10 | -------------------------------------------------------------------------------- /src/Worker/Lab/NuGetDownloader.cs: -------------------------------------------------------------------------------- 1 | using NuGet.Common; 2 | using NuGet.Packaging; 3 | using NuGet.Protocol; 4 | using NuGet.Protocol.Core.Types; 5 | using NuGet.Versioning; 6 | using System.IO.Compression; 7 | using System.Runtime.InteropServices; 8 | 9 | namespace DotNetLab.Lab; 10 | 11 | public static class NuGetUtil 12 | { 13 | internal static async Task> GetAssembliesFromNupkgAsync(Stream nupkgStream, string folder) 14 | { 15 | const string extension = ".dll"; 16 | using var zipArchive = await ZipArchive.CreateAsync(nupkgStream, ZipArchiveMode.Read, leaveOpen: false, entryNameEncoding: null); 17 | using var reader = new PackageArchiveReader(zipArchive); 18 | return reader.GetFiles() 19 | .Where(file => 20 | { 21 | // Get only DLL files directly in the specified folder 22 | // and starting with `Microsoft.`. 23 | return file.EndsWith(extension, StringComparison.OrdinalIgnoreCase) && 24 | file.StartsWith(folder, StringComparison.OrdinalIgnoreCase) && 25 | file.LastIndexOf('/') is int lastSlashIndex && 26 | lastSlashIndex == folder.Length && 27 | file.AsSpan(lastSlashIndex + 1).StartsWith("Microsoft.", StringComparison.Ordinal); 28 | }) 29 | .Select(file => 30 | { 31 | ZipArchiveEntry entry = reader.GetEntry(file); 32 | using var entryStream = entry.Open(); 33 | var buffer = new byte[entry.Length]; 34 | var memoryStream = new MemoryStream(buffer); 35 | entryStream.CopyTo(memoryStream); 36 | return new LoadedAssembly() 37 | { 38 | Name = entry.Name[..^extension.Length], 39 | Data = ImmutableCollectionsMarshal.AsImmutableArray(buffer), 40 | Format = AssemblyDataFormat.Dll, 41 | }; 42 | }) 43 | .ToImmutableArray(); 44 | } 45 | } 46 | 47 | internal sealed class NuGetDownloaderPlugin( 48 | Lazy nuGetDownloader) 49 | : ICompilerDependencyResolver 50 | { 51 | public Task TryResolveCompilerAsync( 52 | CompilerInfo info, 53 | CompilerVersionSpecifier specifier, 54 | BuildConfiguration configuration) 55 | { 56 | if (specifier is CompilerVersionSpecifier.NuGetLatest or CompilerVersionSpecifier.NuGet) 57 | { 58 | return nuGetDownloader.Value.TryResolveCompilerAsync(info, specifier, configuration); 59 | } 60 | 61 | return Task.FromResult(null); 62 | } 63 | } 64 | 65 | internal sealed class NuGetDownloader : ICompilerDependencyResolver 66 | { 67 | private readonly SourceRepository repository; 68 | private readonly SourceCacheContext cacheContext; 69 | private readonly AsyncLazy findPackageById; 70 | 71 | public NuGetDownloader() 72 | { 73 | ImmutableArray> providers = 74 | [ 75 | new(() => new RegistrationResourceV3Provider()), 76 | new(() => new DependencyInfoResourceV3Provider()), 77 | new(() => new CustomHttpHandlerResourceV3Provider()), 78 | new(() => new HttpSourceResourceProvider()), 79 | new(() => new ServiceIndexResourceV3Provider()), 80 | new(() => new RemoteV3FindPackageByIdResourceProvider()), 81 | ]; 82 | repository = Repository.CreateSource( 83 | providers, 84 | "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json"); 85 | cacheContext = new SourceCacheContext(); 86 | findPackageById = new(() => repository.GetResourceAsync()); 87 | } 88 | 89 | public async Task TryResolveCompilerAsync( 90 | CompilerInfo info, 91 | CompilerVersionSpecifier specifier, 92 | BuildConfiguration configuration) 93 | { 94 | NuGetVersion version; 95 | if (specifier is CompilerVersionSpecifier.NuGetLatest) 96 | { 97 | var versions = await (await findPackageById).GetAllVersionsAsync( 98 | info.PackageId, 99 | cacheContext, 100 | NullLogger.Instance, 101 | CancellationToken.None); 102 | version = versions.FirstOrDefault() ?? 103 | throw new InvalidOperationException($"Package '{info.PackageId}' not found."); 104 | } 105 | else if (specifier is CompilerVersionSpecifier.NuGet nuGetSpecifier) 106 | { 107 | version = nuGetSpecifier.Version; 108 | } 109 | else 110 | { 111 | return null; 112 | } 113 | 114 | var package = new NuGetDownloadablePackage(specifier, info.PackageFolder, async () => 115 | { 116 | var stream = new MemoryStream(); 117 | var success = await (await findPackageById).CopyNupkgToStreamAsync( 118 | info.PackageId, 119 | version, 120 | stream, 121 | cacheContext, 122 | NullLogger.Instance, 123 | CancellationToken.None); 124 | 125 | if (!success) 126 | { 127 | throw new InvalidOperationException( 128 | $"Failed to download '{info.PackageId}' version '{version}'."); 129 | } 130 | 131 | return stream; 132 | }); 133 | 134 | return new() 135 | { 136 | Info = package.GetInfoAsync, 137 | Assemblies = package.GetAssembliesAsync, 138 | }; 139 | } 140 | } 141 | 142 | internal sealed class NuGetDownloadablePackage( 143 | CompilerVersionSpecifier specifier, 144 | string folder, 145 | Func> streamFactory) 146 | { 147 | private readonly AsyncLazy _stream = new(streamFactory); 148 | 149 | private async Task GetStreamAsync() 150 | { 151 | var result = await _stream; 152 | result.Position = 0; 153 | return result; 154 | } 155 | 156 | private async Task GetReaderAsync() 157 | { 158 | return new(await GetStreamAsync(), leaveStreamOpen: true); 159 | } 160 | 161 | public async Task GetInfoAsync() 162 | { 163 | using var reader = await GetReaderAsync(); 164 | var metadata = reader.NuspecReader.GetRepositoryMetadata(); 165 | return new( 166 | version: reader.GetIdentity().Version.ToString(), 167 | commitHash: metadata.Commit, 168 | repoUrl: metadata.Url) 169 | { 170 | VersionSpecifier = specifier, 171 | Configuration = BuildConfiguration.Release, 172 | }; 173 | } 174 | 175 | public async Task> GetAssembliesAsync() 176 | { 177 | return await NuGetUtil.GetAssembliesFromNupkgAsync(await GetStreamAsync(), folder: folder); 178 | } 179 | } 180 | 181 | internal sealed class CustomHttpHandlerResourceV3Provider : ResourceProvider 182 | { 183 | public CustomHttpHandlerResourceV3Provider() 184 | : base(typeof(HttpHandlerResource), nameof(CustomHttpHandlerResourceV3Provider)) 185 | { 186 | } 187 | 188 | public override Task> TryCreate(SourceRepository source, CancellationToken token) 189 | { 190 | return Task.FromResult(TryCreate(source)); 191 | } 192 | 193 | private static Tuple TryCreate(SourceRepository source) 194 | { 195 | if (source.PackageSource.IsHttp) 196 | { 197 | var clientHandler = new CorsClientHandler(); 198 | var messageHandler = new ServerWarningLogHandler(clientHandler); 199 | return new(true, new HttpHandlerResourceV3(clientHandler, messageHandler)); 200 | } 201 | 202 | return new(false, null); 203 | } 204 | } 205 | 206 | internal sealed class CorsClientHandler : HttpClientHandler 207 | { 208 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 209 | { 210 | if (request.RequestUri?.AbsolutePath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase) == true) 211 | { 212 | request.RequestUri = request.RequestUri.WithCorsProxy(); 213 | } 214 | 215 | return base.SendAsync(request, cancellationToken); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Worker/Lab/SdkDownloader.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using System.Xml.Serialization; 3 | 4 | namespace DotNetLab.Lab; 5 | 6 | internal sealed class SdkDownloader( 7 | HttpClient client) 8 | { 9 | private const string sdkRepoOwner = "dotnet"; 10 | private const string sdkRepoName = "sdk"; 11 | private const string sdkRepoUrl = $"https://github.com/{sdkRepoOwner}/{sdkRepoName}"; 12 | private const string roslynRepoUrl = "https://github.com/dotnet/roslyn"; 13 | private const string razorRepoUrl = "https://github.com/dotnet/razor"; 14 | private const string versionDetailsRelativePath = "eng/Version.Details.xml"; 15 | 16 | private static readonly XmlSerializer versionDetailsSerializer = new(typeof(Dependencies)); 17 | 18 | public async Task GetInfoAsync(string version) 19 | { 20 | CommitLink commit = await getCommitAsync(version); 21 | return await getInfoAsync(version, commit); 22 | 23 | async Task getCommitAsync(string version) 24 | { 25 | var url = $"https://dotnetcli.azureedge.net/dotnet/Sdk/{version}/productCommit-win-x64.json"; 26 | using var response = await client.GetAsync(url.WithCorsProxy()); 27 | response.EnsureSuccessStatusCode(); 28 | var result = await response.Content.ReadFromJsonAsync(LabWorkerJsonContext.Default.Options); 29 | return new() { Hash = result?.Sdk.Commit ?? "", RepoUrl = sdkRepoUrl }; 30 | } 31 | 32 | async Task getInfoAsync(string version, CommitLink commit) 33 | { 34 | var url = $"https://api.github.com/repos/{sdkRepoOwner}/{sdkRepoName}/contents/{versionDetailsRelativePath}?ref={commit.Hash}"; 35 | using var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, url) 36 | { 37 | Headers = { { "Accept", "application/vnd.github.raw" } }, 38 | }); 39 | response.EnsureSuccessStatusCode(); 40 | using var stream = await response.Content.ReadAsStreamAsync(); 41 | var dependencies = (Dependencies)versionDetailsSerializer.Deserialize(stream)!; 42 | 43 | var roslynVersion = dependencies.GetVersion(roslynRepoUrl) ?? ""; 44 | var razorVersion = dependencies.GetVersion(razorRepoUrl) ?? ""; 45 | return new() 46 | { 47 | SdkVersion = version, 48 | Commit = commit, 49 | RoslynVersion = roslynVersion, 50 | RazorVersion = razorVersion, 51 | }; 52 | } 53 | } 54 | } 55 | 56 | internal sealed class ProductCommit 57 | { 58 | public required Entry Sdk { get; init; } 59 | 60 | public sealed class Entry 61 | { 62 | public required string Commit { get; init; } 63 | } 64 | } 65 | 66 | // Must be public for XmlSerializer. 67 | public sealed class Dependencies 68 | { 69 | public required List ProductDependencies { get; init; } 70 | 71 | public string? GetVersion(string uri) 72 | { 73 | return ProductDependencies.FirstOrDefault(d => d.Uri == uri)?.Version; 74 | } 75 | 76 | public sealed class Dependency 77 | { 78 | public required string Uri { get; init; } 79 | [XmlAttribute] 80 | public required string Version { get; init; } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Worker/Program.cs: -------------------------------------------------------------------------------- 1 | using DotNetLab; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | using System.Runtime.Versioning; 5 | using System.Text.Json; 6 | 7 | Console.WriteLine("Worker started."); 8 | 9 | if (args.Length != 2) 10 | { 11 | Console.WriteLine($"Expected 2 args, got {args.Length}."); 12 | return; 13 | } 14 | 15 | var services = WorkerServices.Create( 16 | baseUrl: args[0], 17 | logLevel: Enum.Parse(args[1])); 18 | 19 | Imports.RegisterOnMessage(async e => 20 | { 21 | try 22 | { 23 | var data = e.GetPropertyAsString("data") ?? string.Empty; 24 | var incoming = JsonSerializer.Deserialize(data, WorkerJsonContext.Default.WorkerInputMessage); 25 | var executor = services.GetRequiredService(); 26 | PostMessage(await incoming!.HandleAndGetOutputAsync(executor)); 27 | } 28 | catch (Exception ex) 29 | { 30 | PostMessage(new WorkerOutputMessage.Failure(ex) { Id = -1 }); 31 | } 32 | }); 33 | 34 | PostMessage(new WorkerOutputMessage.Ready { Id = -1 }); 35 | 36 | // Keep running. 37 | while (true) 38 | { 39 | await Task.Delay(100); 40 | } 41 | 42 | static void PostMessage(WorkerOutputMessage message) 43 | { 44 | Imports.PostMessage(JsonSerializer.Serialize(message, WorkerJsonContext.Default.WorkerOutputMessage)); 45 | } 46 | 47 | [SupportedOSPlatform("browser")] 48 | partial class Program; 49 | -------------------------------------------------------------------------------- /src/Worker/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("DotNetLab.UnitTests")] 4 | -------------------------------------------------------------------------------- /src/Worker/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "wasmbrowser": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "http://localhost:5117", 9 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Worker/Utils/SimpleConsoleLoggerProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | 3 | namespace DotNetLab; 4 | 5 | /// 6 | /// A simle console logger provider. 7 | /// 8 | /// 9 | /// The default console logger provider creates threads so it's unsupported on Blazor WebAssembly. 10 | /// The Blazor WebAssembly's built-in console logger provider can be obtained from WebAssemblyHostBuilder 11 | /// but fails because of missing JS imports. 12 | /// 13 | internal sealed class SimpleConsoleLoggerProvider : ILoggerProvider 14 | { 15 | public ILogger CreateLogger(string categoryName) => new Logger(); 16 | 17 | public void Dispose() { } 18 | 19 | sealed class Logger : ILogger 20 | { 21 | public IDisposable? BeginScope(TState state) where TState : notnull => null; 22 | 23 | public bool IsEnabled(LogLevel logLevel) => true; 24 | 25 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) 26 | { 27 | Console.WriteLine("Worker: {0}", formatter(state, exception)); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Worker/Utils/WebcilUtil.cs: -------------------------------------------------------------------------------- 1 | using MetadataReferenceService.BlazorWasm; 2 | using Microsoft.NET.WebAssembly.Webcil; 3 | using System.Reflection.PortableExecutable; 4 | using System.Runtime.InteropServices; 5 | 6 | namespace DotNetLab; 7 | 8 | internal static class WebcilUtil 9 | { 10 | private static readonly Func convertFromWebcil = typeof(BlazorWasmMetadataReferenceService).Assembly 11 | .GetType("MetadataReferenceService.BlazorWasm.WasmWebcil.WebcilConverterUtil")! 12 | .GetMethod("ConvertFromWebcil", BindingFlags.Public | BindingFlags.Static)! 13 | .CreateDelegate>(); 14 | 15 | public static ImmutableArray WebcilToDll(ImmutableArray bytes) 16 | { 17 | var inputStream = new MemoryStream(ImmutableCollectionsMarshal.AsArray(bytes)!); 18 | return ImmutableCollectionsMarshal.AsImmutableArray(convertFromWebcil(inputStream, /* wrappedInWebAssembly */ true)); 19 | } 20 | 21 | public static Stream DllToWebcil(FileStream inputStream) 22 | { 23 | var converter = WebcilConverter.FromPortableExecutable("", ""); 24 | 25 | using var reader = new PEReader(inputStream); 26 | converter.GatherInfo(reader, out var wcInfo, out var peInfo); 27 | 28 | var tempStream = new MemoryStream(); 29 | converter.WriteConversionTo(tempStream, inputStream, peInfo, wcInfo); 30 | tempStream.Seek(0, SeekOrigin.Begin); 31 | 32 | var wrapper = new WebcilWasmWrapper(tempStream); 33 | var outputStream = new MemoryStream(); 34 | wrapper.WriteWasmWrappedWebcil(outputStream); 35 | outputStream.Seek(0, SeekOrigin.Begin); 36 | 37 | return outputStream; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Worker/Worker.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | true 5 | Exe 6 | browser-wasm 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Worker/WorkerInterop.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices.JavaScript; 2 | 3 | namespace DotNetLab; 4 | 5 | internal sealed partial class WorkerInterop 6 | { 7 | private const string ModuleName = "worker-interop.js"; 8 | 9 | [JSImport("getDotNetConfig", ModuleName)] 10 | public static partial string GetDotNetConfig(); 11 | } 12 | -------------------------------------------------------------------------------- /src/Worker/WorkerServices.cs: -------------------------------------------------------------------------------- 1 | using DotNetLab.Lab; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace DotNetLab; 6 | 7 | public static class WorkerServices 8 | { 9 | public static IServiceProvider CreateTest( 10 | HttpMessageHandler? httpMessageHandler = null, 11 | Action? configureServices = null) 12 | { 13 | return Create( 14 | baseUrl: "http://localhost", 15 | logLevel: LogLevel.Debug, 16 | httpMessageHandler, 17 | configureServices: services => 18 | { 19 | services.AddScoped>(static _ => static () => null); 20 | services.Configure(static options => 21 | { 22 | options.AssembliesAreAlwaysInDllFormat = true; 23 | }); 24 | configureServices?.Invoke(services); 25 | }); 26 | } 27 | 28 | public static IServiceProvider Create( 29 | string baseUrl, 30 | LogLevel logLevel, 31 | HttpMessageHandler? httpMessageHandler = null, 32 | Action? configureServices = null) 33 | { 34 | var services = new ServiceCollection(); 35 | services.AddLogging(builder => 36 | { 37 | builder.AddFilter("DotNetLab.*", logLevel); 38 | builder.AddProvider(new SimpleConsoleLoggerProvider()); 39 | }); 40 | services.AddScoped(sp => new HttpClient(httpMessageHandler ?? new HttpClientHandler()) { BaseAddress = new Uri(baseUrl) }); 41 | services.AddScoped(); 42 | services.AddScoped(); 43 | services.AddScoped(); 44 | services.AddScoped(); 45 | services.AddScoped>(); 46 | services.AddScoped(); 47 | services.AddScoped(); 48 | services.AddScoped(); 49 | services.AddScoped(); 50 | services.AddScoped(); 51 | services.AddScoped(static sp => sp.GetRequiredService()); 52 | services.AddScoped(); 53 | services.AddScoped(); 54 | services.AddScoped>(static _ => DotNetBootConfig.GetFromRuntime); 55 | configureServices?.Invoke(services); 56 | return services.BuildServiceProvider(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Worker/wwwroot/index.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | .NET Lab Worker 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Worker/wwwroot/interop.js: -------------------------------------------------------------------------------- 1 | export function getDotNetConfig() { 2 | return JSON.stringify(getDotnetRuntime(0).getConfig()); 3 | } 4 | -------------------------------------------------------------------------------- /src/Worker/wwwroot/main.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./dotnet').ModuleAPI} */ 2 | import { dotnet as dn } from '../../_framework/dotnet.js'; 3 | import * as interop from './interop.js'; 4 | 5 | // Extract arguments from URL of the script. 6 | const args = [...new URLSearchParams(self.location.search).entries()].filter(([k, _]) => k === 'arg').map(([_, v]) => v); 7 | 8 | /** @type {import('./dotnet').DotnetHostBuilder} */ 9 | const dotnet = dn.withExitOnUnhandledError(); 10 | 11 | const instance = await dotnet 12 | .withApplicationArguments(...args) 13 | .create(); 14 | 15 | instance.setModuleImports('worker-imports.js', { 16 | registerOnMessage: (handler) => self.addEventListener('message', handler), 17 | postMessage: (message) => self.postMessage(message), 18 | }); 19 | 20 | instance.setModuleImports('worker-interop.js', interop); 21 | 22 | await instance.runMainAndExit('DotNetLab.Worker.wasm'); 23 | -------------------------------------------------------------------------------- /src/WorkerApi/InputMessage.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco; 2 | using BlazorMonaco.Editor; 3 | using BlazorMonaco.Languages; 4 | using DotNetLab.Lab; 5 | using System.Text.Json.Serialization; 6 | 7 | namespace DotNetLab; 8 | 9 | [JsonDerivedType(typeof(Ping), nameof(Ping))] 10 | [JsonDerivedType(typeof(Compile), nameof(Compile))] 11 | [JsonDerivedType(typeof(GetOutput), nameof(GetOutput))] 12 | [JsonDerivedType(typeof(UseCompilerVersion), nameof(UseCompilerVersion))] 13 | [JsonDerivedType(typeof(GetCompilerDependencyInfo), nameof(GetCompilerDependencyInfo))] 14 | [JsonDerivedType(typeof(GetSdkInfo), nameof(GetSdkInfo))] 15 | [JsonDerivedType(typeof(ProvideCompletionItems), nameof(ProvideCompletionItems))] 16 | [JsonDerivedType(typeof(ResolveCompletionItem), nameof(ResolveCompletionItem))] 17 | [JsonDerivedType(typeof(OnDidChangeWorkspace), nameof(OnDidChangeWorkspace))] 18 | [JsonDerivedType(typeof(OnDidChangeModel), nameof(OnDidChangeModel))] 19 | [JsonDerivedType(typeof(OnDidChangeModelContent), nameof(OnDidChangeModelContent))] 20 | [JsonDerivedType(typeof(GetDiagnostics), nameof(GetDiagnostics))] 21 | public abstract record WorkerInputMessage 22 | { 23 | public required int Id { get; init; } 24 | 25 | protected abstract Task HandleNonGenericAsync(IExecutor executor); 26 | 27 | public async Task HandleAndGetOutputAsync(IExecutor executor) 28 | { 29 | try 30 | { 31 | var outgoing = await HandleNonGenericAsync(executor); 32 | if (ReferenceEquals(outgoing, NoOutput.Instance)) 33 | { 34 | return new WorkerOutputMessage.Empty { Id = Id }; 35 | } 36 | else 37 | { 38 | return new WorkerOutputMessage.Success(outgoing) { Id = Id }; 39 | } 40 | } 41 | catch (Exception ex) 42 | { 43 | return new WorkerOutputMessage.Failure(ex) { Id = Id }; 44 | } 45 | } 46 | 47 | public sealed record Ping : WorkerInputMessage 48 | { 49 | public override Task HandleAsync(IExecutor executor) 50 | { 51 | return executor.HandleAsync(this); 52 | } 53 | } 54 | 55 | public sealed record Compile(CompilationInput Input) : WorkerInputMessage 56 | { 57 | public override Task HandleAsync(IExecutor executor) 58 | { 59 | return executor.HandleAsync(this); 60 | } 61 | } 62 | 63 | public sealed record GetOutput(CompilationInput Input, string? File, string OutputType) : WorkerInputMessage 64 | { 65 | public override Task HandleAsync(IExecutor executor) 66 | { 67 | return executor.HandleAsync(this); 68 | } 69 | } 70 | 71 | public sealed record UseCompilerVersion(CompilerKind CompilerKind, string? Version, BuildConfiguration Configuration) : WorkerInputMessage 72 | { 73 | public override Task HandleAsync(IExecutor executor) 74 | { 75 | return executor.HandleAsync(this); 76 | } 77 | } 78 | 79 | public sealed record GetCompilerDependencyInfo(CompilerKind CompilerKind) : WorkerInputMessage 80 | { 81 | public override Task HandleAsync(IExecutor executor) 82 | { 83 | return executor.HandleAsync(this); 84 | } 85 | } 86 | 87 | public sealed record GetSdkInfo(string VersionToLoad) : WorkerInputMessage 88 | { 89 | public override Task HandleAsync(IExecutor executor) 90 | { 91 | return executor.HandleAsync(this); 92 | } 93 | } 94 | 95 | public sealed record ProvideCompletionItems(string ModelUri, Position Position, CompletionContext Context) : WorkerInputMessage 96 | { 97 | public override Task HandleAsync(IExecutor executor) 98 | { 99 | return executor.HandleAsync(this); 100 | } 101 | } 102 | 103 | public sealed record ResolveCompletionItem(MonacoCompletionItem Item) : WorkerInputMessage 104 | { 105 | public override Task HandleAsync(IExecutor executor) 106 | { 107 | return executor.HandleAsync(this); 108 | } 109 | } 110 | 111 | public sealed record OnDidChangeWorkspace(ImmutableArray Models) : WorkerInputMessage 112 | { 113 | public override Task HandleAsync(IExecutor executor) 114 | { 115 | return executor.HandleAsync(this); 116 | } 117 | } 118 | 119 | public sealed record OnDidChangeModel(string ModelUri) : WorkerInputMessage 120 | { 121 | public override Task HandleAsync(IExecutor executor) 122 | { 123 | return executor.HandleAsync(this); 124 | } 125 | } 126 | 127 | public sealed record OnDidChangeModelContent(ModelContentChangedEvent Args) : WorkerInputMessage 128 | { 129 | public override Task HandleAsync(IExecutor executor) 130 | { 131 | return executor.HandleAsync(this); 132 | } 133 | } 134 | 135 | public sealed record GetDiagnostics() : WorkerInputMessage> 136 | { 137 | public override Task> HandleAsync(IExecutor executor) 138 | { 139 | return executor.HandleAsync(this); 140 | } 141 | } 142 | 143 | public interface IExecutor 144 | { 145 | Task HandleAsync(Ping message); 146 | Task HandleAsync(Compile message); 147 | Task HandleAsync(GetOutput message); 148 | Task HandleAsync(UseCompilerVersion message); 149 | Task HandleAsync(GetCompilerDependencyInfo message); 150 | Task HandleAsync(GetSdkInfo message); 151 | Task HandleAsync(ProvideCompletionItems message); 152 | Task HandleAsync(ResolveCompletionItem message); 153 | Task HandleAsync(OnDidChangeWorkspace message); 154 | Task HandleAsync(OnDidChangeModel message); 155 | Task HandleAsync(OnDidChangeModelContent message); 156 | Task> HandleAsync(GetDiagnostics message); 157 | } 158 | 159 | } 160 | 161 | public abstract record WorkerInputMessage : WorkerInputMessage 162 | { 163 | protected sealed override async Task HandleNonGenericAsync(IExecutor executor) 164 | { 165 | return await HandleAsync(executor); 166 | } 167 | 168 | public abstract Task HandleAsync(IExecutor executor); 169 | } 170 | 171 | public sealed record NoOutput 172 | { 173 | private NoOutput() { } 174 | 175 | public static NoOutput Instance { get; } = new(); 176 | public static Task AsyncInstance { get; } = Task.FromResult(Instance); 177 | } 178 | 179 | public sealed record ModelInfo(string Uri, string FileName) 180 | { 181 | public string? NewContent { get; set; } 182 | } 183 | -------------------------------------------------------------------------------- /src/WorkerApi/Lab/CompilerDependency.cs: -------------------------------------------------------------------------------- 1 | using NuGet.Versioning; 2 | using ProtoBuf; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | 6 | namespace DotNetLab.Lab; 7 | 8 | public sealed record CompilerDependencyInfo 9 | { 10 | [JsonConstructor] 11 | public CompilerDependencyInfo(string version, CommitLink commit) 12 | { 13 | Version = version; 14 | Commit = commit; 15 | } 16 | 17 | private CompilerDependencyInfo((string Version, string CommitHash, string RepoUrl) arg) 18 | : this(arg.Version, new() { Hash = arg.CommitHash, RepoUrl = arg.RepoUrl }) 19 | { 20 | } 21 | 22 | public CompilerDependencyInfo(string version, string commitHash, string repoUrl) 23 | : this((Version: version, CommitHash: commitHash, RepoUrl: repoUrl)) 24 | { 25 | } 26 | 27 | public CompilerDependencyInfo(string assemblyName) 28 | : this(FromAssembly(assemblyName)) 29 | { 30 | } 31 | 32 | public required CompilerVersionSpecifier VersionSpecifier { get; init; } 33 | public string Version { get; } 34 | public CommitLink Commit { get; } 35 | public required BuildConfiguration Configuration { get; init; } 36 | public bool CanChangeBuildConfiguration { get; init; } 37 | 38 | private static (string Version, string CommitHash, string RepoUrl) FromAssembly(string assemblyName) 39 | { 40 | string version = ""; 41 | string hash = ""; 42 | string repositoryUrl = ""; 43 | foreach (var attribute in Assembly.Load(assemblyName).CustomAttributes) 44 | { 45 | switch (attribute.AttributeType.FullName) 46 | { 47 | case "System.Reflection.AssemblyInformationalVersionAttribute" 48 | when attribute.ConstructorArguments is [{ Value: string informationalVersion }] && 49 | VersionUtil.TryParseInformationalVersion(informationalVersion, out var parsedVersion, out var parsedHash): 50 | version = parsedVersion; 51 | hash = parsedHash; 52 | break; 53 | 54 | case "System.Reflection.AssemblyMetadataAttribute" 55 | when attribute.ConstructorArguments is [{ Value: "RepositoryUrl" }, { Value: string repoUrl }]: 56 | repositoryUrl = repoUrl; 57 | break; 58 | } 59 | } 60 | 61 | return (Version: version, CommitHash: hash, RepoUrl: repositoryUrl); 62 | } 63 | } 64 | 65 | public sealed record CommitLink 66 | { 67 | public required string RepoUrl { get; init; } 68 | public required string Hash { get; init; } 69 | public string ShortHash => VersionUtil.GetShortCommitHash(Hash); 70 | public string Url => string.IsNullOrEmpty(Hash) ? "" : VersionUtil.GetCommitUrl(RepoUrl, Hash); 71 | } 72 | 73 | public enum CompilerKind 74 | { 75 | Roslyn, 76 | Razor, 77 | } 78 | 79 | [ProtoContract] 80 | public enum BuildConfiguration 81 | { 82 | Release, 83 | Debug, 84 | } 85 | 86 | public sealed record CompilerInfo( 87 | CompilerKind CompilerKind, 88 | string RepositoryUrl, 89 | string PackageId, 90 | string PackageFolder, 91 | int BuildDefinitionId, 92 | string ArtifactNameFormat, 93 | ImmutableArray AssemblyNames, 94 | string? NupkgArtifactPath = null) 95 | { 96 | public static readonly CompilerInfo Roslyn = new( 97 | CompilerKind: CompilerKind.Roslyn, 98 | RepositoryUrl: "https://github.com/dotnet/roslyn", 99 | PackageId: "Microsoft.Net.Compilers.Toolset", 100 | PackageFolder: "tasks/netcore/bincore", 101 | BuildDefinitionId: 95, // roslyn-CI 102 | ArtifactNameFormat: "Transport_Artifacts_Windows_{0}", 103 | AssemblyNames: ["Microsoft.CodeAnalysis.CSharp", "Microsoft.CodeAnalysis"]); 104 | 105 | public static readonly CompilerInfo Razor = new( 106 | CompilerKind: CompilerKind.Razor, 107 | RepositoryUrl: "https://github.com/dotnet/razor", 108 | PackageId: "Microsoft.Net.Compilers.Razor.Toolset", 109 | PackageFolder: "source-generators", 110 | BuildDefinitionId: 103, // razor-tooling-ci 111 | ArtifactNameFormat: "Packages_Windows_NT_{0}", 112 | AssemblyNames: ["Microsoft.CodeAnalysis.Razor.Compiler", .. Roslyn.AssemblyNames], 113 | NupkgArtifactPath: "Shipping"); 114 | 115 | public static CompilerInfo For(CompilerKind kind) 116 | { 117 | return kind switch 118 | { 119 | CompilerKind.Roslyn => Roslyn, 120 | CompilerKind.Razor => Razor, 121 | _ => throw Util.Unexpected(kind), 122 | }; 123 | } 124 | 125 | public string NuGetVersionListUrl => SimpleNuGetUtil.GetPackageVersionListUrl(PackageId); 126 | public string PrListUrl => $"{RepositoryUrl}/pulls"; 127 | public string BuildListUrl => SimpleAzDoUtil.GetBuildListUrl(BuildDefinitionId); 128 | public string BranchListUrl => $"{RepositoryUrl}/branches"; 129 | } 130 | 131 | [JsonDerivedType(typeof(BuiltIn), nameof(BuiltIn))] 132 | [JsonDerivedType(typeof(NuGet), nameof(NuGet))] 133 | [JsonDerivedType(typeof(NuGetLatest), nameof(NuGetLatest))] 134 | [JsonDerivedType(typeof(Build), nameof(Build))] 135 | [JsonDerivedType(typeof(PullRequest), nameof(PullRequest))] 136 | [JsonDerivedType(typeof(Branch), nameof(Branch))] 137 | public abstract record CompilerVersionSpecifier 138 | { 139 | /// 140 | /// Order matters here. Only the first specifier 141 | /// which is successfully resolved by a 142 | /// will be used by the and . 143 | /// 144 | public static IEnumerable Parse(string? specifier) 145 | { 146 | // Null -> use the built-in compiler. 147 | if (string.IsNullOrWhiteSpace(specifier)) 148 | { 149 | yield return new BuiltIn(); 150 | yield break; 151 | } 152 | 153 | if (specifier == "latest") 154 | { 155 | yield return new NuGetLatest(); 156 | yield break; 157 | } 158 | 159 | // Single number -> a PR number or an AzDo build number. 160 | if (int.TryParse(specifier, out int number) && number > 0) 161 | { 162 | yield return new PullRequest(number); 163 | yield return new Build(number); 164 | yield break; 165 | } 166 | 167 | if (NuGetVersion.TryParse(specifier, out var nuGetVersion)) 168 | { 169 | yield return new NuGet(nuGetVersion); 170 | } 171 | 172 | yield return new Branch(specifier); 173 | } 174 | 175 | public sealed record BuiltIn : CompilerVersionSpecifier; 176 | public sealed record NuGet([property: JsonConverter(typeof(NuGetVersionJsonConverter))] NuGetVersion Version) : CompilerVersionSpecifier; 177 | public sealed record NuGetLatest : CompilerVersionSpecifier; 178 | public sealed record Build(int BuildId) : CompilerVersionSpecifier; 179 | public sealed record PullRequest(int PullRequestNumber) : CompilerVersionSpecifier; 180 | public sealed record Branch(string BranchName) : CompilerVersionSpecifier; 181 | } 182 | 183 | internal sealed class NuGetVersionJsonConverter : JsonConverter 184 | { 185 | public override NuGetVersion? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 186 | { 187 | return reader.GetString() is { } s ? NuGetVersion.Parse(s) : null; 188 | } 189 | 190 | public override void Write(Utf8JsonWriter writer, NuGetVersion value, JsonSerializerOptions options) 191 | { 192 | writer.WriteStringValue(value.ToString()); 193 | } 194 | } 195 | 196 | public sealed record SdkInfo 197 | { 198 | public required string SdkVersion { get; init; } 199 | public required CommitLink Commit { get; init; } 200 | public required string RoslynVersion { get; init; } 201 | public required string RazorVersion { get; init; } 202 | } 203 | -------------------------------------------------------------------------------- /src/WorkerApi/OutputMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace DotNetLab; 4 | 5 | [JsonDerivedType(typeof(Ready), nameof(Ready))] 6 | [JsonDerivedType(typeof(Empty), nameof(Empty))] 7 | [JsonDerivedType(typeof(Success), nameof(Success))] 8 | [JsonDerivedType(typeof(Failure), nameof(Failure))] 9 | public abstract record WorkerOutputMessage 10 | { 11 | public const int BroadcastId = -1; 12 | 13 | public required int Id { get; init; } 14 | 15 | public bool IsBroadcast => Id == BroadcastId; 16 | 17 | public sealed record Ready : WorkerOutputMessage; 18 | 19 | public sealed record Empty : WorkerOutputMessage; 20 | 21 | public sealed record Success(object? Result) : WorkerOutputMessage; 22 | 23 | [method: JsonConstructor] 24 | public sealed record Failure(string Message, string FullString) : WorkerOutputMessage 25 | { 26 | public Failure(Exception ex) : this(Message: ex.Message, FullString: ex.ToString()) { } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/WorkerApi/Utils/MonacoEditor.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco; 2 | using BlazorMonaco.Languages; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace DotNetLab; 6 | 7 | /// 8 | /// Used instead of for performance. 9 | /// Because does complex JSON serialization every time 10 | /// just for . 11 | /// 12 | /// 13 | /// VSCode docs: . 14 | /// 15 | public sealed class MonacoCompletionList 16 | { 17 | public required ImmutableArray Suggestions { get; init; } 18 | public BlazorMonaco.Range? Range { get; init; } 19 | public bool IsIncomplete { get; init; } 20 | } 21 | 22 | /// 23 | /// VSCode docs: . 24 | /// 25 | public sealed class MonacoCompletionItem 26 | { 27 | public int Index { get; init; } 28 | public required string Label { get; init; } 29 | public required CompletionItemKind Kind { get; init; } 30 | public string? InsertText { get; init; } 31 | public string? FilterText { get; init; } 32 | public string? SortText { get; init; } 33 | public string? Documentation { get; set; } 34 | public List? AdditionalTextEdits { get; set; } 35 | public string? Detail { get; set; } 36 | public string[]? CommitCharacters { get; set; } 37 | } 38 | 39 | [JsonSerializable(typeof(LanguageSelector))] 40 | [JsonSerializable(typeof(Position))] 41 | [JsonSerializable(typeof(CompletionContext))] 42 | [JsonSerializable(typeof(MonacoCompletionItem))] 43 | [JsonSerializable(typeof(MonacoCompletionList))] 44 | [JsonSourceGenerationOptions( 45 | PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, 46 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] 47 | public sealed partial class BlazorMonacoJsonContext : JsonSerializerContext; 48 | -------------------------------------------------------------------------------- /src/WorkerApi/Utils/NetUtil.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Encodings.Web; 2 | 3 | namespace DotNetLab; 4 | 5 | public static class NetUtil 6 | { 7 | public static Uri WithCorsProxy(this Uri uri) 8 | { 9 | return uri.ToString().WithCorsProxy(); 10 | } 11 | 12 | public static Uri WithCorsProxy(this string uri) 13 | { 14 | return new Uri("https://cloudflare-cors-anywhere.knowpicker.workers.dev/?" + 15 | UrlEncoder.Default.Encode(uri)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/WorkerApi/Utils/SimpleAzDoUtil.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab; 2 | 3 | public static class SimpleAzDoUtil 4 | { 5 | public static readonly string BaseAddress = "https://dev.azure.com/dnceng-public/public"; 6 | 7 | public static string GetBuildListUrl(int definitionId) 8 | { 9 | return $"{BaseAddress}/_build?definitionId={definitionId}"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/WorkerApi/Utils/SimpleMonacoConversions.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco; 2 | using BlazorMonaco.Editor; 3 | 4 | namespace DotNetLab; 5 | 6 | public static class SimpleMonacoConversions 7 | { 8 | public static MarkerData ToMarkerData(this DiagnosticData d) 9 | { 10 | return new MarkerData 11 | { 12 | CodeAsObject = new() 13 | { 14 | Value = d.Id, 15 | TargetUri = d.HelpLinkUri, 16 | }, 17 | Message = d.Message, 18 | StartLineNumber = d.StartLineNumber, 19 | StartColumn = d.StartColumn, 20 | EndLineNumber = d.EndLineNumber, 21 | EndColumn = d.EndColumn, 22 | Severity = d.Severity switch 23 | { 24 | DiagnosticDataSeverity.Error => MarkerSeverity.Error, 25 | DiagnosticDataSeverity.Warning => MarkerSeverity.Warning, 26 | _ => MarkerSeverity.Info, 27 | }, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/WorkerApi/Utils/SimpleNuGetUtil.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab; 2 | 3 | internal static class SimpleNuGetUtil 4 | { 5 | public static string GetPackageVersionListUrl(string packageId) 6 | { 7 | return $"https://dev.azure.com/dnceng/public/_artifacts/feed/dotnet-tools/NuGet/{packageId}/versions"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/WorkerApi/Utils/ThrowingTraceListener.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace DotNetLab; 4 | 5 | /// 6 | /// Ensures s throw normal exceptions instead of crashing the process. 7 | /// 8 | public sealed class ThrowingTraceListener : TraceListener 9 | { 10 | [SuppressMessage("Usage", "CA2255: The 'ModuleInitializer' attribute should not be used in libraries")] 11 | [ModuleInitializer] 12 | internal static void Initialize() 13 | { 14 | Trace.Listeners.Clear(); 15 | Trace.Listeners.Add(new ThrowingTraceListener()); 16 | } 17 | 18 | public override void Fail(string? message, string? detailMessage) 19 | { 20 | var stackTrace = new StackTrace(fNeedFileInfo: true); 21 | var logMessage = (string.IsNullOrEmpty(message) ? "Assertion failed" : message) + 22 | (string.IsNullOrEmpty(detailMessage) ? "" : Environment.NewLine + detailMessage); 23 | 24 | throw new InvalidOperationException(logMessage); 25 | } 26 | 27 | public override void Write(object? o) 28 | { 29 | } 30 | 31 | public override void Write(object? o, string? category) 32 | { 33 | } 34 | 35 | public override void Write(string? message) 36 | { 37 | } 38 | 39 | public override void Write(string? message, string? category) 40 | { 41 | } 42 | 43 | public override void WriteLine(object? o) 44 | { 45 | } 46 | 47 | public override void WriteLine(object? o, string? category) 48 | { 49 | } 50 | 51 | public override void WriteLine(string? message) 52 | { 53 | } 54 | 55 | public override void WriteLine(string? message, string? category) 56 | { 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/WorkerApi/Utils/VersionUtil.cs: -------------------------------------------------------------------------------- 1 | namespace DotNetLab; 2 | 3 | public static class VersionUtil 4 | { 5 | private static readonly Lazy _currentCommitHash = new(GetCurrentCommitHash); 6 | 7 | public static readonly string CurrentRepositoryOwnerAndName = "jjonescz/DotNetLab"; 8 | public static readonly string CurrentRepositoryUrl = $"https://github.com/{CurrentRepositoryOwnerAndName}"; 9 | public static readonly string CurrentRepositoryReleasesUrl = $"{CurrentRepositoryUrl}/releases"; 10 | public static string? CurrentCommitHash => _currentCommitHash.Value; 11 | public static string? CurrentShortCommitHash 12 | => CurrentCommitHash == null ? null : GetShortCommitHash(CurrentCommitHash); 13 | public static string? CurrentCommitUrl 14 | => CurrentCommitHash == null ? null : GetCommitUrl(CurrentRepositoryUrl, CurrentCommitHash); 15 | 16 | public static string GetCommitUrl(string repoUrl, string commitHash) 17 | => $"{repoUrl}/commit/{commitHash}"; 18 | 19 | private static string? GetCurrentCommitHash() 20 | { 21 | var informationalVersion = typeof(VersionUtil).Assembly 22 | .GetCustomAttribute()? 23 | .InformationalVersion; 24 | 25 | if (informationalVersion != null && 26 | TryParseInformationalVersion(informationalVersion, out _, out var commitHash)) 27 | { 28 | return commitHash; 29 | } 30 | 31 | return null; 32 | } 33 | 34 | public static string GetShortCommitHash(string commitHash) => commitHash[..7]; 35 | 36 | public static bool TryParseInformationalVersion( 37 | string informationalVersion, 38 | out string version, 39 | [NotNullWhen(returnValue: true)] out string? commitHash) 40 | { 41 | if (informationalVersion.IndexOf('+') is >= 0 and var plusIndex) 42 | { 43 | version = informationalVersion[..plusIndex]; 44 | commitHash = informationalVersion[(plusIndex + 1)..]; 45 | return true; 46 | } 47 | 48 | version = informationalVersion; 49 | commitHash = null; 50 | return false; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/WorkerApi/WorkerApi.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/WorkerApi/WorkerJsonContext.cs: -------------------------------------------------------------------------------- 1 | using BlazorMonaco.Editor; 2 | using DotNetLab.Lab; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace DotNetLab; 6 | 7 | [JsonSerializable(typeof(WorkerInputMessage))] 8 | [JsonSerializable(typeof(WorkerOutputMessage))] 9 | [JsonSerializable(typeof(CompiledAssembly))] 10 | [JsonSerializable(typeof(CompilerDependencyInfo))] 11 | [JsonSerializable(typeof(SdkInfo))] 12 | [JsonSerializable(typeof(ImmutableArray))] 13 | public sealed partial class WorkerJsonContext : JsonSerializerContext; 14 | -------------------------------------------------------------------------------- /test/UnitTests/CompilerProxyTests.cs: -------------------------------------------------------------------------------- 1 | using DotNetLab.Lab; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace DotNetLab; 5 | 6 | public sealed class CompilerProxyTests(ITestOutputHelper output) 7 | { 8 | [Fact] 9 | public async Task SpecifiedNuGetRoslynVersion() 10 | { 11 | var services = WorkerServices.CreateTest(new MockHttpMessageHandler(output)); 12 | 13 | var version = "4.12.0-2.24409.2"; 14 | var commit = "2158b591"; 15 | 16 | await services.GetRequiredService() 17 | .UseAsync(CompilerKind.Roslyn, version, BuildConfiguration.Release); 18 | 19 | var compiled = await services.GetRequiredService() 20 | .CompileAsync(new(new([new() { FileName = "Input.cs", Text = "#error version" }]))); 21 | 22 | var diagnosticsText = compiled.GetRequiredGlobalOutput(CompiledAssembly.DiagnosticsOutputType).EagerText; 23 | Assert.NotNull(diagnosticsText); 24 | output.WriteLine(diagnosticsText); 25 | Assert.Contains($"{version} ({commit})", diagnosticsText); 26 | } 27 | 28 | [Theory] 29 | [InlineData("4.11.0-3.24352.2", "92051d4c")] 30 | [InlineData("4.10.0-1.24076.1", "e1c36b10")] 31 | [InlineData("5.0.0-1.25252.6", "b6ec1031")] 32 | public async Task SpecifiedNuGetRoslynVersion_WithConfiguration(string version, string commit) 33 | { 34 | var services = WorkerServices.CreateTest(new MockHttpMessageHandler(output)); 35 | 36 | await services.GetRequiredService() 37 | .UseAsync(CompilerKind.Roslyn, version, BuildConfiguration.Release); 38 | 39 | var compiled = await services.GetRequiredService() 40 | .CompileAsync(new(new([new() { FileName = "Input.cs", Text = "#error version" }])) 41 | { 42 | Configuration = """ 43 | Config.CSharpParseOptions(options => options 44 | .WithLanguageVersion(LanguageVersion.CSharp10)); 45 | """, 46 | }); 47 | 48 | var diagnosticsText = compiled.GetRequiredGlobalOutput(CompiledAssembly.DiagnosticsOutputType).EagerText; 49 | Assert.NotNull(diagnosticsText); 50 | output.WriteLine(diagnosticsText); 51 | Assert.Equal($""" 52 | // /Input.cs(1,8): error CS1029: #error: 'version' 53 | // #error version 54 | Diagnostic(ErrorCode.ERR_ErrorDirective, "version").WithArguments("version").WithLocation(1, 8), 55 | // /Input.cs(1,8): error CS8304: Compiler version: '{version} ({commit})'. Language version: 10.0. 56 | // #error version 57 | Diagnostic(ErrorCode.ERR_CompilerAndLanguageVersion, "version").WithArguments("{version} ({commit})", "10.0").WithLocation(1, 8) 58 | """, diagnosticsText); 59 | } 60 | 61 | [Theory] 62 | [InlineData("9.0.0-preview.24413.5")] 63 | [InlineData("9.0.0-preview.25128.1")] 64 | [InlineData("10.0.0-preview.25252.1")] 65 | [InlineData("10.0.0-preview.25264.1")] 66 | [InlineData("main")] // test that we can download a branch 67 | public async Task SpecifiedNuGetRazorVersion(string version) 68 | { 69 | var services = WorkerServices.CreateTest(new MockHttpMessageHandler(output)); 70 | 71 | await services.GetRequiredService() 72 | .UseAsync(CompilerKind.Razor, version, BuildConfiguration.Release); 73 | 74 | var compiled = await services.GetRequiredService() 75 | .CompileAsync(new(new([new() { FileName = "TestComponent.razor", Text = "test" }]))); 76 | 77 | var diagnosticsText = compiled.GetRequiredGlobalOutput(CompiledAssembly.DiagnosticsOutputType).EagerText; 78 | Assert.NotNull(diagnosticsText); 79 | output.WriteLine(diagnosticsText); 80 | Assert.Empty(diagnosticsText); 81 | 82 | var cSharpText = await compiled.GetRequiredGlobalOutput("cs").GetTextAsync(outputFactory: null); 83 | output.WriteLine(cSharpText); 84 | Assert.Contains("class TestComponent", cSharpText); 85 | } 86 | 87 | [Theory] 88 | [InlineData(RazorToolchain.SourceGenerator, RazorStrategy.Runtime)] 89 | [InlineData(RazorToolchain.InternalApi, RazorStrategy.Runtime)] 90 | [InlineData(RazorToolchain.InternalApi, RazorStrategy.DesignTime)] 91 | public async Task SpecifiedRazorOptions(RazorToolchain toolchain, RazorStrategy strategy) 92 | { 93 | var services = WorkerServices.CreateTest(new MockHttpMessageHandler(output)); 94 | 95 | string code = """ 96 |
@Param
97 | 98 | @code { 99 | [Parameter] public int Param { get; set; } = 42; 100 | } 101 | """; 102 | 103 | var compiled = await services.GetRequiredService() 104 | .CompileAsync(new(new([new() { FileName = "TestComponent.razor", Text = code }])) 105 | { 106 | RazorToolchain = toolchain, 107 | RazorStrategy = strategy, 108 | }); 109 | 110 | var diagnosticsText = compiled.GetRequiredGlobalOutput(CompiledAssembly.DiagnosticsOutputType).EagerText; 111 | Assert.NotNull(diagnosticsText); 112 | output.WriteLine(diagnosticsText); 113 | Assert.Empty(diagnosticsText); 114 | 115 | var cSharpText = await compiled.GetRequiredGlobalOutput("cs").GetTextAsync(outputFactory: null); 116 | output.WriteLine(cSharpText); 117 | Assert.Contains("class TestComponent", cSharpText); 118 | 119 | var htmlText = await compiled.Files.Single().Value.GetRequiredOutput("html").GetTextAsync(outputFactory: null); 120 | output.WriteLine(htmlText); 121 | Assert.Equal("
42
", htmlText); 122 | } 123 | } 124 | 125 | internal sealed partial class MockHttpMessageHandler : HttpClientHandler 126 | { 127 | private readonly ITestOutputHelper testOutput; 128 | private readonly string directory; 129 | 130 | public MockHttpMessageHandler(ITestOutputHelper testOutput) 131 | { 132 | this.testOutput = testOutput; 133 | directory = Path.GetDirectoryName(GetType().Assembly.Location)!; 134 | } 135 | 136 | protected override Task SendAsync( 137 | HttpRequestMessage request, 138 | CancellationToken cancellationToken) 139 | { 140 | if (request.RequestUri?.Host != "localhost") 141 | { 142 | testOutput.WriteLine($"Skipping mocking non-localhost request: {request.RequestUri}"); 143 | return base.SendAsync(request, cancellationToken); 144 | } 145 | 146 | testOutput.WriteLine($"Mocking request: {request.RequestUri}"); 147 | 148 | if (UrlRegex.Match(request.RequestUri?.ToString() ?? "") is 149 | { 150 | Success: true, 151 | Groups: [_, { ValueSpan: var fileName }], 152 | }) 153 | { 154 | if (fileName.EndsWith(".wasm", StringComparison.Ordinal)) 155 | { 156 | var assemblyName = fileName[..^5]; 157 | var assemblyPath = Path.Join(directory, assemblyName) + ".dll"; 158 | return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) 159 | { 160 | Content = new StreamContent(File.OpenRead(assemblyPath)), 161 | }); 162 | } 163 | } 164 | 165 | throw new NotImplementedException(request.RequestUri?.ToString()); 166 | } 167 | 168 | [GeneratedRegex("""^https?://localhost/_framework/(.*)$""")] 169 | private static partial Regex UrlRegex { get; } 170 | } 171 | -------------------------------------------------------------------------------- /test/UnitTests/CompressorTests.cs: -------------------------------------------------------------------------------- 1 | using DotNetLab.Lab; 2 | 3 | namespace DotNetLab; 4 | 5 | public sealed class CompressorTests 6 | { 7 | [Fact] 8 | public void RazorInitialCode() 9 | { 10 | var source = """ 11 | 12 | 13 | @code { 14 | [Parameter] public int Param { get; set; } 15 | } 16 | """.ReplaceLineEndings("\r\n"); 17 | var savedState = new SavedState() { Inputs = [new() { FileName = "", Text = source }] }; 18 | var compressed = Compressor.Compress(savedState); 19 | Assert.Equal((89, 114), (source.Length, compressed.Length)); 20 | var uncompressed = Compressor.Uncompress(compressed); 21 | Assert.Equal(savedState.Inputs.Single(), uncompressed.Inputs.Single()); 22 | } 23 | 24 | [Fact] 25 | public void BackwardsCompatibility() 26 | { 27 | // Do not change this string, we need to ensure it's always successfully parsed 28 | // to ensure old URLs can be opened in new versions of the app. 29 | var state = """ 30 | 48rhEg5JLS5xzs8tyM9LzSvRK0qsyi8SCrVBEVUISCxKzLVVMlRS0Lfj4nJIzk9JVajmUgCCaLBUaklqUaxCQWlSTmayQiZMg0K1QnpqibVCMYio5aoFAA 31 | """; 32 | var actual = Compressor.Uncompress(state); 33 | var expected = new SavedState() 34 | { 35 | Inputs = 36 | [ 37 | new() 38 | { 39 | FileName = "TestComponent.razor", 40 | Text = """ 41 | 42 | 43 | @code { 44 | [Parameter] public int Param { get; set; } 45 | } 46 | """.ReplaceLineEndings("\n"), 47 | }, 48 | ], 49 | }; 50 | Assert.Equal(expected.Inputs.Single(), actual.Inputs.Single()); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/UnitTests/InputOutputCacheTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using System.Text.Json; 3 | 4 | namespace DotNetLab; 5 | 6 | public sealed class InputOutputCacheTests 7 | { 8 | [Fact] 9 | public void BackwardsCompatibility() 10 | { 11 | // Do not change this string, we need to ensure it's always successfully parsed 12 | // to ensure old cached outputs can be opened in new versions of the app. 13 | // https://lab.razor.fyi/#47Lm4gooyk8vSszVSy4W0g2uLC5JzdVzzs8rzs9J1QsvyixJ9cnMS9VQck5MzkhNUUitSMwtyEnVU9K05vJiLirNi-LkOLT-_ONVqQIsAA 14 | var response = """ 15 | {"Files":{"Program.cs":{"Outputs":[{"Type":"syntax","Label":"Syntax","Priority":0,"Language":null,"DesignTimeText":null,"EagerText":"CompilationUnit\n\u251C\u2500GlobalStatement\n\u2502 \u2514\u2500ExpressionStatement\n\u2502 \u251C\u2500InvocationExpression\n\u2502 \u2502 \u251C\u2500SimpleMemberAccessExpression\n\u2502 \u2502 \u2502 \u251C\u2500SimpleMemberAccessExpression\n\u2502 \u2502 \u2502 \u2502 \u251C\u2500IdentifierName \u0022System\u0022\n\u2502 \u2502 \u2502 \u2502 \u251C\u2500DotToken \u0022.\u0022\n\u2502 \u2502 \u2502 \u2502 \u2514\u2500IdentifierName \u0022Console\u0022\n\u2502 \u2502 \u2502 \u251C\u2500DotToken \u0022.\u0022\n\u2502 \u2502 \u2502 \u2514\u2500IdentifierName \u0022WriteLine\u0022\n\u2502 \u2502 \u2514\u2500ArgumentList\n\u2502 \u2502 \u251C\u2500OpenParenToken \u0022(\u0022\n\u2502 \u2502 \u251C\u2500Argument\n\u2502 \u2502 \u2502 \u2514\u2500StringLiteralExpression\n\u2502 \u2502 \u2502 \u2514\u2500StringLiteralToken \u0022\u0022Cached example.\u0022\u0022\n\u2502 \u2502 \u2514\u2500CloseParenToken \u0022)\u0022\n\u2502 \u2514\u2500SemicolonToken \u0022;\u0022\n\u2514\u2500EndOfFileToken \u0022\u0022\n"},{"Type":"syntaxTrivia","Label":"Syntax \u002B Trivia","Priority":0,"Language":null,"DesignTimeText":null,"EagerText":"CompilationUnit\n\u251C\u2500GlobalStatement\n\u2502 \u2514\u2500ExpressionStatement\n\u2502 \u251C\u2500InvocationExpression\n\u2502 \u2502 \u251C\u2500SimpleMemberAccessExpression\n\u2502 \u2502 \u2502 \u251C\u2500SimpleMemberAccessExpression\n\u2502 \u2502 \u2502 \u2502 \u251C\u2500IdentifierName\n\u2502 \u2502 \u2502 \u2502 \u2502 \u2514\u2500IdentifierToken \u0022System\u0022\n\u2502 \u2502 \u2502 \u2502 \u251C\u2500DotToken \u0022.\u0022\n\u2502 \u2502 \u2502 \u2502 \u2514\u2500IdentifierName\n\u2502 \u2502 \u2502 \u2502 \u2514\u2500IdentifierToken \u0022Console\u0022\n\u2502 \u2502 \u2502 \u251C\u2500DotToken \u0022.\u0022\n\u2502 \u2502 \u2502 \u2514\u2500IdentifierName\n\u2502 \u2502 \u2502 \u2514\u2500IdentifierToken \u0022WriteLine\u0022\n\u2502 \u2502 \u2514\u2500ArgumentList\n\u2502 \u2502 \u251C\u2500OpenParenToken \u0022(\u0022\n\u2502 \u2502 \u251C\u2500Argument\n\u2502 \u2502 \u2502 \u2514\u2500StringLiteralExpression\n\u2502 \u2502 \u2502 \u2514\u2500StringLiteralToken \u0022\u0022Cached example.\u0022\u0022\n\u2502 \u2502 \u2514\u2500CloseParenToken \u0022)\u0022\n\u2502 \u2514\u2500SemicolonToken \u0022;\u0022\n\u2502 \u2514\u2500TrailingTrivia\n\u2502 \u2514\u2500EndOfLineTrivia \u0022\u23CE\u0022\n\u2514\u2500EndOfFileToken \u0022\u0022\n"},{"Type":"il","Label":"IL","Priority":0,"Language":"csharp","DesignTimeText":null,"EagerText":".class private auto ansi \u0027\u003CModule\u003E\u0027\n{\n} // end of class \u003CModule\u003E\n\n.class private auto ansi beforefieldinit Program\n\textends [System.Runtime]System.Object\n{\n\t.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (\n\t\t01 00 00 00\n\t)\n\t// Methods\n\t.method private hidebysig static \n\t\tvoid \u0027\u003CMain\u003E$\u0027 (\n\t\t\tstring[] args\n\t\t) cil managed \n\t{\n\t\t// Method begins at RVA 0x2050\n\t\t// Header size: 1\n\t\t// Code size: 12 (0xc)\n\t\t.maxstack 8\n\t\t.entrypoint\n\n\t\tIL_0000: ldstr \u0022Cached example.\u0022\n\t\tIL_0005: call void [System.Console]System.Console::WriteLine(string)\n\t\tIL_000a: nop\n\t\tIL_000b: ret\n\t} // end of method Program::\u0027\u003CMain\u003E$\u0027\n\n\t.method public hidebysig specialname rtspecialname \n\t\tinstance void .ctor () cil managed \n\t{\n\t\t// Method begins at RVA 0x205d\n\t\t// Header size: 1\n\t\t// Code size: 8 (0x8)\n\t\t.maxstack 8\n\n\t\tIL_0000: ldarg.0\n\t\tIL_0001: call instance void [System.Runtime]System.Object::.ctor()\n\t\tIL_0006: nop\n\t\tIL_0007: ret\n\t} // end of method Program::.ctor\n\n} // end of class Program\n\n"},{"Type":"seq","Label":"Sequence points","Priority":0,"Language":null,"DesignTimeText":null,"EagerText":"\u003CMain\u003E$(IL_0000-IL_000a 20:3-20:40): Console.WriteLine(\u0022Cached example.\u0022);\n\u003CMain\u003E$(IL_000a-IL_000c 21:2-21:3): }\n"},{"Type":"cs","Label":"C#","Priority":0,"Language":"csharp","DesignTimeText":null,"EagerText":"using System;\nusing System.Diagnostics;\nusing System.Reflection;\nusing System.Runtime.CompilerServices;\nusing System.Security;\nusing System.Security.Permissions;\n\n[assembly: CompilationRelaxations(8)]\n[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]\n[assembly: Debuggable(/*Could not decode attribute arguments.*/)]\n[assembly: SecurityPermission(8, SkipVerification = true)]\n[assembly: AssemblyVersion(\u00220.0.0.0\u0022)]\n[module: UnverifiableCode]\n[module: RefSafetyRules(11)]\n[CompilerGenerated]\ninternal class Program\n{\n\tprivate static void \u003CMain\u003E$(string[] args)\n\t{\n\t\tConsole.WriteLine(\u0022Cached example.\u0022);\n\t}\n}\n"},{"Type":"run","Label":"Run","Priority":1,"Language":null,"DesignTimeText":null,"EagerText":"Exit code: 0\nStdout:\nCached example.\n\nStderr:\n"}]}},"GlobalOutputs":[{"Type":"errors","Label":"Error List","Priority":0,"Language":"csharp","DesignTimeText":null,"EagerText":""}],"NumWarnings":0,"NumErrors":0,"Diagnostics":[],"BaseDirectory":"/TestProject/"} 16 | """; 17 | var output = JsonSerializer.Deserialize(response, WorkerJsonContext.Default.CompiledAssembly); 18 | var serialized = JsonSerializer.Serialize(output, WorkerJsonContext.Default.CompiledAssembly); 19 | 20 | // Back-compat breaking changes: 21 | // 2025-03-15: DesignTimeText has been removed from the output (it is now an input option instead). 22 | // This makes it possible to use Razor SG or internal APIs depending on the run/design-time strategy 23 | // instead of needing to use both SG and internal API at the same time leading to bad perf. 24 | response = response.Replace(""","DesignTimeText":null""", null); 25 | 26 | serialized.Should().Be(response); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/UnitTests/TemplateCacheTests.cs: -------------------------------------------------------------------------------- 1 | using DotNetLab.Lab; 2 | using FluentAssertions; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using System.Text.Json; 5 | 6 | namespace DotNetLab; 7 | 8 | public sealed class TemplateCacheTests 9 | { 10 | private static readonly TemplateCache cache = new(); 11 | 12 | [Fact] 13 | public void NumberOfEntries() 14 | { 15 | Assert.Equal(3, cache.Entries.Length); 16 | } 17 | 18 | [Theory, MemberData(nameof(GetIndices))] 19 | public async Task UpToDate(int index) 20 | { 21 | var (input, actualJsonFactory) = cache.Entries[index]; 22 | 23 | // Compile to get the output corresponding to a template input. 24 | var services = WorkerServices.CreateTest(); 25 | var compiler = services.GetRequiredService(); 26 | var actualOutput = await compiler.CompileAsync(input); 27 | 28 | // Expand all lazy outputs. 29 | var allOutputs = actualOutput.Files.Values.SelectMany(f => f.Outputs) 30 | .Concat(actualOutput.GlobalOutputs); 31 | foreach (var output in allOutputs) 32 | { 33 | string? value; 34 | try 35 | { 36 | value = await output.GetTextAsync(outputFactory: null); 37 | } 38 | catch (Exception ex) 39 | { 40 | value = $"{ex.GetType()}: {ex.Message}"; 41 | output.SetEagerText(value); 42 | } 43 | 44 | Assert.NotNull(value); 45 | Assert.Equal(value, output.EagerText); 46 | } 47 | 48 | Assert.True(cache.TryGetOutput(SavedState.From(input), out _, out var expectedOutput)); 49 | 50 | // Code generation depends on system new line sequence, so continue only on systems where new line is '\n'. 51 | if (Environment.NewLine is not "\n") 52 | { 53 | return; 54 | } 55 | 56 | // Compare JSONs (do this early so when templates are updated, 57 | // this fails and can be used to do manual updates of the JSON snapshots). 58 | var expectedJson = JsonSerializer.Serialize(actualOutput, WorkerJsonContext.Default.CompiledAssembly); 59 | var actualJson = Encoding.UTF8.GetString(actualJsonFactory()); 60 | if (expectedJson != actualJson) 61 | { 62 | var fileName = Path.GetTempFileName(); 63 | File.WriteAllText(fileName, expectedJson); 64 | actualJson.Should().Be(expectedJson, 65 | $"JSON string literal inside {nameof(TemplateCache)} needs to be updated, see '{fileName}'."); 66 | } 67 | 68 | // Compare the objects as well. 69 | actualOutput.Should().BeEquivalentTo(expectedOutput); 70 | } 71 | 72 | public static TheoryData GetIndices() 73 | { 74 | return new(Enumerable.Range(0, cache.Entries.Length)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/UnitTests/UnitTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | true 5 | Exe 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | --------------------------------------------------------------------------------