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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 15:31:25 +08:00
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"
+56 -4
Переглянути файл
@@ -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();
+18 -7
Переглянути файл
@@ -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>> {
+33 -2
Переглянути файл
@@ -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);
+134
Переглянути файл
@@ -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);
}
+157
Переглянути файл
@@ -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();
}
+26 -2
Переглянути файл
@@ -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,
+1
Переглянути файл
@@ -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 }
+60
Переглянути файл
@@ -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
+14
Переглянути файл
@@ -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)
+12
Переглянути файл
@@ -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>,
}
+38
Переглянути файл
@@ -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();
+10
Переглянути файл
@@ -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"],
},
];
+25
Переглянути файл
@@ -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);
+18
Переглянути файл
@@ -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",
+1 -1
Переглянути файл
@@ -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
+44 -3
Переглянути файл
@@ -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())))
}
+19 -5
Переглянути файл
@@ -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();
+11 -1
Переглянути файл
@@ -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) {
+188
Переглянути файл
@@ -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(_))));
}
}
+1
Переглянути файл
@@ -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,
}
+3
Переглянути файл
@@ -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,
}
+241
Переглянути файл
@@ -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
Переглянути файл
@@ -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 };
+15
Переглянути файл
@@ -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 = "\
+23 -2
Переглянути файл
@@ -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" => {
+109
Переглянути файл
@@ -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")));
}
}
}
+1
Переглянути файл
@@ -8,6 +8,7 @@
pub mod apply;
mod discover;
mod exec;
pub mod godot;
pub mod java;
pub mod libraries;
pub mod locate;
+1
Переглянути файл
@@ -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 }
+31
Переглянути файл
@@ -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();
+10 -2
Переглянути файл
@@ -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(),
+26
Переглянути файл
@@ -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"
+8
Переглянути файл
@@ -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"
+8
Переглянути файл
@@ -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"
+1 -1
Переглянути файл
@@ -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
+8 -1
Переглянути файл
@@ -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,
+3 -1
Переглянути файл
@@ -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 {
+11
Переглянути файл
@@ -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" });
});
});
+8
Переглянути файл
@@ -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");
+19
Переглянути файл
@@ -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),
+28
Переглянути файл
@@ -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",
+20
Переглянути файл
@@ -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" });
+28
Переглянути файл
@@ -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") {
+20
Переглянути файл
@@ -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(
+42 -1
Переглянути файл
@@ -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);
+6
Переглянути файл
@@ -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",
+3
Переглянути файл
@@ -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

+3
Переглянути файл
@@ -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

+3
Переглянути файл
@@ -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

+3
Переглянути файл
@@ -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

+3
Переглянути файл
@@ -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

+3
Переглянути файл
@@ -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