дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
2eaa6cb0c7
коміт
e3dae02de7
52 files changed
+1584
-33
No files matched your search
Створено
+11
@@ -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"
|
||||
|
||||
@@ -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<String>,
|
||||
/// `host:port` of an adapter inside another program (the Godot editor): connected to
|
||||
/// when it accepts, else `command` is started.
|
||||
pub connect: Option<String>,
|
||||
}
|
||||
|
||||
/// Built-in adapters used when no language definition names `adapter`.
|
||||
@@ -47,6 +52,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<u16> {
|
||||
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<u16>) -> 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();
|
||||
|
||||
@@ -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<Vec<StackFrame>> {
|
||||
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<Vec<Scope>> {
|
||||
|
||||
@@ -51,9 +51,16 @@ pub(crate) struct Session {
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
pub breakpoint_ids: Mutex<HashMap<i64, (PathBuf, usize)>>,
|
||||
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
|
||||
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<DebugManager>, s: &Arc<Session>, 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);
|
||||
|
||||
@@ -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<LanguageRegistry> {
|
||||
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<String> = ["--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);
|
||||
}
|
||||
@@ -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=<godot executable> 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<LanguageRegistry> {
|
||||
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<Vec<IdeEvent>>, 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<Mutex<Vec<IdeEvent>>> = 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();
|
||||
}
|
||||
@@ -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<R, W>(
|
||||
reader: R,
|
||||
writer: W,
|
||||
handler: Arc<dyn Handler>,
|
||||
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<R, W>(
|
||||
reader: R,
|
||||
mut writer: W,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -75,6 +75,14 @@ pub struct LanguageServerConfig {
|
||||
/// Human hint shown when the binary is missing.
|
||||
#[serde(default)]
|
||||
pub install_hint: Option<String>,
|
||||
/// `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<String>,
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub install_hint: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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::<std::net::SocketAddr>().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::<std::net::SocketAddr>().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();
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<dyn Fn(String) + Send + Sync>,
|
||||
) -> Result<Peer, LaunchError>;
|
||||
|
||||
/// [`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<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Peer, LaunchError> {
|
||||
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<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> Result<Peer, LaunchError> {
|
||||
self.launch_cancellable(config, root, handler, on_stderr, &CancellationToken::new())
|
||||
}
|
||||
|
||||
fn launch_cancellable(
|
||||
&self,
|
||||
config: &LanguageServerConfig,
|
||||
root: &Path,
|
||||
handler: Arc<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Peer, LaunchError> {
|
||||
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())))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Option<LanguageServerConfig>>,
|
||||
languages: Mutex<Vec<LanguageId>>,
|
||||
state: Mutex<State>,
|
||||
/// Fired when the instance being started is abandoned (stop / restart while starting).
|
||||
start_cancel: Mutex<CancellationToken>,
|
||||
phase: watch::Sender<Phase>,
|
||||
status: Mutex<(ServerStatus, Option<String>)>,
|
||||
/// 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) {
|
||||
|
||||
@@ -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<u16>, root: &Path) -> Vec<String> {
|
||||
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<SocketAddr, LaunchError> {
|
||||
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<TcpStream> {
|
||||
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<u16> {
|
||||
Ok(std::net::TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port())
|
||||
}
|
||||
|
||||
fn into_tokio(stream: TcpStream) -> std::io::Result<tokio::net::TcpStream> {
|
||||
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<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Peer, LaunchError> {
|
||||
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<Mutex<VecDeque<String>>> = Arc::default();
|
||||
let on_line: Arc<dyn Fn(String) + Send + Sync> = {
|
||||
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<String> = 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<R: AsyncRead + Unpin + Send + 'static>(stream: R, on_line: Arc<dyn Fn(String) + Send + Sync>) {
|
||||
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<String> =
|
||||
["--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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<serde_json::Valu
|
||||
root_markers: vec!["package.json".into()],
|
||||
initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()),
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
launch_error: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Language servers reached over TCP (`connect`, the Godot editor's GDScript
|
||||
//! server) against a mock LSP server on a local socket.
|
||||
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_core::{CancellationToken, DocId, IdeEvent, LanguageId, Pos};
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
|
||||
use ide_lsp::{LaunchError, Launcher, LspManager, ProcessLauncher};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerLog {
|
||||
requests: Mutex<Vec<String>>,
|
||||
notifications: Mutex<Vec<String>>,
|
||||
/// Server ends of the accepted connections.
|
||||
peers: Mutex<Vec<Peer>>,
|
||||
}
|
||||
|
||||
struct MockHandler(Arc<ServerLog>);
|
||||
|
||||
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<Value, (i64, String)> {
|
||||
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<ServerLog>, 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<LspManager>,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
}
|
||||
|
||||
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<String>)> {
|
||||
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())
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
|
||||
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<String> {
|
||||
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<RunConfig>) {
|
||||
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<RunConfig>) {
|
||||
let Some(path) = first_existing(root, &["justfile", "Justfile", ".justfile"]) else { return };
|
||||
let Some(src) = read(&path) else { return };
|
||||
|
||||
@@ -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 = "\
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> = 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<PathBuf> = Vec::new();
|
||||
match c.kind.as_str() {
|
||||
"python" | "pytest" => {
|
||||
|
||||
@@ -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<PathBuf>) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = 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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod apply;
|
||||
mod discover;
|
||||
mod exec;
|
||||
pub mod godot;
|
||||
pub mod java;
|
||||
pub mod libraries;
|
||||
pub mod locate;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -284,6 +284,8 @@ pub struct Tools {
|
||||
platform: Platform,
|
||||
/// Executables chosen in settings, by command.
|
||||
custom: BTreeMap<String, PathBuf>,
|
||||
/// Executables found outside the search path (app bundles), by command.
|
||||
found: BTreeMap<String, PathBuf>,
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
@@ -297,7 +299,7 @@ impl Tools {
|
||||
let system: Vec<PathBuf> = 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<String, PathBuf>) -> 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(),
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,9 @@ fn tools(app: &AppHandle, state: &AppState, window: &Window) -> IdeResult<Tools>
|
||||
.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 {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -196,6 +196,24 @@ function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The scene a `godot --path <dir> [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),
|
||||
|
||||
@@ -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<Record<string, string>>;
|
||||
|
||||
@@ -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<RunConfig, "kind">) => 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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 <project> res://<scene>` 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") {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <project>` runs the main scene, `… <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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#478CBF"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M0.5 0.5V5.5H1.7C2.8 5.5 3.5 4.7 3.5 3.6V2.4C3.5 1.3 2.8 0.5 1.7 0.5Z" stroke="white"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 567 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#5C9DD1"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 0.5V5.5H1.7C2.8 5.5 3.5 4.7 3.5 3.6V2.4C3.5 1.3 2.8 0.5 1.7 0.5Z" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 571 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#8E5DB8"/><path transform="translate(4 6)" d="M3.5 1.3C3.2 0.8 2.7 0.5 2 0.5C1.1 0.5 0.5 1 0.5 1.7C0.5 3.2 3.5 2.6 3.5 4.3C3.5 5 2.9 5.5 2 5.5C1.3 5.5 0.7 5.2 0.4 4.6" stroke="white"/><path transform="translate(9 6)" d="M0.5 0V6M3.5 0V6M0.5 3H3.5" stroke="white"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 536 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#A577CC"/><path transform="translate(4 6)" d="M3.5 1.3C3.2 0.8 2.7 0.5 2 0.5C1.1 0.5 0.5 1 0.5 1.7C0.5 3.2 3.5 2.6 3.5 4.3C3.5 5 2.9 5.5 2 5.5C1.3 5.5 0.7 5.2 0.4 4.6" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 0V6M3.5 0V6M0.5 3H3.5" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 540 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#478CBF"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="white"/><path transform="translate(9 6)" d="M0.5 5.5V0.5H2C3 0.5 3.5 1.1 3.5 1.9C3.5 2.7 3 3.3 2 3.3H0.5M2 3.3L3.5 5.5" stroke="white"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 572 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke-linejoin="round"><path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#5C9DD1"/><path transform="translate(4 6)" d="M3.5 1.4C3.2 0.8 2.6 0.5 2 0.5C1.1 0.5 0.5 1.3 0.5 2.3V3.7C0.5 4.7 1.1 5.5 2 5.5C2.9 5.5 3.5 4.9 3.5 4V3.5H2" stroke="#2B2D30"/><path transform="translate(9 6)" d="M0.5 5.5V0.5H2C3 0.5 3.5 1.1 3.5 1.9C3.5 2.7 3 3.3 2 3.3H0.5M2 3.3L3.5 5.5" stroke="#2B2D30"/></g>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 576 B |
Reference in new issue
Заблокувати користувача