Setting Up Emacs for C# Development on Windows

Introduction

I have been developing C# with .NET 9.0 for the last year on Windows and I thought it was probably time to write down my current setup, and maybe someone might even find this useful!

So, this guide documents my setup for running Emacs 30.1 on Windows with full C# development support, including LSP, debugging (through DAPE), and all the ancillary tools you’d expect from a modern development environment. The setup is designed to be portable and self-contained, which is particularly useful in air-gapped or restricted environments.

A version of this can be found at https://github.com/captainflasmr/Emacs-on-windows which will be a living continually updated version!

Prerequisites

Before we begin, you’ll need:

You can verify your .NET installation by opening a command prompt and running:

dotnet --version

If you see version 9.0.x or later, you’re ready to proceed.

The Big Picture

Here’s what we’re building:

D:\source\emacs-30.1\
├── bin\
│   ├── emacs.exe, runemacs.exe, etc.
│   ├── PortableGit\          # Git for version control
│   ├── Apache-Subversion\    # SVN (if needed)
│   ├── csharp-ls\            # C# Language Server
│   ├── netcoredbg\           # .NET debugger
│   ├── omnisharp-win-x64\    # Alternative C# LSP
│   ├── hunspell\             # Spell checking
│   ├── find\                 # ripgrep for fast searching
│   ├── ffmpeg-7.1.1-.../     # Video processing
│   └── ImageMagick-.../      # Image processing
└── share\
    └── emacs\...

The key insight here is keeping everything within the Emacs installation directory. This makes the whole setup portable—you can copy it to another machine or keep it on a USB drive.

Step 1: Installing Emacs

Download Emacs 30.1 from the GNU Emacs download page. For Windows, grab the installer or the zip archive.

I install to an external drive D:\source\emacs-30.1 rather than Program Files—it avoids permission issues and keeps everything in one place.

Test your installation by running bin\runemacs.exe. You should see a fresh Emacs frame.

Step 2: Setting Up csharp-ls (The C# Language Server)

This is the heart of the C# development experience. csharp-ls provides code completion, go-to-definition, find references, diagnostics, and more through the Language Server Protocol (LSP).

If you have internet access, the easiest way to install csharp-ls is as a .NET global tool:

# Install the latest version globally
dotnet tool install --global csharp-ls

# Or install a specific version
dotnet tool install --global csharp-ls --version 0.20.0

# Verify installation
csharp-ls --version

By default, global tools are installed to:

The executable will be csharp-ls.exe and can be called directly once the tools directory is in your PATH.

Option B: Offline Installation via NuGet Package

For air-gapped environments, you can download the NuGet package and extract it manually:

  1. On a machine with internet, download the package:

          # Download the nupkg file
          nuget install csharp-ls -Version 0.20.0 -OutputDirectory ./packages
    
          # Or download directly from NuGet Gallery:
          # https://www.nuget.org/packages/csharp-ls/
          # Click "Download package" on the right side
    
  2. The .nupkg file is just a ZIP archive. Extract it:

          # Rename to .zip and extract, or use 7-Zip
          # The DLLs are in tools/net9.0/any/
    
  3. Copy the tools/net9.0/any/ directory to your Emacs bin:

          xcopy /E packages\csharp-ls.0.20.0\tools\net9.0\any D:\source\emacs-30.1\bin\csharp-ls\
    
  4. The language server is now at: D:\source\emacs-30.1\bin\csharp-ls\CSharpLanguageServer.dll

Configuring Eglot for csharp-ls

In your init.el, configure Eglot to use csharp-ls:

(require 'eglot)

;; Option A: If installed as a global tool
(setq eglot-server-programs
      '((csharp-mode . ("csharp-ls"))))

;; Option B: If running from extracted DLL
(setq eglot-server-programs
      '((csharp-mode . ("dotnet"
                        "D:/source/emacs-30.1/bin/csharp-ls/CSharpLanguageServer.dll"))))

I also have the following commented out if there are some eglot functions that causes slowdowns or I just think I don’t need:

(setq eglot-ignored-server-capabilities
      '(
        ;; :hoverProvider                    ; Documentation on hover
        ;; :completionProvider               ; Code completion
        ;; :signatureHelpProvider            ; Function signature help
        ;; :definitionProvider               ; Go to definition
        ;; :typeDefinitionProvider           ; Go to type definition
        ;; :implementationProvider           ; Go to implementation
        ;; :declarationProvider              ; Go to declaration
        ;; :referencesProvider               ; Find references
        ;; :documentHighlightProvider        ; Highlight symbols automatically
        ;; :documentSymbolProvider           ; List symbols in buffer
        ;; :workspaceSymbolProvider          ; List symbols in workspace
        ;; :codeActionProvider               ; Execute code actions
        ;; :codeLensProvider                 ; Code lens
        ;; :documentFormattingProvider       ; Format buffer
        ;; :documentRangeFormattingProvider  ; Format portion of buffer
        ;; :documentOnTypeFormattingProvider ; On-type formatting
        ;; :renameProvider                   ; Rename symbol
        ;; :documentLinkProvider             ; Highlight links in document
        ;; :colorProvider                    ; Decorate color references
        ;; :foldingRangeProvider             ; Fold regions of buffer
        ;; :executeCommandProvider           ; Execute custom commands
        ;; :inlayHintProvider                ; Inlay hints
        ))

Step 3: Setting Up the Debugger (netcoredbg)

For debugging .NET applications, we’ll use netcoredbg, which implements the Debug Adapter Protocol (DAP).

Installing netcoredbg

  1. Download from Samsung’s GitHub releases
  2. Extract to D:\source\emacs-30.1\bin\netcoredbg\
  3. Verify: netcoredbg.exe --version

Configuring dape for Debugging

dape is an excellent DAP client for Emacs. Here’s my configuration:

(use-package dape
  :load-path "z:/SharedVM/source/dape-master"
  :init
  ;; Set key prefix BEFORE loading dape
  (setq dape-key-prefix (kbd "C-c d"))
  :config
  ;; Define common configuration
  (defvar project-netcoredbg-path "d:/source/emacs-30.1/bin/netcoredbg/netcoredbg.exe"
    "Path to netcoredbg executable.")
  (defvar project-netcoredbg-log "d:/source/emacs-30.1/bin/netcoredbg/netcoredbg.log"
    "Path to netcoredbg log file.")
  (defvar project-project-root "d:/source/PROJECT"
    "Root directory of PROJECT project.")
  (defvar project-build-config "Debug"
    "Build configuration (Debug or Release).")
  (defvar project-target-arch "x64"
    "Target architecture (x64, x86, or AnyCPU).")

  ;; Helper function to create component configs
  (defun project-dape-config (component-name dll-name &optional stop-at-entry)
    "Create a dape configuration for a component.
COMPONENT-NAME is the component directory name
DLL-NAME is the DLL filename without extension.
STOP-AT-ENTRY if non-nil, stops at program entry point."
    (let* ((component-dir (format "%s/%s" project-project-root component-name))
           (bin-path (format "%s/bin/%s/%s/net9.0"
                             component-dir
                             project-target-arch
                             project-build-config))
           (dll-path (format "%s/%s.dll" bin-path dll-name))
           (config-name (intern (format "netcoredbg-launch-%s"
                                        (downcase component-name)))))
      `(,config-name
        modes (csharp-mode csharp-ts-mode)
        command ,project-netcoredbg-path
        command-args (,(format "--interpreter=vscode")
                      ,(format "--engineLogging=%s" project-netcoredbg-log))
        normalize-path-separator 'windows
        :type "coreclr"
        :request "launch"
        :program ,dll-path
        :cwd ,component-dir
        :console "externalTerminal"
        :internalConsoleOptions "neverOpen"
        :suppressJITOptimizations t
        :requireExactSource nil
        :justMyCode t
        :stopAtEntry ,(if stop-at-entry t :json-false))))

  ;; Register all component configurations
  (dolist (config (list
                   (project-dape-config "DM" "DM.MSS" t)
                   (project-dape-config "Demo" "Demo.MSS" t)
                   (project-dape-config "Test_001" "Test" t)))
    (add-to-list 'dape-configs config))

  ;; Set buffer arrangement and other options
  (setq dape-buffer-window-arrangement 'gud)
  (setq dape-debug t)
  (setq dape-repl-echo-shell-output t))

Now you can start debugging with M-x dape and selecting your configuration.

Step 4: Installing Supporting Tools

Portable Git

  1. Download PortableGit-2.50.0-64-bit.7z.exe from git-scm.com
  2. Run and extract to D:\source\emacs-30.1\bin\PortableGit\

This gives you git.exe, bash.exe, and a whole Unix-like environment.

ripgrep (Fast Searching)

  1. Download from ripgrep releases
  2. Extract rg.exe to D:\source\emacs-30.1\bin\find\

ripgrep is dramatically faster than grep for searching codebases.

Hunspell (Spell Checking)

  1. Download hunspell-1.3.2-3-w32-bin.zip
  2. Extract to D:\source\emacs-30.1\bin\hunspell\
  3. Download dictionary files (en_GB.dic and en_GB.aff) and place in hunspell\share\hunspell\

ImageMagick (Image Processing)

  1. Download the portable Q16 x64 version from imagemagick.org
  2. Extract to D:\source\emacs-30.1\bin\ImageMagick-7.1.2-9-portable-Q16-x64\

This enables image-dired thumbnail generation.

FFmpeg (Video Processing)

  1. Download from ffmpeg.org (essentials build is fine)
  2. Extract to D:\source\emacs-30.1\bin\ffmpeg-7.1.1-essentials_build\

Useful for video thumbnails in dired and media processing.

Step 5: Configuring the PATH

This is crucial—Emacs needs to find all these tools. Here’s the PATH configuration from my init.el:

(when (eq system-type 'windows-nt)
  (let* ((emacs-bin "d:/source/emacs-30.1/bin")
         (xPaths
          `(,emacs-bin
            ,(concat emacs-bin "/PortableGit/bin")
            ,(concat emacs-bin "/PortableGit/usr/bin")
            ,(concat emacs-bin "/hunspell/bin")
            ,(concat emacs-bin "/find")
            ,(concat emacs-bin "/netcoredbg")
            ,(concat emacs-bin "/csharp-ls/tools/net9.0/any")
            ,(concat emacs-bin "/ffmpeg-7.1.1-essentials_build/bin")
            ,(concat emacs-bin "/ImageMagick-7.1.2-9-portable-Q16-x64")))
         (winPaths (getenv "PATH")))
    (setenv "PATH" (concat (mapconcat 'identity xPaths ";") ";" winPaths))
    (setq exec-path (append xPaths (parse-colon-path winPaths)))))

Step 6: Installing Emacs Packages

Extract these to a shared location or download from MELPA

PackagePurpose
corfuModern completion UI
dapeDebug Adapter Protocol client
highlight-indent-guidesVisual indentation guides
ztreeDirectory tree comparison
web-modeWeb template editing

Example package configuration:

(use-package corfu
  :load-path "z:/SharedVM/source/corfu-main"
  :custom
  (corfu-auto nil)         ; Manual completion trigger
  (corfu-cycle t)          ; Cycle through candidates
  (corfu-preselect 'first))

(use-package ztree
  :load-path "z:/SharedVM/source/ztree"
  :config
  (setq ztree-diff-filter-list
        '("build" "\\.dll" "\\.git" "bin" "obj"))
  (global-set-key (kbd "C-c z d") 'ztree-diff))

(use-package web-mode
  :load-path "z:/SharedVM/source/web-mode-master"
  :mode "\\.cshtml?\\'"
  :hook (html-mode . web-mode)
  :bind (:map web-mode-map ("M-;" . nil)))

Note that I turn off autocomplete for corfu and complete using complete-symbol manually, otherwise the LSP is constantly accessed with slowdown.

I often use Meld but am currently am looking to adapt ztree to perform better for directory comparisons.

Web-mode is the best package I have found for html type file navigation and folding, very useful when developing Razor pages for example.

Step 7: auto open file modes

Of course running and building in windows means in Emacs probably having to open .csproj files from time to time, well nxml-mode comes in useful for this:

(add-to-list 'auto-mode-alist '("\\.csproj\\'" . nxml-mode))

Step 8: build script

Here is my general build script, leveraging msbuild and running generally from eshell

New projects are added to :

set PROJECTS
set PROJECT_NAMES
@echo off
setlocal

REM =================================================================
REM Build Management Script
REM =================================================================
REM Usage: build-selected.bat [action] [verbosity] [configuration] [platform]
REM   action: build, clean, restore, rebuild (default: build)
REM   verbosity: quiet, minimal, normal, detailed, diagnostic (default: minimal)
REM   configuration: Debug, Release (default: Debug)
REM   platform: x64, x86, "Any CPU" (default: x64)
REM =================================================================

REM Set defaults
set ACTION=%1
set VERBOSITY=%2
set CONFIGURATION=%3
set PLATFORM=%4

if "%ACTION%"=="" set ACTION=build
if "%VERBOSITY%"=="" set VERBOSITY=minimal
if "%CONFIGURATION%"=="" set CONFIGURATION=Debug
if "%PLATFORM%"=="" set PLATFORM=x64

echo Build Script - Action=%ACTION%, Verbosity=%VERBOSITY%, Config=%CONFIGURATION%, Platform=%PLATFORM%
echo.

REM Common build parameters
set BUILD_PARAMS=/p:Configuration=%CONFIGURATION% /p:Platform="%PLATFORM%" /verbosity:%VERBOSITY%

REM Set MSBuild target based on action
if /I "%ACTION%"=="build" set TARGET=Build
if /I "%ACTION%"=="clean" set TARGET=Clean
if /I "%ACTION%"=="restore" set TARGET=Restore
if /I "%ACTION%"=="rebuild" set TARGET=Rebuild

if "%TARGET%"=="" (
    echo Error: Invalid action '%ACTION%'. Use: build, clean, restore, or rebuild
    exit /b 1
)

echo Executing %ACTION% action...
echo.

set PROJECTS[1]=Demo/Demo.csproj
set PROJECT_NAMES[1]=Demo

set PROJECTS[2]=Test/Test.csproj
set PROJECT_NAMES[2]=Test

set PROJECT_COUNT=2

REM Special handling for rebuild (clean then build)
if /I "%ACTION%"=="rebuild" (
    echo === CLEANING PHASE ===
    for /L %%i in (1,1,%PROJECT_COUNT%) do (
        call :process_project %%i Clean
        if errorlevel 1 goto :error
    )
    echo.
    echo === BUILDING PHASE ===
    set TARGET=Build
)

REM Process all active projects
for /L %%i in (1,1,%PROJECT_COUNT%) do (
    call :process_project %%i %TARGET%
    if errorlevel 1 goto :error
)

echo.
if /I "%ACTION%"=="clean" (
    echo All selected components cleaned successfully!
) else if /I "%ACTION%"=="restore" (
    echo All selected components restored successfully!
) else if /I "%ACTION%"=="rebuild" (
    echo All selected components rebuilt successfully!
) else (
    echo All selected components built successfully!
)
goto :end

:process_project
    setlocal EnableDelayedExpansion
    set idx=%1
    set target=%2

    REM Get project path and name using the index
    for /f "tokens=2 delims==" %%a in ('set PROJECTS[%idx%] 2^>nul') do set PROJECT_PATH=%%a
    for /f "tokens=2 delims==" %%a in ('set PROJECT_NAMES[%idx%] 2^>nul') do set PROJECT_NAME=%%a

    if "!PROJECT_PATH!"=="" goto :eof

    echo ----------------------------------------
    echo [%idx%/%PROJECT_COUNT%] %target%ing !PROJECT_NAME!...

    REM Build the project normally
    msbuild "!PROJECT_PATH!" /t:%target% %BUILD_PARAMS%
    if errorlevel 1 exit /b 1

goto :eof

:error
echo.
echo %ACTION% failed! Check the output above for errors.
exit /b 1

:end
echo %ACTION% completed at %time%

to launch applications of course, if it is a pure DOTNET project you would use dotnet run

Troubleshooting

“Cannot find csharp-ls” or Eglot won’t start

  1. Check the PATH: M-x getenv RET PATH
  2. Verify the DLL exists at the configured location
  3. Try running manually: dotnet path\to\CSharpLanguageServer.dll --version
  4. Check *eglot-events* buffer for detailed error messages

LSP is slow or uses too much memory

Try adding to your configuration:

;; Increase garbage collection threshold during LSP operations
(setq gc-cons-threshold 100000000)  ; 100MB
(setq read-process-output-max (* 1024 1024))  ; 1MB

Debugger won’t attach

  1. Ensure the project is built in Debug configuration
  2. Check the DLL path matches your build output
  3. Look at *dape-repl* for error messages
  4. Verify netcoredbg runs: netcoredbg.exe --version

Conclusion

This setup has served me well for my windows .NET 9.0 projects and various other C# work. The key benefits:

The initial setup takes some effort, but once it’s done, you have a powerful, consistent development environment that travels with you.

Comments

comments powered by Disqus