From e3dae02de7d921c642b8a902bf3a7f2e5414d15b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:31:25 +0800 Subject: [PATCH] feat(languages): Add Godot / GDScript support (#73) Adds GDScript, Godot resource and shader file types with tree-sitter highlighting, the GDScript language server over TCP (a headless Godot editor started on demand), "Godot Project" / current-scene run configurations, and debugging through Godot's debug adapter. Breakpoints set through symlinked paths (/tmp, /var on macOS) were reported verified but never hit; the adapter now sends resolved paths and maps stack frames back to the paths the IDE opened. The duplicate exit event Godot sends after disconnect is ignored. Fixes #68 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- Cargo.lock | 11 + crates/ide-dap/src/adapter.rs | 60 ++++- crates/ide-dap/src/lib.rs | 25 +- crates/ide-dap/src/session.rs | 35 ++- crates/ide-dap/src/tests.rs | 134 ++++++++++ crates/ide-dap/tests/godot.rs | 157 ++++++++++++ crates/ide-jsonrpc/src/lib.rs | 28 +- crates/ide-language/Cargo.toml | 1 + .../queries/gdscript/highlights.scm | 60 +++++ .../ide-language/queries/gdscript/outline.scm | 14 + crates/ide-language/src/lib.rs | 12 + crates/ide-language/src/registry.rs | 38 +++ crates/ide-language/src/syntax/grammars.rs | 10 + crates/ide-language/src/syntax/highlight.rs | 25 ++ crates/ide-language/src/syntax/tests.rs | 18 ++ crates/ide-lsp/Cargo.toml | 2 +- crates/ide-lsp/src/launch.rs | 47 +++- crates/ide-lsp/src/lib.rs | 24 +- crates/ide-lsp/src/server.rs | 12 +- crates/ide-lsp/src/tcp.rs | 188 ++++++++++++++ crates/ide-lsp/tests/mock_server.rs | 1 + crates/ide-lsp/tests/real_servers.rs | 3 + crates/ide-lsp/tests/tcp_server.rs | 241 ++++++++++++++++++ crates/ide-run/src/detect.rs | 19 ++ crates/ide-run/tests/detect.rs | 15 ++ crates/ide-sdk/src/apply.rs | 25 +- crates/ide-sdk/src/godot.rs | 109 ++++++++ crates/ide-sdk/src/lib.rs | 1 + crates/ide-sdk/src/locate.rs | 1 + crates/ide-sdk/src/tests.rs | 31 +++ crates/ide-tools/src/lib.rs | 12 +- languages/gdscript.toml | 26 ++ languages/gdshader.toml | 8 + languages/godot-resource.toml | 8 + src-tauri/src/commands/debug.rs | 2 +- src-tauri/src/commands/run.rs | 9 +- src-tauri/src/commands/tools.rs | 4 +- .../debugger/__tests__/launch.test.ts | 11 + .../debugger/__tests__/remote.test.ts | 8 + src/features/debugger/launch.ts | 19 ++ src/features/debugger/remote.ts | 28 ++ src/features/run/producers.test.ts | 20 ++ src/features/run/producers.ts | 28 ++ src/features/run/runConfigTypes.test.tsx | 20 ++ src/features/run/types.ts | 43 +++- src/ui/icons.tsx | 6 + src/ui/openidea/fileTypes/gdscript.svg | 3 + src/ui/openidea/fileTypes/gdscript_dark.svg | 3 + src/ui/openidea/fileTypes/gdshader.svg | 3 + src/ui/openidea/fileTypes/gdshader_dark.svg | 3 + src/ui/openidea/fileTypes/godot.svg | 3 + src/ui/openidea/fileTypes/godot_dark.svg | 3 + 52 files changed, 1584 insertions(+), 33 deletions(-) create mode 100644 crates/ide-dap/tests/godot.rs create mode 100644 crates/ide-language/queries/gdscript/highlights.scm create mode 100644 crates/ide-language/queries/gdscript/outline.scm create mode 100644 crates/ide-lsp/src/tcp.rs create mode 100644 crates/ide-lsp/tests/tcp_server.rs create mode 100644 crates/ide-sdk/src/godot.rs create mode 100644 languages/gdscript.toml create mode 100644 languages/gdshader.toml create mode 100644 languages/godot-resource.toml create mode 100644 src/ui/openidea/fileTypes/gdscript.svg create mode 100644 src/ui/openidea/fileTypes/gdscript_dark.svg create mode 100644 src/ui/openidea/fileTypes/gdshader.svg create mode 100644 src/ui/openidea/fileTypes/gdshader_dark.svg create mode 100644 src/ui/openidea/fileTypes/godot.svg create mode 100644 src/ui/openidea/fileTypes/godot_dark.svg diff --git a/Cargo.lock b/Cargo.lock index 65ac805cb..7cf8346a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4180,6 +4180,7 @@ dependencies = [ "tree-sitter-css", "tree-sitter-dart", "tree-sitter-elixir", + "tree-sitter-gdscript", "tree-sitter-go", "tree-sitter-graphql", "tree-sitter-haskell", @@ -9295,6 +9296,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-gdscript" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a37fe8c0a10c0c39ecd5b2f7db53933f691488f5572409a4d3c0dfeb3f6108" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-go" version = "0.25.0" diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index aff17aeaa..e7277da7d 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -18,6 +18,8 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}; use tokio::process::{Child, Command}; pub const PORT_PLACEHOLDER: &str = "${port}"; +/// Replaced by the session's working directory (the project) in adapter arguments. +pub const ROOT_PLACEHOLDER: &str = "${root}"; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Transport { @@ -38,6 +40,9 @@ pub struct AdapterSpec { pub env: Vec<(String, String)>, /// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface. pub server_host: Option, + /// `host:port` of an adapter inside another program (the Godot editor): connected to + /// when it accepts, else `command` is started. + pub connect: Option, } /// Built-in adapters used when no language definition names `adapter`. @@ -47,6 +52,7 @@ fn builtin(adapter: &str) -> Option { command: command.to_owned(), args: args.iter().map(|a| (*a).to_owned()).collect(), transport: transport.to_owned(), + connect: None, install_hint: Some(hint.to_owned()), }; Some(match adapter { @@ -119,6 +125,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec { cwd, env: Vec::new(), server_host: None, + connect: config.connect.clone(), } } @@ -127,6 +134,19 @@ pub fn substitute_port(args: &[String], port: u16) -> Vec { args.iter().map(|a| a.replace(PORT_PLACEHOLDER, &port.to_string())).collect() } +/// Replaces every `${root}` occurrence with `root`. +pub fn substitute_root(args: &[String], root: &Path) -> Vec { + let root = root.to_string_lossy(); + args.iter().map(|a| a.replace(ROOT_PLACEHOLDER, &root)).collect() +} + +/// Splits `host:port` (`[v6]:port`). +pub fn parse_address(addr: &str) -> Option<(String, u16)> { + let (host, port) = addr.trim().rsplit_once(':')?; + let host = host.trim_start_matches('[').trim_end_matches(']'); + Some((host.to_owned(), port.parse().ok().filter(|p| *p != 0)?)) +} + /// A port that was free a moment ago on the loopback interface. pub fn free_port() -> std::io::Result { let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; @@ -306,6 +326,10 @@ pub trait AdapterLauncher: Send + Sync + 'static { pub struct ProcessLauncher; const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +/// How long an adapter inside another program (a headless Godot editor) may take to listen. +const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120); +/// Probe of a `connect` address before starting the command. +const PROBE_TIMEOUT: Duration = Duration::from_millis(500); impl AdapterLauncher for ProcessLauncher { fn launch(&self, mut spec: AdapterSpec, reuse_port: Option) -> LaunchFuture { @@ -327,6 +351,28 @@ impl AdapterLauncher for ProcessLauncher { stderr, }); } + let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) { + Some(addr) => Some( + parse_address(addr) + .ok_or_else(|| IdeError::invalid(format!("invalid debug adapter address `{addr}`")))?, + ), + None => None, + }; + // An adapter inside a program the user runs (the Godot editor): use it, never stop it. + if let Some((host, port)) = &embedded + && let Ok(Ok(stream)) = + tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect((host.as_str(), *port))).await + { + let _ = stream.set_nodelay(true); + let (r, w) = stream.into_split(); + return Ok(AdapterIo { + reader: Box::new(r), + writer: Box::new(w), + child: None, + port: Some(*port), + stderr, + }); + } if spec.adapter == DEBUGPY_TOOL { prepare_debugpy(&mut spec).await?; } @@ -337,12 +383,17 @@ impl AdapterLauncher for ProcessLauncher { .flatten() .ok_or_else(|| missing(&spec))?; let port = match spec.transport { - Transport::Tcp => Some(free_port()?), + // An embedded adapter started without `${port}` listens on its usual address. + Transport::Tcp => match &embedded { + Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port), + _ => Some(free_port()?), + }, Transport::Stdio => None, }; + let args = substitute_root(&spec.args, &spec.cwd); let args = match port { - Some(port) => substitute_port(&spec.args, port), - None => spec.args.clone(), + Some(port) => substitute_port(&args, port), + None => args, }; let mut cmd = Command::new(&binary); ide_core::process::no_window(cmd.as_std_mut()); @@ -374,7 +425,8 @@ impl AdapterLauncher for ProcessLauncher { if let Some(out) = child.stdout.take() { pump_lines(out, spec.adapter.clone(), stderr.clone()); } - let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), CONNECT_TIMEOUT).await { + let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT }; + let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await { Ok(s) => s, Err(e) => { let _ = child.start_kill(); diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 20f8c0cbc..247049f25 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -620,6 +620,14 @@ impl DebugManager { if config.adapter == "js-debug" && arguments.get("__workspaceFolder").is_none() { arguments["__workspaceFolder"] = json!(cwd); } + // Godot compares `project` with its own (resolved) project path: `/tmp/game` is + // rejected by an editor that opened `/private/tmp/game`. + if config.adapter == "godot" + && let Some(project) = arguments.get("project").and_then(Value::as_str) + && let Ok(real) = std::fs::canonicalize(project) + { + arguments["project"] = json!(real); + } if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() { arguments["sourceLanguages"] = json!(["rust"]); } @@ -984,8 +992,8 @@ impl DebugManager { } pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult> { - let body = self - .session(s)? + let session = self.session(s)?; + let body = session .conn .request_timeout( "stackTrace", @@ -993,11 +1001,14 @@ impl DebugManager { REQUEST_TIMEOUT, ) .await?; - Ok(body - .get("stackFrames") - .and_then(Value::as_array) - .map(|f| f.iter().map(frame_from).collect()) - .unwrap_or_default()) + let frame = |f: &Value| { + let mut frame = frame_from(f); + if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) { + frame.path = Some(client.into()); + } + frame + }; + Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default()) } pub async fn scopes(&self, s: DebugSessionId, frame_id: i64) -> IdeResult> { diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index 7fefb1e0e..27ac375b2 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -51,9 +51,16 @@ pub(crate) struct Session { pub pending_restore: Mutex, /// Adapter breakpoint id → (file, index in the stored list). pub breakpoint_ids: Mutex>, + /// Resolved path → the path as the IDE knows it, for adapters given resolved paths. + resolved_paths: Mutex>, progress: Mutex>, } +/// Adapters that only match files by their resolved path: Godot maps a script to +/// `res://` by prefix and silently never stops at a breakpoint set through a +/// symlinked path (`/tmp/game` while the editor has `/private/tmp/game`). +const RESOLVES_PATHS: &[&str] = &["godot"]; + impl Session { #[allow(clippy::too_many_arguments)] pub fn new( @@ -86,6 +93,7 @@ impl Session { terminated: AtomicBool::new(false), pending_restore: Mutex::default(), breakpoint_ids: Mutex::new(HashMap::new()), + resolved_paths: Mutex::new(HashMap::new()), progress: Mutex::new(HashMap::new()), } } @@ -94,6 +102,25 @@ impl Session { self.owner.sink.emit(IdeEvent::Debug { session: self.id, event }); } + /// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters). + pub fn adapter_path(&self, path: &Path) -> PathBuf { + if !RESOLVES_PATHS.contains(&self.adapter.as_str()) { + return path.to_path_buf(); + } + match std::fs::canonicalize(path) { + Ok(real) if real != path => { + self.resolved_paths.lock().insert(real.clone(), path.to_path_buf()); + real + } + _ => path.to_path_buf(), + } + } + + /// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]). + pub fn client_path(&self, path: &str) -> Option { + self.resolved_paths.lock().get(Path::new(path)).cloned() + } + pub fn supports(&self, cap: &str) -> bool { self.caps.lock().get(cap).and_then(Value::as_bool).unwrap_or(false) } @@ -135,7 +162,7 @@ impl Session { .request_timeout( "setBreakpoints", json!({ - "source": { "path": path, "name": name }, + "source": { "path": self.adapter_path(path), "name": name }, "breakpoints": source_bps, "sourceModified": false, }), @@ -311,7 +338,11 @@ async fn on_event(mgr: &Arc, s: &Arc, event: &str, body: s.emit(DebugEvent::BreakpointsChanged { path: path.into() }); } } - "exited" => s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 }), + // Godot repeats `exited` / `terminated` when the client disconnects afterwards. + "exited" if !s.terminated.load(Ordering::Acquire) => { + s.emit(DebugEvent::Exited { code: int("exitCode").unwrap_or(0) as i32 }) + } + "exited" => {} "terminated" => { if !s.terminated.swap(true, Ordering::AcqRel) { s.emit(DebugEvent::Terminated); diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index fac94e1b9..7f9987cd1 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -881,3 +881,137 @@ async fn hot_swap_only_for_java_debug() { let failed = hot_swap_result(&json!({"changedClasses": [], "errorMessage": "boom"})); assert_eq!(failed.error_message.as_deref(), Some("boom")); } + +fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc { + let lang = ide_language::LanguageConfig { + id: ide_core::LanguageId::new("gdscript"), + name: "GDScript".into(), + extensions: vec!["gd".into()], + filenames: vec![], + first_line: None, + line_comment: None, + block_comment: None, + indent: None, + ui_mode: None, + tree_sitter: None, + language_servers: vec![], + debugger: Some(ide_language::DebuggerConfig { + adapter: "godot".into(), + command: command.into(), + args: args.iter().map(|a| (*a).to_owned()).collect(), + transport: "tcp".into(), + connect: Some(format!("127.0.0.1:{port}")), + install_hint: Some("Open the project in the Godot editor".into()), + }), + }; + Arc::new(LanguageRegistry::from_configs(vec![lang])) +} + +fn godot_launch(project: &std::path::Path) -> LaunchConfig { + LaunchConfig { + name: "Main Scene".into(), + adapter: "godot".into(), + request: "launch".into(), + program: None, + args: vec![], + cwd: Some(project.to_path_buf().into()), + env: BTreeMap::new(), + extra_json: Some(json!({"project": project, "scene": "main"}).to_string()), + } +} + +/// Godot's debug adapter runs inside the editor (port 6006): an open editor is +/// used as is, nothing is started. +#[tokio::test] +async fn godot_connects_to_the_editor_adapter() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log: Log = Arc::default(); + let server_log = log.clone(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + fake_adapter(stream, server_log, Script::default()).await; + }); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[])); + let project = std::env::temp_dir(); + let id = manager.start(godot_launch(&project), owner).await.unwrap(); + let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap(); + assert_eq!(launch["arguments"]["scene"], "main"); + assert_eq!(launch["arguments"]["project"], json!(std::fs::canonicalize(&project).unwrap())); + assert!(log.lock().iter().any(|m| m["command"] == "configurationDone")); + manager.stop(id).await.unwrap(); +} + +#[tokio::test] +async fn godot_without_editor_or_executable_says_what_to_do() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[])); + let err = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); + assert!(err.message.contains("Open the project in the Godot editor"), "{}", err.message); +} + +/// No editor open: the command (a headless editor) is started for the project and +/// the client connects once it listens; the started process ends with the session. +#[cfg(unix)] +#[tokio::test] +async fn godot_starts_a_headless_editor_when_none_is_open() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let log: Log = Arc::default(); + let server_log = log.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + fake_adapter(stream, server_log, Script::default()).await; + }); + let (owner, _events) = recording_owner("main"); + // `sleep` stands in for `godot --headless --editor --path ${root} …`. + let manager = DebugManager::new(godot_registry(port, "sleep", &["30"])); + let id = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap(); + assert!(log.lock().iter().any(|m| m["command"] == "launch")); + manager.stop(id).await.unwrap(); +} + +/// Godot never stops at a breakpoint given through a symlinked path (it maps scripts +/// to `res://` by its resolved project path): breakpoints and `project` go out resolved. +#[cfg(unix)] +#[tokio::test] +async fn godot_gets_resolved_paths() { + let real = std::env::temp_dir().join(format!("openidea-godot-real-{}", std::process::id())); + let link = std::env::temp_dir().join(format!("openidea-godot-link-{}", std::process::id())); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("player.gd"), "extends Node\n").unwrap(); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let real = std::fs::canonicalize(&real).unwrap(); + + let log: Log = Arc::default(); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::with_launcher( + godot_registry(1, "godot", &[]), + Arc::new(FakeLauncher { log: log.clone(), script: Script::default() }), + ); + let bp = Breakpoint { line: 0, condition: None, hit_condition: None, log_message: None, enabled: true }; + manager.set_breakpoints(link.join("player.gd"), vec![bp]).await.unwrap(); + let id = manager.start(godot_launch(&link), owner).await.unwrap(); + let sent = log.lock().iter().find(|m| m["command"] == "setBreakpoints").cloned().unwrap(); + assert_eq!(sent["arguments"]["source"]["path"], json!(real.join("player.gd"))); + let launch = log.lock().iter().find(|m| m["command"] == "launch").cloned().unwrap(); + assert_eq!(launch["arguments"]["project"], json!(real)); + manager.stop(id).await.unwrap(); + std::fs::remove_file(&link).ok(); + std::fs::remove_dir_all(&real).ok(); +} + +#[test] +fn root_and_address_placeholders() { + let args: Vec = ["--path", "${root}", "--dap-port", "${port}"].iter().map(|s| s.to_string()).collect(); + let args = adapter::substitute_port(&adapter::substitute_root(&args, std::path::Path::new("/g")), 6010); + assert_eq!(args, ["--path", "/g", "--dap-port", "6010"]); + assert_eq!(adapter::parse_address("127.0.0.1:6006"), Some(("127.0.0.1".into(), 6006))); + assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006))); + assert_eq!(adapter::parse_address("6006"), None); +} diff --git a/crates/ide-dap/tests/godot.rs b/crates/ide-dap/tests/godot.rs new file mode 100644 index 000000000..a26f590fa --- /dev/null +++ b/crates/ide-dap/tests/godot.rs @@ -0,0 +1,157 @@ +//! Drives Godot's debug adapter (inside the Godot editor) with the GDScript +//! language definition: a headless editor is started for the project, the main +//! scene is played, a breakpoint hits, stack / variables / stepping work, and the +//! game and the editor end with the session. +//! +//! Ignored by default (needs Godot 4). Run with +//! `GODOT_BIN= cargo test -p ide-dap --test godot -- --ignored --nocapture`. + +use ide_core::event::DebugEvent; +use ide_core::{IdeEvent, SharedSink}; +use ide_dap::*; +use ide_language::{LanguageConfig, LanguageRegistry}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SCRIPT: &str = "extends Node + +var health: int = 3 + +func _ready() -> void: +\tvar total := add(2, 3) +\tprint(\"total = \", total) +\thealth -= 1 +\tget_tree().quit() + +func add(a: int, b: int) -> int: +\treturn a + b +"; +/// 0-based line of `return a + b`. +const BREAK_LINE: u32 = 11; + +fn project(dir: &Path) { + std::fs::write( + dir.join("project.godot"), + "config_version=5\n\n[application]\nconfig/name=\"Probe\"\nrun/main_scene=\"res://main.tscn\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("main.tscn"), + "[gd_scene load_steps=2 format=3]\n\n[ext_resource type=\"Script\" path=\"res://player.gd\" id=\"1\"]\n\n[node name=\"Main\" type=\"Node\"]\nscript = ExtResource(\"1\")\n", + ) + .unwrap(); + std::fs::write(dir.join("player.gd"), SCRIPT).unwrap(); +} + +/// The built-in GDScript definition, with the editor's usual port moved aside so an +/// open editor can't answer instead of the one this test starts. +fn registry() -> Arc { + let builtin = LanguageRegistry::load(None).unwrap(); + let mut gd: LanguageConfig = builtin.get(&ide_core::LanguageId::new("gdscript")).unwrap(); + let dbg = gd.debugger.as_mut().unwrap(); + let unused = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + dbg.connect = Some(format!("127.0.0.1:{unused}")); + Arc::new(LanguageRegistry::from_configs(vec![gd])) +} + +async fn wait_for(events: &Mutex>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent { + let start = Instant::now(); + loop { + if let Some(e) = events.lock().iter().find_map(|e| match e { + IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()), + _ => None, + }) { + return e; + } + assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock()); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn godot_processes(project: &Path) -> usize { + let out = std::process::Command::new("pgrep").args(["-f", &project.to_string_lossy()]).output().unwrap(); + String::from_utf8_lossy(&out.stdout).lines().count() +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn breakpoints_stepping_and_variables_in_a_real_godot() { + let Ok(godot) = std::env::var("GODOT_BIN") else { + eprintln!("GODOT_BIN not set; skipping"); + return; + }; + let dir = std::env::temp_dir().join(format!("openidea-godot-dap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let root = std::fs::canonicalize(&dir).unwrap(); + project(&root); + let manager = DebugManager::new(registry()); + let events: Arc>> = Arc::default(); + let sink: SharedSink = { + let events = events.clone(); + Arc::new(move |e: IdeEvent| events.lock().push(e)) + }; + let script_as_opened = dir.join("player.gd"); + manager + .set_breakpoints( + script_as_opened.clone(), + vec![Breakpoint { + line: BREAK_LINE, + condition: None, + hit_condition: None, + log_message: None, + enabled: true, + }], + ) + .await + .unwrap(); + // As the UI builds it for "Godot Project" (`godot --path .`). + let config = LaunchConfig { + name: "Godot Project".into(), + adapter: "godot".into(), + request: "launch".into(), + program: None, + args: vec![], + // The project as opened, through a symlink on macOS (`/var` → `/private/var`). + cwd: Some(dir.clone().into()), + env: BTreeMap::new(), + extra_json: Some(serde_json::json!({ "project": dir, "scene": "main" }).to_string()), + }; + let id = manager + .start_with(config, SessionOwner::new("main", sink), |_| Some(godot.clone())) + .await + .unwrap_or_else(|e| panic!("start: {e:?}")); + + let stopped = wait_for(&events, Duration::from_secs(120), |e| matches!(e, DebugEvent::Stopped { .. })).await; + let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() }; + let frames = manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!(frames[0].name, "add"); + assert_eq!(frames[0].line, BREAK_LINE); + // Frames name the file as the IDE opened it, not Godot's resolved path. + assert_eq!(frames[0].path.as_deref().map(Path::new), Some(script_as_opened.as_path())); + assert_eq!(frames[1].name, "_ready"); + let scopes = manager.scopes(id, frames[0].id).await.unwrap(); + let locals = scopes.iter().find(|s| s.name == "Locals").expect("Locals scope"); + let vars = manager.variables(id, locals.variables_reference).await.unwrap(); + let value = |n: &str| vars.iter().find(|v| v.name == n).map(|v| v.value.clone()); + assert_eq!((value("a").as_deref(), value("b").as_deref()), (Some("2"), Some("3")), "{vars:?}"); + + events.lock().clear(); + manager.step(id, thread_id, StepKind::Over).await.unwrap(); + wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await; + let frames = manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!((frames[0].name.as_str(), frames[0].line), ("_ready", 6), "stepped back into `_ready`, at the print"); + manager.resume(id, thread_id).await.unwrap(); + // (The game's last output is not always forwarded when it quits right away.) + wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Terminated)).await; + let _ = manager.stop(id).await; + // The headless editor started for the session ends with it, the game too. + let start = Instant::now(); + while godot_processes(&root) > 0 { + assert!(start.elapsed() < Duration::from_secs(10), "Godot still running for {}", root.display()); + tokio::time::sleep(Duration::from_millis(200)).await; + } + std::fs::remove_dir_all(&root).ok(); +} diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 13cdee9f0..137ea9272 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -344,7 +344,13 @@ impl Peer { }); } - let inner = peer.inner.clone(); + peer.supervise(child); + Ok(peer) + } + + /// Kills `child` when the connection is torn down; closes the connection when it exits. + fn supervise(&self, mut child: tokio::process::Child) { + let inner = self.inner.clone(); tokio::spawn(async move { let status = tokio::select! { status = child.wait() => status.ok(), @@ -355,7 +361,6 @@ impl Peer { }; inner.mark_closed(status.and_then(|s| s.code())); }); - Ok(peer) } /// Speaks JSON-RPC over arbitrary byte streams (in-memory pipes in tests, TCP sockets). @@ -379,6 +384,25 @@ impl Peer { Self::start(reader, writer, handler, true, None, framing) } + /// [`Peer::connect`] over streams served by `child` (a server reached over a socket + /// it listens on): the child is killed with the connection, and its exit closes it. + /// + /// Must be called from within a Tokio runtime. + pub fn connect_with_child( + reader: R, + writer: W, + handler: Arc, + child: tokio::process::Child, + ) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength); + peer.supervise(child); + peer + } + fn start( reader: R, mut writer: W, diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index 91bf1beb9..39207b942 100644 --- a/crates/ide-language/Cargo.toml +++ b/crates/ide-language/Cargo.toml @@ -53,6 +53,7 @@ tree-sitter-xml = "0.7.0" tree-sitter-haskell = "0.23.1" tree-sitter-dart = "0.2.0" tree-sitter-graphql = "0.3.0" +tree-sitter-gdscript = "6.1.0" [dev-dependencies] criterion = { version = "0.5", default-features = false } diff --git a/crates/ide-language/queries/gdscript/highlights.scm b/crates/ide-language/queries/gdscript/highlights.scm new file mode 100644 index 000000000..0177e5211 --- /dev/null +++ b/crates/ide-language/queries/gdscript/highlights.scm @@ -0,0 +1,60 @@ +; GDScript highlighting (the grammar crate ships no query). Later patterns win. + +(identifier) @variable +(name) @variable + +(comment) @comment +[(region_start) (region_end)] @comment +(string) @string +(escape_sequence) @string.escape +[(string_name) (node_path) (get_node)] @string.special +(integer) @number +(float) @number +[(true) (false) (null)] @constant.builtin + +(type) @type +(type (identifier) @type) +(extends_statement (type) @type) +(class_name_statement name: (name) @type) +(class_definition name: (name) @type) +(enum_definition name: (name) @type) +(enumerator left: (identifier) @constant) + +(function_definition name: (name) @function.definition) +(constructor_definition "_init" @function.definition) +(lambda name: (name) @function.definition) +(signal_statement name: (name) @function.definition) +(call (identifier) @function) +(attribute_call (identifier) @function) +(base_call (identifier) @function) + +(attribute (identifier) (identifier) @variable.member) + +((identifier) @constant + (#lua-match? @constant "^[A-Z][A-Z_0-9]*$")) +((identifier) @variable.builtin + (#lua-match? @variable.builtin "^self$")) +(const_statement name: (name) @constant) + +(typed_parameter (identifier) @variable.parameter) +(default_parameter (identifier) @variable.parameter) +(typed_default_parameter (identifier) @variable.parameter) + +(annotation "@" @attribute (identifier) @attribute) +[(remote_keyword) (static_keyword)] @keyword +[(breakpoint_statement) (pass_statement) (break_statement) (continue_statement)] @keyword + +[ + "if" "elif" "else" "for" "while" "match" "when" "return" "await" + "func" "class" "class_name" "extends" "signal" "enum" "const" "var" + "set" "get" "setget" "onready" "export" "in" "is" "as" "and" "or" "not" +] @keyword + +[ + "+" "-" "*" "/" "%" "**" "<<" ">>" "&" "|" "^" "~" "!" "&&" "||" + "==" "!=" "<" "<=" ">" ">=" "=" ":=" "+=" "-=" "*=" "/=" "%=" "**=" + "<<=" ">>=" "&=" "|=" "^=" "->" +] @operator + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +["," "." ":" ";"] @punctuation.delimiter diff --git a/crates/ide-language/queries/gdscript/outline.scm b/crates/ide-language/queries/gdscript/outline.scm new file mode 100644 index 000000000..e3b471b86 --- /dev/null +++ b/crates/ide-language/queries/gdscript/outline.scm @@ -0,0 +1,14 @@ +(class_name_statement name: (name) @name) @def.class +(class_definition name: (name) @name) @def.class +(function_definition name: (name) @name parameters: (parameters) @detail) @def.function +(constructor_definition "_init" @name parameters: (parameters) @detail) @def.constructor +(signal_statement name: (name) @name) @def.event +(enum_definition name: (name) @name) @def.enum +(enumerator left: (identifier) @name) @def.enum_member +(const_statement name: (name) @name) @def.constant +(source (variable_statement name: (name) @name) @def.field) +(source (export_variable_statement name: (name) @name) @def.field) +(source (onready_variable_statement name: (name) @name) @def.field) +(class_body (variable_statement name: (name) @name) @def.field) +(class_body (export_variable_statement name: (name) @name) @def.field) +(class_body (onready_variable_statement name: (name) @name) @def.field) diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index ef4762976..6af20c66a 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -75,6 +75,14 @@ pub struct LanguageServerConfig { /// Human hint shown when the binary is missing. #[serde(default)] pub install_hint: Option, + /// `host:port` of a server that speaks LSP over TCP (Godot's editor). Connects + /// there when something listens; otherwise starts `command` (when installed) + /// and connects once it accepts: to `127.0.0.1:${port}` when the arguments + /// contain `${port}` (a free port), else to this address. `${root}` in the + /// arguments is the workspace root. A server reached without starting it is + /// never shut down or killed by the IDE. + #[serde(default)] + pub connect: Option, /// An alternative to the servers listed before it (pyright after /// basedpyright): only used while none of those is installed. #[serde(default)] @@ -96,6 +104,10 @@ pub struct DebuggerConfig { /// `stdio` or `tcp`. #[serde(default = "default_transport")] pub transport: String, + /// `tcp` only: `host:port` of an adapter running inside another program (the + /// Godot editor). Used when it accepts connections; otherwise `command` is started. + #[serde(default)] + pub connect: Option, #[serde(default)] pub install_hint: Option, } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 29cd289cb..f3103be8d 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -22,9 +22,12 @@ const BUILTIN: &[(&str, &str)] = &[ ("erlang", include_str!("../../../languages/erlang.toml")), ("fsharp", include_str!("../../../languages/fsharp.toml")), ("gitattributes", include_str!("../../../languages/gitattributes.toml")), + ("gdscript", include_str!("../../../languages/gdscript.toml")), + ("gdshader", include_str!("../../../languages/gdshader.toml")), ("go", include_str!("../../../languages/go.toml")), ("gomod", include_str!("../../../languages/gomod.toml")), ("gowork", include_str!("../../../languages/gowork.toml")), + ("godot-resource", include_str!("../../../languages/godot-resource.toml")), ("graphql", include_str!("../../../languages/graphql.toml")), ("groovy", include_str!("../../../languages/groovy.toml")), ("haskell", include_str!("../../../languages/haskell.toml")), @@ -542,10 +545,17 @@ mod tests { } for ls in &cfg.language_servers { assert!(!ls.command.is_empty() && ls.install_hint.is_some(), "{name}: {} incomplete", ls.name); + if let Some(addr) = &ls.connect { + assert!(addr.parse::().is_ok(), "{name}: bad connect address {addr}"); + } } if let Some(d) = &cfg.debugger { assert!(matches!(d.transport.as_str(), "stdio" | "tcp"), "{name}: bad transport"); assert!(d.install_hint.is_some(), "{name}: debugger without install hint"); + if let Some(addr) = &d.connect { + assert_eq!(d.transport, "tcp", "{name}: `connect` needs the tcp transport"); + assert!(addr.parse::().is_ok(), "{name}: bad connect address {addr}"); + } } for ext in &cfg.extensions { assert_eq!(ext, &ext.to_ascii_lowercase(), "{name}: extensions must be lower case"); @@ -679,6 +689,13 @@ mod tests { (".gitignore", "ignore"), (".gitattributes", "gitattributes"), ("server.log", "log"), + ("player.gd", "gdscript"), + ("main.tscn", "godot-resource"), + ("theme.tres", "godot-resource"), + ("project.godot", "godot-resource"), + ("icon.svg.import", "godot-resource"), + ("export_presets.cfg", "godot-resource"), + ("water.gdshader", "gdshader"), ("notes.txt", "plaintext"), ]; for (path, id) in cases { @@ -686,6 +703,27 @@ mod tests { } } + #[test] + fn gdscript_definition() { + let reg = reg(); + let gd = reg.get(&LanguageId::new("gdscript")).unwrap(); + assert_eq!(gd.line_comment.as_deref(), Some("#")); + let indent = gd.indent.unwrap(); + assert!(!indent.insert_spaces, "Godot indents with tabs"); + assert_eq!(gd.tree_sitter.as_deref(), Some("gdscript")); + let ls = &gd.language_servers[0]; + assert_eq!((ls.name.as_str(), ls.command.as_str()), ("godot", "godot")); + assert_eq!(ls.connect.as_deref(), Some("127.0.0.1:6005")); + assert_eq!(ls.root_markers, ["project.godot"]); + assert!(ls.args.iter().any(|a| a == "${port}") && ls.args.iter().any(|a| a == "${root}")); + let dbg = gd.debugger.as_ref().unwrap(); + assert_eq!((dbg.adapter.as_str(), dbg.transport.as_str()), ("godot", "tcp")); + assert_eq!(dbg.connect.as_deref(), Some("127.0.0.1:6006")); + assert!(dbg.args.iter().any(|a| a == "--dap-port")); + let res = reg.get(&LanguageId::new("godot-resource")).unwrap(); + assert_eq!(res.line_comment.as_deref(), Some(";")); + } + #[test] fn detects_shebangs() { let reg = reg(); diff --git a/crates/ide-language/src/syntax/grammars.rs b/crates/ide-language/src/syntax/grammars.rs index 2045bde4a..b6bdfe45f 100644 --- a/crates/ide-language/src/syntax/grammars.rs +++ b/crates/ide-language/src/syntax/grammars.rs @@ -450,4 +450,14 @@ pub(crate) static SPECS: &[Spec] = &[ imports: NONE, constructors: NONE, }, + Spec { + name: "gdscript", + language: tree_sitter_gdscript::LANGUAGE, + outline: outline!("gdscript"), + highlights: Some(Highlights { query: highlights!("gdscript"), first_wins: false }), + fold_nodes: &["string"], + fold_bodies: &["body", "class_body", "match_body"], + imports: NONE, + constructors: &["_init"], + }, ]; diff --git a/crates/ide-language/src/syntax/highlight.rs b/crates/ide-language/src/syntax/highlight.rs index 2daab82fd..07c942492 100644 --- a/crates/ide-language/src/syntax/highlight.rs +++ b/crates/ide-language/src/syntax/highlight.rs @@ -440,6 +440,31 @@ mod tests { ], ), ("make", "# hi\nCC := gcc\nall: main.o\n\t$(CC) -o app main.o\n", &[("# hi", K::Comment)]), + ( + "gdscript", + "extends Node2D\nclass_name Player\n# hi\n@export var speed: float = 1.5\nconst MAX_HP = 10\nsignal died(cause)\n\nfunc _ready() -> void:\n\tvar label = $Label\n\tself.position.x += speed\n\tprint(\"ready\", &\"name\", 3)\n", + &[ + ("extends", K::Keyword), + ("Node2D", K::Type), + ("class_name", K::Keyword), + ("Player", K::Type), + ("# hi", K::Comment), + ("@export", K::Attribute), + ("var", K::Keyword), + ("float", K::Type), + ("1.5", K::Number), + ("MAX_HP", K::Constant), + ("signal", K::Keyword), + ("func", K::Keyword), + ("_ready", K::Function), + ("$Label", K::StringSpecial), + ("self", K::SelfVariable), + ("print", K::FunctionCall), + ("\"ready\"", K::String), + ("&\"name\"", K::StringSpecial), + ("3", K::Number), + ], + ), ]; for (lang, src, expect) in cases { let got = spans(lang, src); diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index 80614378a..4f4be7047 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -902,6 +902,24 @@ fn outline_more_languages() { Function build ", ); + check_outline( + "gdscript", + "class_name Player\nextends Node\n\nsignal died\nenum State { IDLE, RUN }\nconst SPEED = 2\n@export var hp := 3\n\nfunc _init(a):\n\tpass\n\nfunc jump(height: float) -> void:\n\tvar local = 1\n\nclass Inner:\n\tvar x = 0\n\tfunc go():\n\t\tpass\n", + " + Class Player + Event died + Enum State + EnumMember IDLE + EnumMember RUN + Constant SPEED + Field hp + Constructor _init + Function jump + Class Inner + Field x + Method go + ", + ); check_outline( "cmake", "project(Foo)\nfunction(my_fn arg)\n message(${arg})\nendfunction()\nadd_executable(app main.c)\n", diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index 07bcefa9b..317f9319a 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -14,7 +14,7 @@ lsp-types.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["net"] } parking_lot.workspace = true tracing.workspace = true futures.workspace = true diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 8e4c24b50..b42273a8c 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -1,5 +1,6 @@ //! Starting server processes: binary resolution and the [`Launcher`] seam. +use ide_core::CancellationToken; use ide_jsonrpc::{Handler, Peer}; use ide_language::LanguageServerConfig; use std::ffi::OsString; @@ -27,13 +28,28 @@ pub trait Launcher: Send + Sync + 'static { on_stderr: Box, ) -> Result; + /// [`Launcher::launch`] that gives up once `cancel` fires (the server was stopped + /// while still starting). Launchers that return at once need not override it. + fn launch_cancellable( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result { + let _ = cancel; + self.launch(config, root, handler, on_stderr) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true } } -/// Resolves `command` on an augmented `PATH` and spawns it with stdio transport. +/// Resolves `command` on an augmented `PATH` and spawns it with stdio transport, +/// or for a `connect` server reaches it over TCP (see [`crate::tcp`]). pub struct ProcessLauncher; impl Launcher for ProcessLauncher { @@ -43,12 +59,37 @@ impl Launcher for ProcessLauncher { root: &Path, handler: Arc, on_stderr: Box, + ) -> Result { + self.launch_cancellable(config, root, handler, on_stderr, &CancellationToken::new()) + } + + fn launch_cancellable( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, ) -> Result { let search = server_search_path(config); - let binary = which::which_in(&config.command, Some(&search), root).map_err(|_| LaunchError::Missing)?; - let binary = resolve_rustup_proxy(&config.command, binary); + let binary = which::which_in(&config.command, Some(&search), root) + .ok() + .map(|b| resolve_rustup_proxy(&config.command, b)); let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); + if let Some(addr) = &config.connect { + return crate::tcp::connect_or_start( + config, + addr, + binary.as_deref(), + &env, + root, + handler, + on_stderr, + cancel, + ); + } + let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index bb46369d4..a608317de 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -14,6 +14,7 @@ mod inspect; pub mod jdtls; mod launch; mod server; +mod tcp; mod text; mod tsserver_bridge; pub mod types; @@ -332,7 +333,9 @@ impl LspManager { let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation)); let me = self.clone(); let tail = server.clone(); - // The settings hook may touch the filesystem (e.g. scanning for JDKs). + let cancel = server.start_token(); + // The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP + // servers may take a while to start listening. let launched = tokio::task::spawn_blocking(move || { let cfg = me.configured(&tail); tail.set_effective_config(cfg.clone()); @@ -342,7 +345,7 @@ impl LspManager { let missing = !me.launcher.available(&cfg, &root); return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) }); } - me.launcher.launch(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line))) + me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); @@ -444,9 +447,11 @@ impl LspManager { return; } let tail = server.stderr_tail(5); - let mut message = match code { - Some(c) => format!("{} exited with code {c}", server.name), - None => format!("{} terminated unexpectedly", server.name), + let cfg = server.effective_config(); + let mut message = match (code, &cfg.connect) { + (Some(c), _) => format!("{} exited with code {c}", server.name), + (None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", server.name), + (None, None) => format!("{} terminated unexpectedly", server.name), }; if !tail.is_empty() { message.push_str(":\n"); @@ -1753,6 +1758,15 @@ impl LspManager { return; }; let peer = run.peer.clone(); + // A server we connected to without starting it (an open Godot editor) is not + // ours to shut down: only the connection closes. + if peer.pid().is_none() && server.effective_config().connect.is_some() { + peer.kill(); + server.end_stop(run.generation); + self.clear_server_state(server); + server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None); + return; + } let graceful = async { peer.request::<_, Value>("shutdown", (), None).await.ok(); peer.notify("exit", ()).ok(); diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 43d5ba0b1..8025c0d29 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -2,7 +2,7 @@ use crate::caps::ServerCaps; use ide_core::event::ServerStatus; -use ide_core::{IdeEvent, LanguageId, ServerId, SharedSink, TaskId}; +use ide_core::{CancellationToken, IdeEvent, LanguageId, ServerId, SharedSink, TaskId}; use ide_jsonrpc::Peer; use ide_language::LanguageServerConfig; use lsp_types as lsp; @@ -65,6 +65,8 @@ pub struct Server { effective: Mutex>, languages: Mutex>, state: Mutex, + /// Fired when the instance being started is abandoned (stop / restart while starting). + start_cancel: Mutex, phase: watch::Sender, status: Mutex<(ServerStatus, Option)>, /// Consecutive crashes of short-lived instances. @@ -90,6 +92,7 @@ impl Server { effective: Mutex::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), + start_cancel: Mutex::new(CancellationToken::new()), phase: watch::channel(Phase::Down).0, status: Mutex::new((ServerStatus::Stopped, None)), crashes: Mutex::new(0), @@ -132,6 +135,7 @@ impl Server { } st.generation += 1; st.conn = Conn::Starting; + *self.start_cancel.lock() = CancellationToken::new(); *self.started.lock() = Some(Instant::now()); self.phase.send_replace(Phase::Starting); Some(st.generation) @@ -197,6 +201,7 @@ impl Server { } Conn::Starting => { st.generation += 1; // invalidates the in-flight start + self.start_cancel.lock().cancel(); self.phase.send_replace(Phase::Down); None } @@ -207,6 +212,11 @@ impl Server { } } + /// Cancelled when the current start is abandoned. + pub fn start_token(&self) -> CancellationToken { + self.start_cancel.lock().clone() + } + pub fn end_stop(&self, generation: u64) { let mut st = self.state.lock(); if st.generation == generation && matches!(st.conn, Conn::Stopping) { diff --git a/crates/ide-lsp/src/tcp.rs b/crates/ide-lsp/src/tcp.rs new file mode 100644 index 000000000..c3992d215 --- /dev/null +++ b/crates/ide-lsp/src/tcp.rs @@ -0,0 +1,188 @@ +//! LSP over TCP, for servers living inside another program (the Godot editor's +//! GDScript server). Mirrors `ide-dap`'s `tcp` transport: connect to the +//! configured address when something listens there, else start the configured +//! command (`${port}` → a free port, `${root}` → the workspace root) and connect +//! once it accepts. The framing and session are the same as over stdio. + +use crate::launch::LaunchError; +use ide_core::CancellationToken; +use ide_jsonrpc::{Handler, Peer}; +use ide_language::LanguageServerConfig; +use parking_lot::Mutex; +use std::collections::VecDeque; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; + +pub(crate) const PORT_PLACEHOLDER: &str = "${port}"; +pub(crate) const ROOT_PLACEHOLDER: &str = "${root}"; + +const PROBE_TIMEOUT: Duration = Duration::from_millis(500); +/// A headless Godot editor imports the project before it listens. +const START_TIMEOUT: Duration = Duration::from_secs(120); +const POLL: Duration = Duration::from_millis(200); +const TAIL_LINES: usize = 20; + +/// `${port}` / `${root}` substituted in every argument. +pub(crate) fn substitute_args(args: &[String], port: Option, root: &Path) -> Vec { + let root = root.to_string_lossy(); + args.iter() + .map(|a| { + let a = a.replace(ROOT_PLACEHOLDER, &root); + match port { + Some(p) => a.replace(PORT_PLACEHOLDER, &p.to_string()), + None => a, + } + }) + .collect() +} + +pub(crate) fn resolve_addr(addr: &str) -> Result { + addr.to_socket_addrs() + .ok() + .and_then(|mut a| a.next()) + .ok_or_else(|| LaunchError::Config(format!("invalid language server address `{addr}`"))) +} + +/// A connection to `addr` if something accepts there right now. +pub(crate) fn probe(addr: &SocketAddr) -> Option { + let stream = TcpStream::connect_timeout(addr, PROBE_TIMEOUT).ok()?; + let _ = stream.set_nodelay(true); + Some(stream) +} + +/// A port that was free a moment ago on the loopback interface. +pub(crate) fn free_port() -> std::io::Result { + Ok(std::net::TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) +} + +fn into_tokio(stream: TcpStream) -> std::io::Result { + stream.set_nonblocking(true)?; + tokio::net::TcpStream::from_std(stream) +} + +/// Connects to the server of `config` (whose `connect` is set), starting `binary` when +/// nothing listens yet. Blocking; must run on a thread inside a Tokio runtime. +#[allow(clippy::too_many_arguments)] +pub(crate) fn connect_or_start( + config: &LanguageServerConfig, + addr: &str, + binary: Option<&Path>, + env: &[(String, String)], + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, +) -> Result { + let addr = resolve_addr(addr)?; + if let Some(stream) = probe(&addr) { + tracing::info!("{}: connected to the server running at {addr}", config.name); + let (r, w) = into_tokio(stream).map_err(|e| LaunchError::Failed(e.to_string()))?.into_split(); + return Ok(Peer::connect(r, w, handler)); + } + let binary = binary.ok_or(LaunchError::Missing)?; + let port = config + .args + .iter() + .any(|a| a.contains(PORT_PLACEHOLDER)) + .then(free_port) + .transpose() + .map_err(|e| LaunchError::Failed(format!("no free port: {e}")))?; + let target = port.map_or(addr, |p| SocketAddr::from(([127, 0, 0, 1], p))); + let args = substitute_args(&config.args, port, root); + + let mut cmd = tokio::process::Command::new(binary); + ide_core::process::no_window(cmd.as_std_mut()); + cmd.args(&args) + .current_dir(root) + .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = + cmd.spawn().map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display())))?; + tracing::info!("{}: started {} {}, waiting for {target}", config.name, binary.display(), args.join(" ")); + + let tail: Arc>> = Arc::default(); + let on_line: Arc = { + let tail = tail.clone(); + Arc::new(move |line: String| { + { + let mut t = tail.lock(); + if t.len() == TAIL_LINES { + t.pop_front(); + } + t.push_back(line.clone()); + } + on_stderr(line); + }) + }; + if let Some(out) = child.stdout.take() { + pump_lines(out, on_line.clone()); + } + if let Some(err) = child.stderr.take() { + pump_lines(err, on_line); + } + let failed = |message: String| { + let tail: Vec = tail.lock().iter().cloned().collect(); + LaunchError::Failed(if tail.is_empty() { message } else { format!("{message}:\n{}", tail.join("\n")) }) + }; + + let deadline = Instant::now() + START_TIMEOUT; + loop { + if cancel.is_cancelled() { + let _ = child.start_kill(); + return Err(LaunchError::Failed(format!("{}: start cancelled", config.name))); + } + if let Some(stream) = probe(&target) { + let (r, w) = into_tokio(stream).map_err(|e| LaunchError::Failed(e.to_string()))?.into_split(); + return Ok(Peer::connect_with_child(r, w, handler, child)); + } + if let Ok(Some(status)) = child.try_wait() { + return Err(failed(format!("{} exited before accepting connections on {target} ({status})", config.name))); + } + if Instant::now() >= deadline { + let _ = child.start_kill(); + return Err(failed(format!( + "{} did not accept connections on {target} within {}s", + config.name, + START_TIMEOUT.as_secs() + ))); + } + std::thread::sleep(POLL); + } +} + +fn pump_lines(stream: R, on_line: Arc) { + tokio::spawn(async move { + let mut lines = BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + on_line(line); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholders_are_substituted() { + let args: Vec = + ["--path", "${root}", "--lsp-port", "${port}", "--x=${port}"].iter().map(|s| s.to_string()).collect(); + assert_eq!( + substitute_args(&args, Some(6010), Path::new("/p/game")), + ["--path", "/p/game", "--lsp-port", "6010", "--x=6010"] + ); + assert_eq!(substitute_args(&args, None, Path::new("/p"))[3], "${port}"); + } + + #[test] + fn addresses_resolve() { + assert_eq!(resolve_addr("127.0.0.1:6005").unwrap().port(), 6005); + assert!(matches!(resolve_addr("nonsense"), Err(LaunchError::Config(_)))); + } +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 5ba8b065b..0a0245166 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -192,6 +192,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { root_markers: vec!["mock.toml".into()], initialization_options: Some(toml::Value::Table(toml::toml! { flag = true nested = { deep = 1 } })), install_hint: Some(format!("install {name}")), + connect: None, fallback: false, launch_error: None, } diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 5c42f7fae..e0bba0013 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -71,6 +71,7 @@ async fn missing_binary_is_reported_with_install_hint() { root_markers: vec![], initialization_options: None, install_hint: Some("npm i -g nope-ls".into()), + connect: None, fallback: false, launch_error: None, }; @@ -198,6 +199,7 @@ async fn typescript_language_server_end_to_end() { root_markers: vec!["tsconfig.json".into()], initialization_options: None, install_hint: None, + connect: None, fallback: false, launch_error: None, }; @@ -239,6 +241,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option>, + notifications: Mutex>, + /// Server ends of the accepted connections. + peers: Mutex>, +} + +struct MockHandler(Arc); + +impl Handler for MockHandler { + fn on_notification(&self, method: &str, _params: Value) { + self.0.notifications.lock().push(method.to_owned()); + } + + fn on_request(&self, method: &str, _params: Value) -> Result { + self.0.requests.lock().push(method.to_owned()); + match method { + "initialize" => Ok(json!({ + "capabilities": { "textDocumentSync": 1, "hoverProvider": true }, + "serverInfo": { "name": "mock-godot" } + })), + "textDocument/hover" => Ok(json!({ "contents": { "kind": "markdown", "value": "**Node2D**" } })), + _ => Ok(Value::Null), + } + } +} + +/// Accepts LSP connections on a local port, like the Godot editor on 6005. +async fn mock_server() -> (u16, Arc, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(ServerLog::default()); + let accept = { + let log = log.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let (r, w) = stream.into_split(); + let peer = Peer::connect(r, w, Arc::new(MockHandler(log.clone()))); + log.peers.lock().push(peer); + } + }) + }; + (port, log, accept) +} + +fn godot_cfg(port: u16, command: &str) -> LanguageServerConfig { + toml::from_str(&format!( + "name = \"godot\"\ncommand = \"{command}\"\nconnect = \"127.0.0.1:{port}\"\nroot-markers = [\"project.godot\"]\ninstall-hint = \"Open the project in the Godot editor\"" + )) + .unwrap() +} + +struct Harness { + _dir: tempfile::TempDir, + root: PathBuf, + mgr: Arc, + events: Arc>>, +} + +fn harness(server: LanguageServerConfig) -> Harness { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap(); + let lang: LanguageConfig = toml::from_str("id = \"gdscript\"\nname = \"GDScript\"\nextensions = [\"gd\"]").unwrap(); + let lang = LanguageConfig { language_servers: vec![server], ..lang }; + let registry = Arc::new(LanguageRegistry::from_configs(vec![lang])); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = { + let events = events.clone(); + Arc::new(move |e: IdeEvent| events.lock().push(e)) + }; + let mgr = LspManager::with_launcher(root.clone(), registry, sink, Arc::new(ProcessLauncher)); + Harness { _dir: dir, root, mgr, events } +} + +impl Harness { + fn open(&self, name: &str, text: &str) -> DocId { + let path = self.root.join(name); + std::fs::write(&path, text).unwrap(); + let doc = DocId::next(); + self.mgr.did_open(doc, &path, &LanguageId::new("gdscript"), 1, text); + doc + } + + fn statuses(&self) -> Vec<(ServerStatus, Option)> { + self.events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServer { status, message, .. } => Some((*status, message.clone())), + _ => None, + }) + .collect() + } + + async fn wait_status(&self, status: ServerStatus, count: usize) { + eventually(|| self.statuses().iter().filter(|(s, _)| *s == status).count() >= count).await; + } +} + +async fn eventually(mut f: impl FnMut() -> bool) { + for _ in 0..1000 { + if f() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("condition not reached in time"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn connects_to_a_running_server_requests_and_reconnects() { + let (port, log, _accept) = mock_server().await; + let h = harness(godot_cfg(port, "openidea-no-such-godot")); + let doc = h.open("player.gd", "extends Node2D\n"); + h.wait_status(ServerStatus::Ready, 1).await; + assert_eq!(log.requests.lock().first().map(String::as_str), Some("initialize")); + eventually(|| log.notifications.lock().iter().any(|m| m == "textDocument/didOpen")).await; + + let hover = h.mgr.hover(doc, Pos::new(0, 9), CancellationToken::new()).await.unwrap().unwrap(); + assert!(format!("{hover:?}").contains("Node2D"), "{hover:?}"); + // Attached to a server it didn't start: no process id. + assert_eq!(h.mgr.servers()[0].pid, None); + + // The editor drops the connection (closed or restarted): the client reconnects and reopens. + log.peers.lock().remove(0).kill(); + h.wait_status(ServerStatus::Crashed, 1).await; + let (_, message) = h.statuses().into_iter().rfind(|(s, _)| *s == ServerStatus::Crashed).unwrap(); + assert!(message.unwrap().contains(&format!("127.0.0.1:{port}"))); + h.wait_status(ServerStatus::Ready, 2).await; + eventually(|| log.notifications.lock().iter().filter(|m| *m == "textDocument/didOpen").count() == 2).await; + assert_eq!(log.requests.lock().iter().filter(|m| *m == "initialize").count(), 2); + + // Shutting down leaves a server it didn't start running: no `shutdown` / `exit`. + h.mgr.shutdown_all().await; + eventually(|| log.peers.lock().iter().all(Peer::is_closed)).await; + assert!(!log.requests.lock().iter().any(|m| m == "shutdown")); + assert!(!log.notifications.lock().iter().any(|m| m == "exit")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn nothing_listening_and_no_executable_reports_how_to_fix_it() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let h = harness(godot_cfg(port, "openidea-no-such-godot")); + h.open("player.gd", "extends Node\n"); + h.wait_status(ServerStatus::Missing, 1).await; + let (_, message) = h.statuses().into_iter().rfind(|(s, _)| *s == ServerStatus::Missing).unwrap(); + assert_eq!(message.as_deref(), Some("Open the project in the Godot editor")); +} + +/// The command is started when nothing listens yet, the client waits until the +/// port accepts, and the started process belongs to the connection. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn starts_the_command_and_waits_until_it_listens() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let cfg = LanguageServerConfig { args: vec!["30".into()], ..godot_cfg(port, "sleep") }; + let root = tempfile::tempdir().unwrap(); + // The "server" (`sleep`) never listens itself; a listener comes up a moment later. + let log = Arc::new(ServerLog::default()); + let server = { + let log = log.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(400)).await; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + let (r, w) = stream.into_split(); + log.peers.lock().push(Peer::connect(r, w, Arc::new(MockHandler(log.clone())))); + }) + }; + let path = root.path().to_path_buf(); + let peer = tokio::task::spawn_blocking(move || { + ProcessLauncher.launch(&cfg, &path, Arc::new(ide_jsonrpc::NoopHandler), Box::new(|_| {})) + }) + .await + .unwrap() + .unwrap_or_else(|e| panic!("{e:?}")); + server.await.unwrap(); + let pid = peer.pid().expect("owns the started process"); + let init: Value = peer.request("initialize", json!({}), None).await.unwrap(); + assert_eq!(init["serverInfo"]["name"], "mock-godot"); + + peer.kill(); + peer.closed().await; + eventually(|| !process_alive(pid)).await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn a_command_that_exits_without_listening_fails_and_cancel_stops_waiting() { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let root = tempfile::tempdir().unwrap(); + let path = root.path().to_path_buf(); + let cfg = godot_cfg(port, "false"); + let err = tokio::task::spawn_blocking(move || { + ProcessLauncher.launch(&cfg, &path, Arc::new(ide_jsonrpc::NoopHandler), Box::new(|_| {})) + }) + .await + .unwrap() + .err() + .expect("fails"); + assert!(matches!(&err, LaunchError::Failed(m) if m.contains("exited before accepting connections")), "{err:?}"); + + let cfg = LanguageServerConfig { args: vec!["30".into()], ..godot_cfg(port, "sleep") }; + let path = root.path().to_path_buf(); + let cancel = CancellationToken::new(); + let token = cancel.clone(); + let started = std::time::Instant::now(); + let waiting = tokio::task::spawn_blocking(move || { + ProcessLauncher.launch_cancellable(&cfg, &path, Arc::new(ide_jsonrpc::NoopHandler), Box::new(|_| {}), &token) + }); + tokio::time::sleep(Duration::from_millis(300)).await; + cancel.cancel(); + let err = waiting.await.unwrap().err().expect("cancelled"); + assert!(matches!(&err, LaunchError::Failed(m) if m.contains("cancelled")), "{err:?}"); + assert!(started.elapsed() < Duration::from_secs(10)); +} + +#[cfg(unix)] +fn process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 63becad64..d2ba05e16 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -19,6 +19,7 @@ pub(crate) fn detect(root: &Path) -> Vec { detect_python(root, &mut out); detect_make(root, &mut out); detect_just(root, &mut out); + detect_godot(root, &mut out); let mut seen = HashSet::new(); out.retain(|c| seen.insert(c.id.clone())); @@ -799,6 +800,24 @@ pub(crate) fn just_recipes(src: &str) -> Vec { recipes } +// ---------------------------------------------------------------- godot + +/// Godot projects (`project.godot`) at the root or one level down: "Godot Project" +/// runs the main scene (`godot --path .`), debuggable through the editor's adapter. +fn detect_godot(root: &Path, out: &mut Vec) { + let dirs = std::iter::once(root.to_path_buf()).chain(subdirs(root)); + for dir in dirs.filter(|d| d.join("project.godot").is_file()) { + let rel = relative(root, &dir); + let (id, name, cwd) = if rel.as_os_str().is_empty() { + ("godot:project".to_owned(), "Godot Project".to_owned(), None) + } else { + let r = rel.to_string_lossy(); + (format!("godot:project:{r}"), format!("Godot Project ({r})"), Some(rel.clone())) + }; + out.push(config(id, name, "godot", "godot", &["--path", "."]).debug("godot").in_dir(cwd)); + } +} + fn detect_just(root: &Path, out: &mut Vec) { let Some(path) = first_existing(root, &["justfile", "Justfile", ".justfile"]) else { return }; let Some(src) = read(&path) else { return }; diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 8104bdbf6..9a69ddfe6 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -294,6 +294,21 @@ fn deno_tasks() { assert!(c[1].is_test); } +#[test] +fn godot_projects_run_their_main_scene() { + let dir = project(&[("project.godot", "config_version=5\n"), ("demo/project.godot", "config_version=5\n")]); + let c = detect(dir.path()); + let root = find(&c, "godot:project"); + assert_eq!((root.name.as_str(), root.kind.as_str()), ("Godot Project", "godot")); + assert_eq!(cmdline(root), "godot --path ."); + assert_eq!(root.cwd, None); + assert_eq!(root.debug_adapter.as_deref(), Some("godot")); + let demo = find(&c, "godot:project:demo"); + assert_eq!(demo.name, "Godot Project (demo)"); + assert_eq!(demo.cwd, Some(std::path::PathBuf::from("demo").into())); + assert!(detect(project(&[("main.gd", "extends Node\n")]).path()).is_empty()); +} + #[test] fn just_recipes() { let justfile = "\ diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 93e0ce644..bca1225f5 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -3,8 +3,8 @@ use crate::discover::{project_venv_python, workspace_tsdk}; use crate::exec::{path_with, search_path}; -use crate::vue; use crate::{bin_dir, exe, venv_of}; +use crate::{godot, vue}; use ide_core::settings::Settings; use ide_language::LanguageServerConfig; use ide_run::RunConfig; @@ -169,8 +169,28 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) { /// paths, toolchain `PATH`, env (`GOROOT`, `RUST_SRC_PATH`, `VIRTUAL_ENV`) and /// server settings (`python.pythonPath`, rust-analyzer `check.command`, `typescript.tsdk`). /// `root` is the project root, `server_root` the server's workspace root. +/// The executable to launch for `command`: the one chosen in settings, else for +/// commands with well-known install locations (Godot) the one found there. +pub fn tool_command(root: &Path, s: &Settings, command: &str) -> Option { + let custom = s.tool_path(command).and_then(|p| setting_path(root, p)); + if command == godot::COMMAND { + return godot::find(custom); + } + custom +} + +/// Points a run configuration launching a bare tool name at the executable from +/// [`tool_command`] (Godot apps live outside the search path). +pub fn resolve_run_command(config: &mut RunConfig, root: &Path, s: &Settings) { + if config.command == godot::COMMAND + && let Some(path) = tool_command(root, s, &config.command) + { + config.command = path.to_string_lossy().into_owned(); + } +} + pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { - if let Some(custom) = s.tool_path(&cfg.command).and_then(|p| setting_path(root, p)) { + if let Some(custom) = tool_command(root, s, &cfg.command) { cfg.command = custom.to_string_lossy().into_owned(); } let mut path_dirs: Vec = Vec::new(); @@ -329,6 +349,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings let mvn_options = maven_options(root, s); for c in configs.iter_mut() { + resolve_run_command(c, root, s); let mut path_dirs: Vec = Vec::new(); match c.kind.as_str() { "python" | "pytest" => { diff --git a/crates/ide-sdk/src/godot.rs b/crates/ide-sdk/src/godot.rs new file mode 100644 index 000000000..31999cbe6 --- /dev/null +++ b/crates/ide-sdk/src/godot.rs @@ -0,0 +1,109 @@ +//! Locating the Godot editor executable: the GDScript language server and debug +//! adapter run inside it, and run configurations launch games with it. +//! +//! Order: the executable chosen in settings (`tools.godot`), the search path +//! (`godot`, `godot4`, distro names), then the usual install locations per OS +//! (app bundles, Flatpak / Snap exports, Steam, Scoop / WinGet). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +/// The command language definitions and run configurations launch. +pub const COMMAND: &str = "godot"; + +pub const NOT_FOUND: &str = "Godot was not found. Install Godot 4 (https://godotengine.org/download), or set \ + the Godot executable in Settings › Languages & Frameworks › Language Servers › godot."; + +/// Names Godot is installed under on the search path. +const PATH_NAMES: &[&str] = &["godot", "godot4", "godot-4", "Godot", "godot-mono", "godot4-mono"]; + +/// The Godot executable: `custom` (from settings) when set, else the search path, +/// else a well-known install location. +pub fn find(custom: Option) -> Option { + if let Some(p) = custom { + return Some(p); + } + find_in(&crate::search_path(), &install_locations(crate::home_dir().as_deref())) +} + +pub(crate) fn find_in(search: &OsStr, locations: &[PathBuf]) -> Option { + PATH_NAMES + .iter() + .find_map(|n| ide_run::which::find_executable(n, search)) + .or_else(|| locations.iter().find(|p| p.is_file()).cloned()) +} + +/// Well-known install locations, most specific first. +pub(crate) fn install_locations(home: Option<&Path>) -> Vec { + let mut out: Vec = Vec::new(); + let home_join = |rel: &str| home.map(|h| h.join(rel)); + if cfg!(target_os = "macos") { + for app in ["Godot.app", "Godot_mono.app", "Godot 4.app"] { + let rel = format!("{app}/Contents/MacOS/Godot"); + out.push(Path::new("/Applications").join(&rel)); + out.extend(home_join(&format!("Applications/{rel}"))); + } + out.extend(home_join( + "Library/Application Support/Steam/steamapps/common/Godot Engine/Godot.app/Contents/MacOS/Godot", + )); + } else if cfg!(windows) { + out.extend(home_join("scoop/shims/godot.exe")); + if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) { + out.push(local.join("Microsoft/WinGet/Links/godot.exe")); + out.push(local.join("Programs/Godot/godot.exe")); + } + for base in ["C:/Program Files", "C:/Program Files (x86)"] { + out.push(Path::new(base).join("Godot/godot.exe")); + out.push(Path::new(base).join("Steam/steamapps/common/Godot Engine/godot.windows.opt.tools.64.exe")); + } + } else { + out.extend(home_join(".local/share/flatpak/exports/bin/org.godotengine.Godot")); + out.push("/var/lib/flatpak/exports/bin/org.godotengine.Godot".into()); + out.push("/snap/bin/godot-4".into()); + out.push("/snap/bin/godot".into()); + out.extend(home_join(".local/share/Steam/steamapps/common/Godot Engine/godot.x11.opt.tools.64")); + } + out +} + +/// A Godot project directory: it holds `project.godot`. +pub fn is_project(dir: &Path) -> bool { + dir.join("project.godot").is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_path_wins_over_install_locations() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + let app = tmp.path().join("Godot.app/Contents/MacOS"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(&app).unwrap(); + let installed = app.join("Godot"); + std::fs::write(&installed, "").unwrap(); + let search = std::env::join_paths([&bin]).unwrap(); + assert_eq!(find_in(&search, std::slice::from_ref(&installed)), Some(installed.clone())); + let on_path = bin.join(if cfg!(windows) { "godot4.exe" } else { "godot4" }); + std::fs::write(&on_path, "").unwrap(); + assert_eq!(find_in(&search, std::slice::from_ref(&installed)), Some(on_path)); + assert_eq!(find_in(OsStr::new(""), &[tmp.path().join("missing")]), None); + } + + #[test] + fn custom_path_is_used_as_is() { + assert_eq!(find(Some("/opt/godot/bin/godot".into())), Some(PathBuf::from("/opt/godot/bin/godot"))); + } + + #[test] + fn install_locations_cover_the_platform() { + let locations = install_locations(Some(Path::new("/home/u"))); + assert!(!locations.is_empty()); + if cfg!(target_os = "macos") { + assert!(locations.contains(&PathBuf::from("/Applications/Godot.app/Contents/MacOS/Godot"))); + assert!(locations.contains(&PathBuf::from("/home/u/Applications/Godot.app/Contents/MacOS/Godot"))); + } + } +} diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 8660e9d52..e0a9df622 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -8,6 +8,7 @@ pub mod apply; mod discover; mod exec; +pub mod godot; pub mod java; pub mod libraries; pub mod locate; diff --git a/crates/ide-sdk/src/locate.rs b/crates/ide-sdk/src/locate.rs index 22717b59c..0e54904bd 100644 --- a/crates/ide-sdk/src/locate.rs +++ b/crates/ide-sdk/src/locate.rs @@ -89,6 +89,7 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi: near.and_then(|n| local_bin(n, name)) .or_else(|| on_path(name, search)) .or_else(|| jdk_tool(name, &crate::java::installed_jdks())) + .or_else(|| (name == crate::godot::COMMAND).then(|| crate::godot::find(None)).flatten()) }) .flatten(); LocatedTool { name: name.clone(), path: path.map(Into::into), version: None } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 5bcc70e49..57b899f18 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -29,6 +29,7 @@ fn server(name: &str) -> LanguageServerConfig { root_markers: vec![], initialization_options: None, install_hint: None, + connect: None, fallback: false, launch_error: None, } @@ -292,6 +293,36 @@ fn custom_tool_path_replaces_the_command() { assert_eq!(cfg.command, "zls"); } +#[test] +fn the_godot_setting_applies_to_server_debugger_and_run_configurations() { + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + s.tools.insert("godot".into(), ide_core::settings::ToolSettings { path: "~/Godot/godot".into() }); + let expected = crate::home_dir().unwrap().join("Godot/godot"); + assert_eq!(crate::apply::tool_command(tmp.path(), &s, "godot"), Some(expected.clone())); + let mut cfg = LanguageServerConfig { command: "godot".into(), ..server("godot") }; + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + assert_eq!(Path::new(&cfg.command), expected); + let mut run = RunConfig { + id: "godot:project".into(), + name: "Godot Project".into(), + kind: "godot".into(), + command: "godot".into(), + args: vec!["--path".into(), ".".into()], + cwd: None, + env: BTreeMap::new(), + detected: true, + is_test: false, + debug_adapter: Some("godot".into()), + allow_parallel: false, + extras: Default::default(), + }; + apply_to_run_configs(std::slice::from_mut(&mut run), tmp.path(), &s); + assert_eq!(Path::new(&run.command), expected); + // Other commands keep their name without a setting. + assert_eq!(crate::apply::tool_command(tmp.path(), &s, "zls"), None); +} + #[test] fn python_library_paths_are_parsed_and_deduplicated() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 3e00a6cd6..209466ed5 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -284,6 +284,8 @@ pub struct Tools { platform: Platform, /// Executables chosen in settings, by command. custom: BTreeMap, + /// Executables found outside the search path (app bundles), by command. + found: BTreeMap, } impl Tools { @@ -297,7 +299,7 @@ impl Tools { let system: Vec = std::env::split_paths(&search_path).filter(|d| !ours.contains(d)).collect(); let system_path = std::env::join_paths(&system).unwrap_or_default(); let child_path = std::env::join_paths(ours.iter().chain(system.iter())).unwrap_or_default(); - Self { root, system_path, child_path, platform, custom: BTreeMap::new() } + Self { root, system_path, child_path, platform, custom: BTreeMap::new(), found: BTreeMap::new() } } /// Reports the executables chosen in settings (command → path) as the tools' location. @@ -306,6 +308,12 @@ impl Tools { self } + /// Reports executables located outside the search path (command → path) as on the PATH. + pub fn with_found_paths(mut self, found: BTreeMap) -> Self { + self.found = found; + self + } + pub fn root(&self) -> &Path { &self.root } @@ -429,7 +437,7 @@ impl Tools { let Some(spec) = spec else { let u = usage?; let command = u.command.clone()?; - let path = self.which(&command); + let path = self.which(&command).or_else(|| self.found.get(&command).cloned()); return Some(ToolInfo { name: name.to_string(), display_name: name.to_string(), diff --git a/languages/gdscript.toml b/languages/gdscript.toml new file mode 100644 index 000000000..be6292be9 --- /dev/null +++ b/languages/gdscript.toml @@ -0,0 +1,26 @@ +id = "gdscript" +name = "GDScript" +extensions = ["gd"] +line-comment = "#" +# Godot's default (Editor Settings › Text Editor › Behavior › Indent › Type). +indent = { tab-size = 4, insert-spaces = false } +tree-sitter = "gdscript" + +# The language server runs inside the Godot editor and speaks LSP over TCP only: +# connect to an open editor, else start a headless editor for the project on a free port. +[[language-servers]] +name = "godot" +command = "godot" +args = ["--headless", "--editor", "--path", "${root}", "--lsp-port", "${port}"] +connect = "127.0.0.1:6005" +root-markers = ["project.godot"] +install-hint = "Open the project in the Godot editor, or install Godot 4 and set the Godot executable in Settings › Languages & Frameworks › Language Servers › godot" + +# Godot's debug adapter also lives in the editor (DAP over TCP, port 6006). +[debugger] +adapter = "godot" +command = "godot" +args = ["--headless", "--editor", "--path", "${root}", "--dap-port", "${port}"] +transport = "tcp" +connect = "127.0.0.1:6006" +install-hint = "Open the project in the Godot editor, or install Godot 4 and set the Godot executable in Settings › Languages & Frameworks › Language Servers › godot" diff --git a/languages/gdshader.toml b/languages/gdshader.toml new file mode 100644 index 000000000..2595ecbff --- /dev/null +++ b/languages/gdshader.toml @@ -0,0 +1,8 @@ +id = "gdshader" +name = "Godot Shader" +extensions = ["gdshader", "gdshaderinc"] +line-comment = "//" +block-comment = ["/*", "*/"] +indent = { tab-size = 4, insert-spaces = false } +# GLSL-like: the C mode colours keywords, types, numbers and comments. +ui-mode = "C" diff --git a/languages/godot-resource.toml b/languages/godot-resource.toml new file mode 100644 index 000000000..c9e9a250e --- /dev/null +++ b/languages/godot-resource.toml @@ -0,0 +1,8 @@ +id = "godot-resource" +name = "Godot Resource" +# Text scenes / resources, `project.godot`, import metadata and export presets (INI-like). +extensions = ["tscn", "tres", "godot", "import"] +filenames = ["export_presets.cfg", "override.cfg", "extension_list.cfg"] +line-comment = ";" +indent = { tab-size = 4, insert-spaces = false } +ui-mode = "Properties files" diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index e435b115f..0721eb42a 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -20,7 +20,7 @@ pub async fn debug_start( }; let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); let custom = |command: &str| { - let path = ide_sdk::apply::setting_path(&root, settings.tool_path(command)?)?; + let path = ide_sdk::apply::tool_command(&root, &settings, command)?; Some(path.to_string_lossy().into_owned()) }; state.debugger.start_with(config, owner, custom).await diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index a95d00ed1..84493b4dd 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -61,7 +61,14 @@ pub fn run_start( if config.command.is_empty() { return Err(IdeError::invalid("run configuration has no command")); } - let encoding = project.settings.effective().console.encoding; + let settings = project.settings.effective(); + // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. + let mut config = config; + ide_sdk::apply::resolve_run_command(&mut config, &root, &settings); + if config.command == ide_sdk::godot::COMMAND { + return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); + } + let encoding = settings.console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) }; state.processes.start( &root, diff --git a/src-tauri/src/commands/tools.rs b/src-tauri/src/commands/tools.rs index 184f7ec71..c8ccd15d2 100644 --- a/src-tauri/src/commands/tools.rs +++ b/src-tauri/src/commands/tools.rs @@ -21,7 +21,9 @@ fn tools(app: &AppHandle, state: &AppState, window: &Window) -> IdeResult .iter() .filter_map(|(command, t)| Some((command.clone(), ide_sdk::apply::setting_path(&root, &t.path)?))) .collect(); - Ok(Tools::new(&data).with_custom_paths(custom)) + // Godot is usually an app bundle / Flatpak outside the search path. + let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); + Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) } fn join_err(e: impl std::fmt::Display) -> IdeError { diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index 9a51da251..c49746c24 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -197,4 +197,15 @@ describe("run config → launch config", () => { // lldb-dap takes `KEY=VALUE` strings. expect(extra(dap)).toEqual({ env: ["K=v"] }); }); + + it("Godot: the editor's adapter plays the main scene or the chosen one of the project", () => { + const project = rc({ kind: "godot", command: "/Apps/Godot", args: ["--path", "."], cwd: "game", debugAdapter: "godot" }); + const main = launchConfigFor(project, "/p")!; + expect(main).toMatchObject({ adapter: "godot", request: "launch", cwd: "/p/game", program: null }); + expect(extra(main)).toEqual({ project: "/p/game", scene: "main" }); + const scene = launchConfigFor({ ...project, args: ["--path", ".", "res://levels/one.tscn"] }, "/p")!; + expect(extra(scene)).toEqual({ project: "/p/game", scene: "res://levels/one.tscn" }); + const relative = launchConfigFor({ ...project, cwd: null, args: ["--path", "game", "menu.tscn"] }, "/p")!; + expect(extra(relative)).toEqual({ project: "/p/game", scene: "res://menu.tscn" }); + }); }); diff --git a/src/features/debugger/__tests__/remote.test.ts b/src/features/debugger/__tests__/remote.test.ts index d0c17fff8..9336ac749 100644 --- a/src/features/debugger/__tests__/remote.test.ts +++ b/src/features/debugger/__tests__/remote.test.ts @@ -137,6 +137,14 @@ describe("remote attach requests", () => { }); }); + it("Attach to Godot connects to the editor's debug adapter for the project", () => { + const l = remoteAttachConfig(configOf(remote.godotAttachType), "/p/game")!; + expect(l).toMatchObject({ adapter: "godot", request: "attach", cwd: "/p/game" }); + expect(JSON.parse(l.extraJson!)).toEqual({ debugServer: "localhost:6006", project: "/p/game" }); + const custom = remoteAttachConfig(configOf(remote.godotAttachType, { port: "6010" }), "/p")!; + expect(JSON.parse(custom.extraJson!).debugServer).toBe("localhost:6010"); + }); + it("Attach to Node.js attaches js-debug to the inspector with local/remote roots", () => { const l = remoteAttachConfig(configOf(nodeRemoteType, { host: "web", remoteRoot: "/usr/src/app" }), "/p")!; expect(l.adapter).toBe("js-debug"); diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index fb2e47562..ccd9229b6 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -196,6 +196,24 @@ function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig { return out; } +/** The scene a `godot --path [scene]` command line plays: `main`, or a `res://` path. */ +export function godotScene(args: readonly string[]): string { + const scene = args.find((a, i) => args[i - 1] !== "--path" && /\.t?scn$/i.test(a)); + if (!scene) return "main"; + return scene.startsWith("res://") ? scene : `res://${scene.replace(/^\.\//, "")}`; +} + +/** + * Godot: the adapter inside the editor plays the scene itself (with the editor's run + * settings), so the launch names the project (checked against the editor's) and the scene. + */ +function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig { + const i = rc.args.indexOf("--path"); + const dir = i >= 0 ? rc.args[i + 1] : undefined; + const project = dir ? join(cwd, dir) : cwd; + return { ...base(rc, "godot", project), extraJson: JSON.stringify({ project, scene: godotScene(rc.args) }) }; +} + /** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */ function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig { const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] }; @@ -219,6 +237,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul if (adapter === "debugpy") return debugpyLaunch(rc, cwd); if (adapter === "js-debug") return jsDebugLaunch(rc, cwd); if (adapter === "delve") return delveLaunch(rc, cwd); + if (adapter === "godot") return godotLaunch(rc, cwd); // Adapters without a template (hidden in Edit Configurations): generic arguments. return { ...base(rc, adapter, cwd), diff --git a/src/features/debugger/remote.ts b/src/features/debugger/remote.ts index ee86cff10..5a4828bc3 100644 --- a/src/features/debugger/remote.ts +++ b/src/features/debugger/remote.ts @@ -12,6 +12,7 @@ export const REMOTE_JVM = "remote-jvm"; export const GO_REMOTE = "go-remote"; export const PYTHON_REMOTE = "python-remote"; export const NODE_REMOTE = "node-remote"; +export const GODOT_ATTACH = "godot-attach"; type Params = Readonly>; @@ -220,11 +221,36 @@ export const nodeRemoteType: RunConfigTypeContribution = { commandLine: () => null, }; +// --------------------------------------------------------------------------- +// Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter) + +export const godotAttachType: RunConfigTypeContribution = { + id: GODOT_ATTACH, + label: "Attach to Godot", + icon: "file-gdscript", + description: "Debug the game the Godot editor is running, through the editor's debug adapter", + defaults: { host: "localhost", port: "6006" }, + debugOnly: true, + debugAdapter: "godot", + fields: [ + hostField, + portField, + { + key: "hint", + label: "Before attaching", + control: "output", + value: () => "Open the project in the Godot editor and run a scene (F5 / F6)", + }, + ], + commandLine: () => null, +}; + export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [ remoteJvmType, goRemoteType, pythonRemoteType, nodeRemoteType, + godotAttachType, ]; export const isRemoteConfig = (c: Pick) => REMOTE_TYPES.some((t) => t.id === c.kind); @@ -291,6 +317,8 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | justMyCode: skip, ...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}), }); + case GODOT_ATTACH: + return attachBase(rc, "godot", root, { debugServer: serverAddress(host, portOf(p, 6006)), project: root }); case NODE_REMOTE: return attachBase(rc, "js-debug", root, { type: "pwa-node", diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 773b70434..c159baa4b 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -262,6 +262,26 @@ describe("project producers", () => { cfg({ id: "pytest", kind: "pytest", command: ".venv/bin/pytest", args: [], isTest: true }), ]; + it("Godot scenes run with godot --path in the project holding them (Current Scene)", () => { + const godot = [ + cfg({ id: "godot:project", kind: "godot", command: "godot", args: ["--path", "."], debugAdapter: "godot" }), + cfg({ id: "godot:project:demo", kind: "godot", command: "godot", args: ["--path", "."], cwd: "demo" }), + ]; + const root = produceContext({ path: `${ROOT}/levels/one.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!; + expect(root).toMatchObject({ name: "one.tscn", scope: "file" }); + expect(root.config).toMatchObject({ + kind: "godot", + command: "godot", + args: ["--path", ".", "res://levels/one.tscn"], + cwd: null, + debugAdapter: "godot", + }); + const nested = produceContext({ path: `${ROOT}/demo/main.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!; + expect(nested.config).toMatchObject({ args: ["--path", ".", "res://main.tscn"], cwd: "demo" }); + // Outside a Godot project there is nothing to run. + expect(produceContext({ path: `${ROOT}/a.tscn`, root: ROOT, configs: [] }, OPTIMISTIC)).toBeNull(); + }); + it("keeps package.json test files on the project's runner", () => { const p = produceContext({ path: `${ROOT}/src/a.test.ts`, root: ROOT, configs, content: "test('x', () => {})" }, ALL)!; expect(p).toMatchObject({ name: "a.test.ts", scope: "file" }); diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index a64bec9d4..a6fb12759 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -511,6 +511,33 @@ function cargoOwner(rel: string, configs: readonly RunConfig[]): RunConfig[] { const hasBuildTool = (configs: readonly RunConfig[], kinds: string[]) => configs.some((c) => kinds.includes(c.kind)); +/** + * A Godot scene: `godot --path res://` in the Godot project holding it + * (a detected "Godot Project" configuration), debuggable through the editor's adapter. + */ +function godotScene(rel: string, configs: readonly RunConfig[]): Produced | null { + const dirs = configs + .filter((c) => c.kind === "godot" && c.detected && c.id.startsWith("godot:project")) + .map((c) => (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "")); + const dir = dirs.filter((d) => !d || rel.startsWith(d + "/")).sort((a, b) => b.length - a.length)[0]; + if (dir === undefined) return null; + const name = baseName(rel); + const scene = `res://${dir ? rel.slice(dir.length + 1) : rel}`; + const config: RunConfig = { + id: `context:file:${rel}`, + name, + kind: "godot", + command: "godot", + args: ["--path", ".", scene], + cwd: dir || null, + env: {}, + detected: true, + isTest: false, + debugAdapter: "godot", + }; + return { name, scope: "file", config }; +} + /** The file as a whole: tests of a test file, the project's run target, or the runtime for its language. */ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null { const s = site(input); @@ -519,6 +546,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null const rel = s.idPath; if (s.inProject) { + if (/\.t?scn$/i.test(rel)) return godotScene(rel, configs); // Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains. const role = fileRole(rel, input.content); if (role === "test") { diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index 2de935c2f..4ec666e44 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -77,6 +77,26 @@ describe("configuration types", () => { expect(withParams(old, params)).toMatchObject({ command: "cargo", args: ["run", "--bin", "my app"] }); }); + it("Godot runs the main scene or a chosen one, and reads detected configurations back", () => { + const godot = typeFor("godot")!; + expect(godot.debugAdapter).toBe("godot"); + const c = cfg("g", { kind: "godot" }); + expect(withParams(c, { projectDir: "game", scene: "", arguments: "" })).toMatchObject({ + command: "godot", + args: ["--path", "."], + cwd: "game", + }); + expect(withParams(c, { scene: "res://one.tscn", arguments: "--debug-collisions" })).toMatchObject({ + args: ["--path", ".", "res://one.tscn", "--debug-collisions"], + cwd: null, + }); + const detected = cfg("godot:project", { kind: "godot", command: "/Apps/Godot.app/Contents/MacOS/Godot", args: ["--path", "."] }); + expect(godot.paramsFrom!(detected)).toEqual({ projectDir: "", scene: "", arguments: "" }); + const scene = { ...detected, cwd: "demo", args: ["--path", ".", "res://a.tscn", "--verbose"] }; + expect(godot.paramsFrom!(scene)).toEqual({ projectDir: "demo", scene: "res://a.tscn", arguments: "--verbose" }); + expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); + }); + it("shell script files and text derive the command line", () => { const c = cfg("s"); expect( diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 8802aed9c..9d30c8a11 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -111,6 +111,47 @@ const program: RunConfigTypeContribution = { c.args.length || !/\s/.test(c.command) ? { program: c.command, arguments: formatArgs(c.args) } : null, }; +const isScene = (a: string) => /\.t?scn$/i.test(a); +const isGodot = (cmd: string) => /^godot([-.\w]*)$/i.test(baseName(cmd).replace(/\.exe$/i, "")); + +/** + * Godot: `godot --path ` runs the main scene, `… ` one scene. The + * executable is the one from Settings › Language Servers › godot (else detected). + */ +const godot: RunConfigTypeContribution = { + id: "godot", + label: "Godot", + icon: "file-gdscript", + description: "Run a Godot project's main scene or a chosen scene", + ownsWorkingDirectory: true, + debugAdapter: "godot", + fields: [ + { key: "projectDir", label: "Project directory", mono: true, placeholder: "Project root (holds project.godot)" }, + { + key: "scene", + label: "Scene", + mono: true, + placeholder: "Main scene", + hint: "e.g. res://levels/level_1.tscn; empty runs the project's main scene", + suggestions: (configs) => [...new Set(configs.filter((c) => c.kind === "godot").flatMap((c) => c.args.filter(isScene)))], + }, + { key: "arguments", label: "Arguments", mono: true, placeholder: "Godot command line options, e.g. --debug-collisions" }, + ], + commandLine: (p) => ({ + command: "godot", + args: ["--path", ".", ...(p.scene?.trim() ? [p.scene.trim()] : []), ...parseArgs(p.arguments ?? "")], + cwd: p.projectDir?.trim() || null, + }), + paramsFrom(c) { + if (!isGodot(c.command)) return null; + const rest = [...c.args]; + const path = rest.indexOf("--path"); + if (path >= 0) rest.splice(path, 2); + const scene = rest.find(isScene) ?? ""; + return { projectDir: dirOf(c.cwd), scene, arguments: formatArgs(rest.filter((a) => a !== scene)) }; + }, +}; + const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"]; const NPM_COMMANDS = ["run", "test", "start", "install", "ci", "publish", "pack"]; @@ -228,7 +269,7 @@ const compound: RunConfigTypeContribution = { commandLine: () => null, }; -export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [shell, program, npm, gradle, maven, compound]; +export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [shell, program, npm, gradle, maven, godot, compound]; export function registerBuiltinTypes(): () => void { const disposers = BUILTIN_TYPES.map(registerRunConfigType); diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 08402c5c4..71277ef1e 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -112,6 +112,9 @@ const ICONS = { "file-ruby": "openidea/fileTypes/ruby", "file-lua": "openidea/fileTypes/lua", "file-zig": "openidea/fileTypes/zig", + "file-gdscript": "openidea/fileTypes/gdscript", + "file-godot": "openidea/fileTypes/godot", + "file-gdshader": "openidea/fileTypes/gdshader", "file-dart": "openidea/fileTypes/dart", "file-elixir": "openidea/fileTypes/elixir", "file-erlang": "openidea/fileTypes/erlang", @@ -800,6 +803,9 @@ const EXT_ICONS = table({ "file-ruby": "rb rake gemspec ru rbi jbuilder podspec", "file-lua": "lua luau rockspec", "file-zig": "zig zon", + "file-gdscript": "gd", + "file-godot": "tscn tres godot import", + "file-gdshader": "gdshader gdshaderinc", "file-dart": "dart", "file-elixir": "ex exs", "file-erlang": "erl hrl escript app.src", diff --git a/src/ui/openidea/fileTypes/gdscript.svg b/src/ui/openidea/fileTypes/gdscript.svg new file mode 100644 index 000000000..1abfd96f8 --- /dev/null +++ b/src/ui/openidea/fileTypes/gdscript.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/gdscript_dark.svg b/src/ui/openidea/fileTypes/gdscript_dark.svg new file mode 100644 index 000000000..28ed7665d --- /dev/null +++ b/src/ui/openidea/fileTypes/gdscript_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/gdshader.svg b/src/ui/openidea/fileTypes/gdshader.svg new file mode 100644 index 000000000..e0119d8d0 --- /dev/null +++ b/src/ui/openidea/fileTypes/gdshader.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/gdshader_dark.svg b/src/ui/openidea/fileTypes/gdshader_dark.svg new file mode 100644 index 000000000..34be60abb --- /dev/null +++ b/src/ui/openidea/fileTypes/gdshader_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/godot.svg b/src/ui/openidea/fileTypes/godot.svg new file mode 100644 index 000000000..f80313ab1 --- /dev/null +++ b/src/ui/openidea/fileTypes/godot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/godot_dark.svg b/src/ui/openidea/fileTypes/godot_dark.svg new file mode 100644 index 000000000..308cb0576 --- /dev/null +++ b/src/ui/openidea/fileTypes/godot_dark.svg @@ -0,0 +1,3 @@ + + +