[BUG] (lsp): Malformed file:// URI Generation (v2.1.1+) [RFC-8089]

Resolved 💬 11 comments Opened Jan 7, 2026 by justcfx2u Closed May 16, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Product: Claude Code CLI
Affected Versions: 2.1.1, 2.1.0, 2.0.67
Platform: Multiple/all, incl. Windows 11 (native installation and via npm install @anthropic-ai/claude-code@2.1.1)
Component: LSP integration code in @anthropic-ai/claude-code/cli.js (affects at least clangd-lsp)
Severity: High - Prevents clangd LSP functionality
Status: Locally patched and verified 2026-01-07 17:29 EST

Relationship to Previous Versions

The bug persists across versions with different minified symbol names but identical root cause.

Problem Statement

The clangd-lsp plugin generates file URIs that violate RFC 8089 (The "file" URI Scheme) on Windows. This results in malformed URIs being sent to clangd via the Language Server Protocol, causing the error:

LSP request 'textDocument/documentSymbol' failed for server 'plugin:clangd-lsp:clangd': trying to get AST for non-added document

Technical Root Cause

The plugin uses JavaScript template literal string interpolation to construct file URIs instead of Node.js's built-in url.pathToFileURL() function.

Malformed output: file://F:\Projects\path\file.cpp
Correct output: file:///F:/Projects/path/file.cpp

Technical Analysis (v2.1.1)

Minified Symbol Cross-Reference

| Minified Symbol | Real Symbol | Module |
|-----------------|-------------|--------|
| cB7, v37, zG7 | pathToFileURL | url |

Multiple aliases exist for pathToFileURL in v2.1.1. Use whichever is in scope for the code section being patched.

Affected Code Patterns (Minified cli.js)

| Minified Line | Pattern (BROKEN) | Fix |
|---------------|------------------|-----|
| 866 | ` file://${aCB()}/ | cB7(aCB()).href + "/" |
| 2191 |
file://${OQ()} | cB7(OQ()).href |
| 2199 |
file://${A} | cB7(A).href |
| 2312 |
file://${E} | cB7(E).href |
| 2340 |
file://${J} | cB7(J).href |
| 2340 |
file://${K} | cB7(K).href` |

Fix Implementation (v2.1.1)

Direct Minified Patching

No unminification required. Search and replace directly in cli.js:

# Find all occurrences
grep -oE ".{0,30}file://\\\$\{[^}]+\}.{0,30}" cli.js

Replacement Patterns

| Find | Replace |
|------|---------|
| ` file://${aCB()}/ | cB7(aCB()).href + "/" |
|
file://${OQ()} | cB7(OQ()).href |
|
file://${A} | cB7(A).href |
|
file://${E} | cB7(E).href |
|
file://${J} | cB7(J).href |
|
file://${K} | cB7(K).href |
|
GW1(file://${J}) | GW1(cB7(J).href) |
|
GW1(file://${K}) | GW1(cB7(K).href)` |

Verification

After restarting Claude Code:

LSP documentSymbol returned 95 symbols from [redacted].cpp

Installation Path Note (fnm users)

For users with fnm (Fast Node Manager), the shell-specific paths like:

C:\Users\<user>\AppData\Local\fnm_multishells\<pid>_<timestamp>\

Are junctions pointing to:

C:\Users\<user>\AppData\Roaming\fnm\node-versions\v<version>\installation\

Patch the file at the real path, not the junction.

Files Referenced

  • clangd-wrapper.go - Go wrapper for logging LSP messages

clangd-wrapper.go Usage

  1. Close Claude Code
  2. Rename C:\Program Files\LLVM\bin\clangd.exe to C:\Program Files\LLVM\bin\clangd-real.exe
  3. Drop clangd-wrapper.exe as C:\Program Files\LLVM\bin\clangd.exe
  4. Launch Claude Code
  5. Execute LSP on a C++ target
  6. Examine C:\Users\<user>\.claude\debug\clangd-messages.log to confirm actual uris passed to clangd
  7. Ensure they are well formatted

Recommendations for Upstream Fix

  1. Search source code for all instances of ` file://${`` pattern
  2. Replace with pathToFileURL(...).href from Node.js url module
  3. Add Windows CI tests that validate URI format compliance
  4. Consider a shared utility function for file URI generation

References

clangd-wrapper.go Source Code


package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

func main() {
	// Log file
	homeDir, _ := os.UserHomeDir()
	logFile := filepath.Join(homeDir, ".claude", "debug", "clangd-messages.log")

	// Start real clangd
	cmd := exec.Command(`C:\Program Files\LLVM\bin\clangd-real.exe`, os.Args[1:]...)

	stdin, _ := cmd.StdinPipe()
	stdout, _ := cmd.StdoutPipe()
	cmd.Stderr = os.Stderr

	cmd.Start()

	// Forward stdin to clangd, intercepting messages
	go func() {
		buffer := make([]byte, 0)
		readBuf := make([]byte, 4096)

		for {
			n, err := os.Stdin.Read(readBuf)
			if err != nil {
				break
			}

			chunk := readBuf[:n]
			buffer = append(buffer, chunk...)

			// Try to extract complete LSP messages
			for {
				msg, rest := extractLSPMessage(buffer)
				if msg == nil {
					break
				}
				buffer = rest

				// Log interesting messages
				if parsed := parseJSON(msg); parsed != nil {
					if method, ok := parsed["method"].(string); ok {
						if method == "textDocument/didOpen" || method == "textDocument/documentSymbol" {
							logMessage(logFile, "TO_CLANGD", parsed)
						}
					}
				}
			}

			// Forward all data to clangd
			stdin.Write(chunk)
		}
	}()

	// Forward clangd output to stdout
	io.Copy(os.Stdout, stdout)

	cmd.Wait()
}

func extractLSPMessage(data []byte) ([]byte, []byte) {
	// Find header end
	headerEnd := bytes.Index(data, []byte("\r\n\r\n"))
	if headerEnd == -1 {
		return nil, data
	}

	// Parse Content-Length
	header := string(data[:headerEnd])
	var contentLength int
	for _, line := range strings.Split(header, "\r\n") {
		if strings.HasPrefix(line, "Content-Length: ") {
			contentLength, _ = strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
			break
		}
	}

	if contentLength == 0 {
		return nil, data[headerEnd+4:]
	}

	messageEnd := headerEnd + 4 + contentLength
	if len(data) < messageEnd {
		return nil, data
	}

	message := data[headerEnd+4 : messageEnd]
	return message, data[messageEnd:]
}

func parseJSON(data []byte) map[string]interface{} {
	var parsed map[string]interface{}
	if err := json.Unmarshal(data, &parsed); err != nil {
		return nil
	}
	return parsed
}

func logMessage(logFile string, direction string, message map[string]interface{}) {
	f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return
	}
	defer f.Close()

	timestamp := time.Now().Format(time.RFC3339)
	jsonBytes, _ := json.MarshalIndent(message, "", "  ")

	fmt.Fprintf(f, "\n%s [%s]\n%s\n", timestamp, direction, string(jsonBytes))
}

What Should Happen?

Code should use pathToFileURL() consistently to avoid passing corrupt file URIs to clangd.

Error Messages/Logs

LSP request 'textDocument/documentSymbol' failed for server 'plugin:clangd-lsp:clangd': trying to get AST for non-added document

Steps to Reproduce

Attempt to use LSP on a clangd-lsp with a valid (e.g., c++) project.

Claude Model

Opus

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.1 (Claude Code)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Windows Terminal

Additional Information

See go source code in bug report to create a clangd.exe proxy which logs malformed inputs and can confirm fix.

View original on GitHub ↗

This issue has 11 comments on GitHub. Read the full discussion on GitHub ↗