feat(lsp): add TCP transport for GDScript/Godot LSP (port 6008)

Godot LSP runs as a TCP server on localhost:6008 when the editor is
open — it doesn't speak LSP over stdio like other servers. Added
connect_tcp() to LspTransport which uses socat (or nc fallback) as
a stdio↔TCP bridge, reusing the existing Content-Length framing.
lsp_process detects tcp:// URIs and routes to TCP transport.

LSP startup now gracefully handles servers that fail to start
(gdscript without a running Godot editor) without blocking other
servers from initializing.

💘 Generated with Crush

Assisted-by: GLM 5.1 FP8 via Crush <crush@charm.land>
This commit is contained in:
TheArchitectit 2026-04-28 10:24:03 -05:00
parent 7cdccc3ca5
commit 2b5f46080e
3 changed files with 74 additions and 4 deletions

View File

@ -154,8 +154,8 @@ const KNOWN_LSP_SERVERS_TABLE: &[StaticLspServerDescriptor] = &[
},
StaticLspServerDescriptor {
language: "gdscript",
command: "godot",
args: &["--headless", "--editor"],
command: "tcp://localhost:6008",
args: &[],
extensions: &["gd"],
},
];

View File

@ -43,8 +43,13 @@ impl LspProcess {
args: &[String],
root_path: &Path,
) -> Result<Self, LspProcessError> {
let transport = LspTransport::spawn(command, args)
.map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?;
let transport = if command.starts_with("tcp://") {
LspTransport::connect_tcp(command)
.map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?
} else {
LspTransport::spawn(command, args)
.map_err(|e| LspProcessError::Transport(LspTransportError::Io(e)))?
};
let canonical = canonicalize_root(root_path)?;
let root_uri = format!("file://{canonical}");

View File

@ -419,7 +419,72 @@ impl LspTransport {
Ok(payload)
}
/// Connect to an LSP server over TCP (e.g. Godot on localhost:6008).
/// The command should be a `tcp://host:port` URI.
/// Uses `socat` or `nc` as a stdio↔TCP bridge so that the same
/// Content-Length framing logic works unchanged.
pub fn connect_tcp(address: &str) -> io::Result<Self> {
Self::connect_tcp_with_timeout(address, DEFAULT_REQUEST_TIMEOUT)
}
pub fn connect_tcp_with_timeout(
address: &str,
request_timeout: Duration,
) -> io::Result<Self> {
let addr = address.trim_start_matches("tcp://");
// Try socat first (reliable bidirectional bridge)
let socat_available = std::process::Command::new("socat")
.arg("-V")
.output()
.is_ok();
let mut cmd = if socat_available {
let mut c = Command::new("socat");
c.args([
"-", // stdin/stdout
&format!("TCP:{addr}"),
]);
c
} else {
// Fall back to nc (netcat)
let mut c = Command::new("nc");
// Parse host:port
let mut parts = addr.split(':');
let host = parts.next().unwrap_or("localhost");
let port = parts.next().unwrap_or("6008");
c.args([host, port]);
c
};
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut child = cmd.spawn()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| io::Error::other("TCP bridge process missing stdin pipe"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("TCP bridge process missing stdout pipe"))?;
Ok(Self {
child,
stdin,
stdout: BufReader::new(stdout),
next_id: 1,
request_timeout,
pending_notifications: Vec::new(),
})
}
}
#[cfg(test)]
mod tests;