An LSP for old razor C# projects
  • C# 99%
  • Makefile 1%
Find a file
2026-09-29 18:46:42 -05:00
.forgejo/workflows ci: move workflows to .forgejo/workflows 2026-09-29 14:49:41 -07:00
packaging/mason fix: canonical repo owner casing (TheNoeTrevino) in mason spec + README 2026-07-17 23:19:16 -07:00
server docs(server): editor-agnostic LspServer summary (drop Neovim specifics) 2026-07-17 22:50:22 -07:00
server.tests fix(server): reply with a JSON-RPC error when a handler throws 2026-07-17 16:28:02 -07:00
.editorconfig build: add .editorconfig + C# formatting to make fmt 2026-07-17 15:18:47 -07:00
.gitattributes build: enforce LF checkout via .gitattributes (fix Windows CI format check) 2026-07-17 23:00:06 -07:00
.gitignore build: server-only Makefile + gitignore after split 2026-07-17 22:50:22 -07:00
Makefile build: server-only Makefile + gitignore after split 2026-07-17 22:50:22 -07:00
nuget.config test: xUnit project against the engine skeleton 2026-07-17 13:18:20 -07:00
README.md fix: canonical repo owner casing (TheNoeTrevino) in mason spec + README 2026-07-17 23:19:16 -07:00

legacy-razor-ls

A language server for classic ASP.NET MVC 5 (System.Web) Razor views - hover, completion, document-highlight, and live diagnostics over LSP. Editor-agnostic: any LSP client can drive it. (Neovim: legacy-razor.nvim.)

@model BalanceHistoryViewModel
...
@Model.TodayBalance      -- hover -> "Currency BalanceHistoryViewModel.TodayBalance { get; set; }"
@Model.                  -- completion -> AccountHistoryItems, TodayBalance, FromDate, ...

Why

"Razor" is really two unrelated stacks that share only a file extension:

  • Classic (this): System.Web.Mvc.WebViewPage<T>, compiled by System.Web.Razor v3, configured via Views/web.config - .NET Framework, every MVC 5 app.
  • Core: RazorPage<T>, the Razor SDK, _ViewImports.cshtml.

Every modern Razor tool - the Roslyn language server's co-hosted Razor engine, rzls, VS Code's C# extension - only understands the Core stack. Point one at a classic WebViewPage<T> view and it resolves nothing: not @model, not the base type, not the web.config imports. The one tool that ever handled classic Razor was full Visual Studio, through in-process "contained language" machinery welded into the IDE - no process boundary, nothing an external editor can attach to.

So a classic view in any LSP-based editor gets nothing. This fills that gap, and crucially it does not reimplement Razor or C# analysis - it hosts the real production machinery (System.Web.Razor generates the C#, Roslyn analyzes it) and adds only the glue:

  1. Generate the view's C# with the real System.Web.Razor v3 pipeline (MvcWebPageRazorHost, so @model/@Html/web.config imports all resolve), in design-time mode so every code region is emitted verbatim.
  2. Host it as a Roslyn Document compiled against the app's bin/.
  3. Map positions both ways through Razor's design-time line mappings, which record the exact source and generated columns of each code region - so a cursor in the .cshtml becomes a position in the generated C#, and Roslyn's answers map back to .cshtml.
  4. Serve it over LSP so any editor's client and completion engine consume it.

What it does

Statuses: done = implemented + tested, partial = works but scoped, planned = on the roadmap.

Feature Status Notes
Diagnostics done Live - Roslyn semantics (CS****) + Razor parse errors (RZ1000).
Hover done Roslyn QuickInfo on @Model.X, helpers, locals.
Completion done Members after .; no completionItem/resolve yet.
Document highlight done Occurrences of the symbol under the cursor.
Go-to-definition partial Within-view only; @Model.X resolves to bin metadata until app sources load.
Find references partial Within-view only, same limit.
Signature help planned Needs a manual semantic-model impl; no app sources required.
Rename planned Reuses find-refs; within-view until app sources land.
Code actions planned Roslyn code-fix providers → map edits back to .cshtml.
Semantic tokens planned Accurate semantic coloring of the C# regions.
App C# sources in workspace planned The flagship - cross-file goto-def/refs/rename into ViewModels.
Request cancellation / async planned $/cancelRequest; don't block the read loop.

Build

dotnet build -c Release server/LegacyRazor.Server.csproj

Requires the .NET SDK to build; the output targets net472 and runs on Windows with the preinstalled .NET Framework 4.x. Prebuilt binaries ship on Releases.

Launch contract

The editor integration API is a single process per app root, speaking LSP over stdio:

LegacyRazor.Server <app-root> --lsp

<app-root> is the classic app's root (the directory with Global.asax / Views/web.config). --version prints the package version. The app must have been built (bin/ populated) so views resolve types.

Editor integration

  • Neovim - legacy-razor.nvim (launches the server, routes .cshtml buffers, adds a whole-app compile check).
  • Any LSP client - start the process with the launch contract above and point it at .cshtml buffers under a classic app root.

Architecture

LegacyRazor.Server (net472)  server/
  ├─ RoslynHost   design-time Razor gen +
  │  ViewSession  Roslyn workspace, region map,
  │               hover / complete / highlight / definition / references / diagnostics
  ├─ References   .NET Framework + facades + app bin/
  ├─ WebConfig    Views/web.config imports + base type
  ├─ RazorGen     CodeDOM -> C# text
  ├─ Protocol     the 0-based position DTOs
  └─ LspServer    JSON-RPC framing + handlers  (Program launches it)

Development

make test        # xUnit engine suite (dotnet test)
make fmt         # dotnet format (./.editorconfig)
make fmt-check   # dotnet format --verify-no-changes (CI)

The engine tests are self-contained: they use a framework type as the model (@model System.String) so no fixture assembly needs compiling.

Limitations

  • Go-to-definition / find-references are within-view until the app's .cs sources are loaded into the workspace.
  • Hovering the bare Model keyword (not a member) may be empty; members work.
  • Classic Razor only; ASP.NET Core Razor is already served by rzls/Roslyn.
  • UTF-16 column edge cases in non-ASCII lines are approximated.