From 3ae6bddcc0688a4e7d8ac1f06de5af76e7f4372f Mon Sep 17 00:00:00 2001 From: Ilya L Date: Mon, 9 Nov 2026 19:16:21 +0200 Subject: [PATCH] feat(arduino): add sketch code intelligence (#2177) .ino sketches get C++ highlighting and code intelligence through Arduino Language Server: completion, signature help, hover, definition navigation and diagnostics. The IDE finds arduino-cli, clangd and the language server installed by Arduino IDE 2 (or paths from languages.arduino settings), reads the board FQBN from sketch.yaml or settings, roots the server at the sketch, and works around the server's capability quirks. Build, upload and serial monitor are left to a plugin. Co-authored-by: mushketer888 <2189038+mushketer888@users.noreply.github.com> --- README.md | 10 + crates/ide-core/src/settings.rs | 13 ++ crates/ide-language/src/lib.rs | 2 +- crates/ide-language/src/markers.rs | 21 ++ crates/ide-lsp/src/arduino.rs | 210 ++++++++++++++++++ crates/ide-lsp/src/inspect.rs | 11 + crates/ide-lsp/src/lib.rs | 21 +- crates/ide-sdk/src/apply.rs | 3 + crates/ide-sdk/src/arduino.rs | 344 +++++++++++++++++++++++++++++ crates/ide-sdk/src/lib.rs | 1 + languages/arduino.toml | 23 +- src/bindings.ts | 10 + src/platform/locales/text/en.json | 3 + src/platform/locales/text/zh.json | 3 + 14 files changed, 670 insertions(+), 5 deletions(-) create mode 100644 crates/ide-lsp/src/arduino.rs create mode 100644 crates/ide-sdk/src/arduino.rs diff --git a/README.md b/README.md index 2a083915a..df480c8a2 100644 --- a/README.md +++ b/README.md @@ -76,4 +76,14 @@ the [official plugins](plugins/official). The plugin API is still a preview. [Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). +Arduino sketches (`.ino`) have C++ syntax highlighting and code intelligence via Arduino +Language Server. For completion, signature help, hover and definition navigation, install +Arduino IDE 2 and the board's core. Configure the server command and arguments in +Settings > Languages & Frameworks > Language Servers, or set tool paths in `settings.toml` +using `languages.arduino.cli`, `languages.arduino.clangd`, `languages.arduino.languageServer` +and `languages.arduino.cliConfig`. OpenIDEA +automatically discovers the IDE's Arduino CLI, clangd and Arduino Language Server on +Windows, macOS and Linux; board FQBN is read from `sketch.yaml` or +`languages.arduino.fqbn` in `settings.toml`. + Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o. diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 5a936b165..5a9cbd612 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -656,6 +656,7 @@ pub struct LanguagesSettings { pub java: JavaSettings, pub ruby: RubySettings, pub markdown: MarkdownSettings, + pub arduino: ArduinoSettings, } impl Default for LanguagesSettings { @@ -677,10 +678,22 @@ impl Default for LanguagesSettings { java: JavaSettings::default(), ruby: RubySettings::default(), markdown: MarkdownSettings::default(), + arduino: ArduinoSettings::default(), } } } +/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ArduinoSettings { + pub cli: String, + pub clangd: String, + pub language_server: String, + pub cli_config: String, + pub fqbn: String, +} + /// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 701374400..016fbeb0c 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -14,7 +14,7 @@ mod markers; pub mod plugin_point; mod registry; mod syntax; -pub use markers::marked_root; +pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root}; pub use plugin_point::PluginLanguages; pub use registry::{FileTypes, LanguageRegistry}; pub use syntax::{ diff --git a/crates/ide-language/src/markers.rs b/crates/ide-language/src/markers.rs index 05eb9b0ff..78d6c41ca 100644 --- a/crates/ide-language/src/markers.rs +++ b/crates/ide-language/src/markers.rs @@ -19,6 +19,27 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) best } +/// `dir` contains at least one `.ino` regular file. +pub fn is_arduino_sketch_dir(dir: &Path) -> bool { + std::fs::read_dir(dir).is_ok_and(|entries| { + entries.flatten().any(|entry| { + entry.path().extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ino")) + && entry.file_type().is_ok_and(|kind| kind.is_file()) + }) + }) +} + +/// Nearest enclosing directory of `path` (including `path` when it is already a +/// directory) within `project` that contains a `.ino` file. +pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option { + let start = usize::from(!path.is_dir()); + path.ancestors() + .skip(start) + .take_while(|dir| dir.starts_with(project)) + .find(|dir| is_arduino_sketch_dir(dir)) + .map(Path::to_path_buf) +} + /// `dir` contains `marker`: a relative path, a file name pattern with one /// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it /// must contain (`pyproject.toml#tool.ruff`). diff --git a/crates/ide-lsp/src/arduino.rs b/crates/ide-lsp/src/arduino.rs new file mode 100644 index 000000000..91b2ae59e --- /dev/null +++ b/crates/ide-lsp/src/arduino.rs @@ -0,0 +1,210 @@ +//! Arduino sketches need their own board context, including their C/C++ sources. + +use crate::LspManager; +use crate::server::Server; +use ide_core::{LanguageId, Location}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub(crate) const SERVER: &str = "arduino-language-server"; + +pub(crate) fn sketch_root(project: &Path, file: &Path) -> Option { + ide_language::arduino_sketch_root(project, file) +} + +pub(crate) fn configure_client_capabilities(capabilities: &mut Value) { + // Arduino's proxy cannot decode the boolean full form, panics on inlayHint (#225), + // and cannot handle clangd's semanticTokens refresh request (#125). + for section in ["textDocument", "workspace"] { + if let Some(section) = capabilities[section].as_object_mut() { + section.remove("semanticTokens"); + section.remove("inlayHint"); + } + } + // ALS uses go.bug.st/lsp (LSP 3.14) which requires a `range` field on `lsp.TextEdit`. + // Clangd returns `InsertReplaceEdit` (`insert` + `replace`) when `insertReplaceSupport` is advertised, + // causing ALS to drop the connection with "json: undefined required field range into lsp.TextEdit". + if let Some(completion) = capabilities.get_mut("textDocument").and_then(|t| t.get_mut("completion")) + && let Some(item) = completion.get_mut("completionItem").and_then(|c| c.as_object_mut()) + { + item.remove("insertReplaceSupport"); + } +} + +pub(crate) fn sanitize_server_capabilities(capabilities: &mut Value) { + // Arduino Language Server's request dispatcher advertises documentSymbol and + // workspaceSymbol, but panics with "not implemented" / "unimplemented" when called. + // Tree-sitter handles C++ outline/symbols in OpenIDEA without hitting ALS. + if let Some(obj) = capabilities.as_object_mut() { + obj.remove("documentSymbolProvider"); + obj.remove("workspaceSymbolProvider"); + } +} + +impl LspManager { + pub(crate) fn arduino_servers(&self, language: &LanguageId, path: &Path) -> Option>> { + if !matches!(language.as_str(), "c" | "cpp") { + return None; + } + let configs = self.language_servers(&LanguageId::new("arduino")); + let cfg = configs.iter().find(|cfg| cfg.name == SERVER)?; + let project = self.project_root_for(path); + if sketch_root(&project, path).is_some() { + let server = self.server_for(cfg, &LanguageId::new("arduino"), path); + server.add_language(language); + let mut servers = vec![server]; + servers.extend( + self.language_servers(language) + .iter() + .filter(|cfg| cfg.name != "clangd" && cfg.name != SERVER) + .filter(|cfg| { + !cfg.require_root_marker + || crate::launch::marked_root(&project, path, &cfg.root_markers).is_some() + }) + .map(|cfg| self.server_for(cfg, language, path)), + ); + return Some(servers); + } + if !path.starts_with(&project) + && let Some(server) = self.origin_server(&crate::uri::path_to_uri_string(path)) + && server.name == SERVER + { + return Some(vec![server]); + } + None + } + + pub(crate) fn note_arduino_sources(&self, server: &Arc, locations: &[Location]) { + if server.name != SERVER { + return; + } + let mut origins = None; + for location in locations { + let path: &Path = &location.path; + if location.uri.is_none() && !path.starts_with(self.project_root_for(path)) { + origins + .get_or_insert_with(|| self.virtual_origins.lock()) + .insert(crate::uri::path_to_uri_string(path), server.id); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_server_allows_diagnostics() { + let registry = ide_language::LanguageRegistry::load(None).unwrap(); + let language = registry.get(&LanguageId::new("arduino")).unwrap(); + let cfg = language.language_servers.iter().find(|cfg| cfg.name == SERVER).unwrap(); + assert!(crate::caps::Restriction::new(SERVER, &cfg.features, &cfg.code_action_kinds).diagnostics()); + } + + #[tokio::test] + async fn inspection_checks_the_sketch_server_and_root_for_all_sources() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let sketch = root.join("examples/blink"); + std::fs::create_dir_all(&sketch).unwrap(); + std::fs::write(sketch.join("blink.ino"), "").unwrap(); + let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap()); + let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {})); + let expected_root = sketch.clone(); + manager.set_config_hook(Box::new(move |cfg, server_root| { + cfg.command = std::env::current_exe().unwrap().to_string_lossy().into_owned(); + if cfg.name != SERVER || server_root != expected_root { + cfg.launch_error = Some("unavailable".into()); + } + })); + for name in ["blink.ino", "helper.cpp", "helper.h"] { + assert!(manager.serves_path(&sketch.join(name)), "{name}"); + } + assert!(!manager.serves_path(&root.join("main.cpp"))); + } + + #[test] + fn nested_sketches_keep_separate_board_roots() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let inner = root.join("examples/blink"); + std::fs::create_dir_all(inner.join("src")).unwrap(); + std::fs::write(root.join("main.ino"), "").unwrap(); + std::fs::write(inner.join("blink.ino"), "").unwrap(); + assert_eq!(sketch_root(root, &inner.join("blink.ino")), Some(inner.clone())); + assert_eq!(sketch_root(root, &inner.join("src/helper.cpp")), Some(inner)); + assert_eq!(sketch_root(root, &root.join("helper.h")), Some(root.to_path_buf())); + assert_eq!(sketch_root(&root.join("examples"), &root.join("other.cpp")), None); + } + + #[test] + fn ordinary_cpp_projects_and_ino_named_directories_are_not_sketches() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("not-a-file.ino")).unwrap(); + assert_eq!(sketch_root(dir.path(), &dir.path().join("main.cpp")), None); + } + + #[tokio::test] + async fn cpp_routing_is_scoped_to_sketches_and_respects_disabled_arduino() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let sketch = root.join("examples/blink"); + std::fs::create_dir_all(&sketch).unwrap(); + std::fs::write(sketch.join("blink.ino"), "").unwrap(); + let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap()); + let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {})); + let cpp = LanguageId::new("cpp"); + let sketch_servers = manager.servers_for(&cpp, &sketch.join("helper.cpp")); + assert_eq!(sketch_servers.iter().map(|s| s.name.as_str()).collect::>(), [SERVER]); + assert_eq!(sketch_servers[0].root, sketch); + assert!(sketch_servers[0].languages().contains(&LanguageId::new("arduino"))); + assert_eq!(manager.servers_for(&cpp, &root.join("main.cpp"))[0].name, "clangd"); + manager.set_servers_hook(Box::new( + |language, defined| { + if language.as_str() == "arduino" { Vec::new() } else { defined.to_vec() } + }, + )); + assert_eq!(manager.servers_for(&cpp, &sketch.join("helper.cpp"))[0].name, "clangd"); + } + + #[test] + fn capabilities_are_sanitized_for_arduino_proxy_quirks() { + let mut client = serde_json::json!({ + "textDocument": { + "semanticTokens": { "dynamicRegistration": false }, + "inlayHint": { "dynamicRegistration": false }, + "completion": { + "completionItem": { + "snippetSupport": true, + "insertReplaceSupport": true + } + } + }, + "workspace": { + "semanticTokens": { "refreshSupport": true }, + "inlayHint": { "refreshSupport": true } + } + }); + configure_client_capabilities(&mut client); + assert!(client["textDocument"].get("semanticTokens").is_none()); + assert!(client["textDocument"].get("inlayHint").is_none()); + assert!(client["textDocument"]["completion"]["completionItem"].get("insertReplaceSupport").is_none()); + assert_eq!(client["textDocument"]["completion"]["completionItem"]["snippetSupport"], true); + assert!(client["workspace"].get("semanticTokens").is_none()); + assert!(client["workspace"].get("inlayHint").is_none()); + + let mut server = serde_json::json!({ + "documentSymbolProvider": {}, + "workspaceSymbolProvider": {}, + "completionProvider": { "triggerCharacters": ["."] }, + "hoverProvider": true + }); + sanitize_server_capabilities(&mut server); + assert!(server.get("documentSymbolProvider").is_none()); + assert!(server.get("workspaceSymbolProvider").is_none()); + assert!(server.get("completionProvider").is_some()); + assert!(server.get("hoverProvider").is_some()); + } +} diff --git a/crates/ide-lsp/src/inspect.rs b/crates/ide-lsp/src/inspect.rs index e8509425b..a58d049d1 100644 --- a/crates/ide-lsp/src/inspect.rs +++ b/crates/ide-lsp/src/inspect.rs @@ -63,6 +63,17 @@ impl LspManager { /// run doesn't prompt to install every language it meets. pub fn serves_path(&self, path: &Path) -> bool { let lang = self.languages.detect(path, None); + let arduino = if lang.as_str() == "arduino" { + Some(self.servers_for(&lang, path)) + } else { + self.arduino_servers(&lang, path) + }; + if let Some(servers) = arduino { + return servers.iter().any(|server| { + let cfg = self.configured(server); + cfg.launch_error.is_none() && self.launcher.available(&cfg, &server.root) + }); + } self.language_servers(&lang).iter().any(|ls| { if ls.require_root_marker && crate::launch::marked_root(&self.project_root, path, &ls.root_markers).is_none() diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 0eaa9467c..30cf477a9 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -7,6 +7,7 @@ //! never `lsp-types`, so the IPC surface stays stable. mod angular; +mod arduino; mod caps; mod clang_tidy; mod convert; @@ -502,10 +503,12 @@ impl LspManager { fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc { let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state()); let mut servers = self.servers.lock(); + let project = self.project_root_for(path); + let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten(); let root = if shared.as_ref().is_some_and(|state| state.shared) { self.project_root.clone() } else { - launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| { + sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| { // Outside the project (library sources, …): reuse a running instance. servers .iter() @@ -530,6 +533,9 @@ impl LspManager { /// only while all servers listed before it are missing. One that requires a /// root marker serves only files below one, and stands in for no other. fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec> { + if let Some(servers) = self.arduino_servers(language, path) { + return servers; + } let mut out = Vec::new(); let mut available_before = false; for sc in &self.language_servers(language) { @@ -775,7 +781,11 @@ impl LspManager { if let Some(info) = result.get("serverInfo") { tracing::info!("{} initialized: {info}", server.name); } - let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({}))); + let mut caps_val = result.get("capabilities").cloned().unwrap_or_else(|| json!({})); + if server.name == arduino::SERVER { + arduino::sanitize_server_capabilities(&mut caps_val); + } + let mut caps = ServerCaps::parse(caps_val); let cfg = server.effective_config(); caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds)); caps @@ -921,6 +931,9 @@ impl LspManager { "capabilities": caps::client_capabilities(), "trace": "off", }); + if server.name == arduino::SERVER { + arduino::configure_client_capabilities(&mut params["capabilities"]); + } if server.name == "gopls" { params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] }); } @@ -1560,6 +1573,7 @@ impl LspManager { let locs = convert::goto_response(&self.conv(t.enc()), r); let locs = library_sources(snap, pos, locs).await; self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); + self.note_arduino_sources(&t.server, &locs); let locs = merge_locations(first, locs); if !locs.is_empty() { return Ok(locs); @@ -1788,6 +1802,7 @@ impl LspManager { .collect(); let locations: Vec = classified.iter().map(|r| r.location.clone()).collect(); self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref())); + self.note_arduino_sources(&t.server, &locations); Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped }) } .await; @@ -1855,6 +1870,7 @@ impl LspManager { locs.retain(|l| !declarations.contains(l)); } self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); + self.note_arduino_sources(&t.server, &locs); acc.locations.extend(locs); acc.incomplete = acc.incomplete.or(w.incomplete); } @@ -3312,6 +3328,7 @@ impl LspManager { .filter(|a| { !wanted.iter().any(|w| w.id == a.server.id) && (configs.iter().any(|c| c.fallback && c.name == a.server.name) + || (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER)) || !configs .iter() .any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key)) diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 527154bcf..b2cfa386e 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -1047,6 +1047,9 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root if name == angular::SERVER { configure_angular(cfg, root, server_root, s); } + if name == crate::arduino::SERVER { + crate::arduino::configure(cfg, root, server_root, s); + } } /// The servers `language` uses after Settings › Language Servers: the diff --git a/crates/ide-sdk/src/arduino.rs b/crates/ide-sdk/src/arduino.rs new file mode 100644 index 000000000..48eca837a --- /dev/null +++ b/crates/ide-sdk/src/arduino.rs @@ -0,0 +1,344 @@ +//! Arduino Language Server tool discovery and launch configuration. +//! Resolves `arduino-language-server`, `arduino-cli` and `clangd` from PATH or Arduino IDE 2. + +use crate::apply::setting_path; +use crate::exec::which; +use ide_core::settings::{ArduinoSettings, Settings}; +use ide_core::{IdeError, IdeResult}; +use ide_language::LanguageServerConfig; +use std::path::{Path, PathBuf}; + +pub const SERVER: &str = "arduino-language-server"; + +fn user_home() -> Option { + if cfg!(windows) { + ide_core::process::host_var_os("USERPROFILE").map(PathBuf::from).or_else(crate::home_dir) + } else { + crate::home_dir() + } +} + +fn ide_resource_dirs() -> Vec { + let mut installs = Vec::new(); + if cfg!(windows) { + if let Some(local) = ide_core::process::host_var_os("LOCALAPPDATA") { + installs.push(PathBuf::from(local).join("Programs/Arduino IDE")); + } + for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] { + if let Some(dir) = ide_core::process::host_var_os(var) { + installs.push(PathBuf::from(dir).join("Arduino IDE")); + } + } + installs.into_iter().map(|p| p.join("resources/app/lib/backend/resources")).collect() + } else if cfg!(target_os = "macos") { + installs.push(PathBuf::from("/Applications/Arduino IDE.app")); + if let Some(home) = user_home() { + installs.push(home.join("Applications/Arduino IDE.app")); + } + installs.into_iter().map(|p| p.join("Contents/Resources/app/lib/backend/resources")).collect() + } else { + vec![PathBuf::from("/opt/arduino-ide/resources/app/lib/backend/resources")] + } +} + +fn resolve_tool(root: &Path, configured: &str, name: &str) -> IdeResult { + let configured = configured.trim(); + if !configured.is_empty() { + let path = setting_path(root, configured).unwrap_or_default(); + if path.is_file() { + return Ok(path); + } + if Path::new(configured).components().count() == 1 + && let Some(path) = which(configured) + { + return Ok(path); + } + return Err(IdeError::unavailable(format!( + "Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino." + ))); + } + which(name) + .or_else(|| ide_resource_dirs().into_iter().map(|p| p.join(crate::exe(name))).find(|p| p.is_file())) + .ok_or_else(|| IdeError::unavailable(format!( + "Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino." + ))) +} + +fn cli_config(root: &Path, configured: &str) -> IdeResult> { + if let Some(path) = setting_path(root, configured) { + if !path.is_file() { + return Err(IdeError::unavailable(format!("Arduino CLI configuration {} does not exist.", path.display()))); + } + return Ok(Some(path)); + } + Ok(user_home().map(|p| p.join(".arduinoIDE/arduino-cli.yaml")).filter(|p| p.is_file())) +} + +fn yaml_scalar(value: &str) -> &str { + let value = value.trim(); + let mut chars = value.char_indices(); + if let Some((_, quote @ ('\'' | '"'))) = chars.next() { + while let Some((index, c)) = chars.next() { + if quote == '"' && c == '\\' { + chars.next(); + } else if c == quote { + if quote == '\'' && chars.as_str().starts_with('\'') { + chars.next(); + } else { + return &value[1..index]; + } + } + } + return &value[1..]; + } + let mut whitespace = true; + for (index, c) in value.char_indices() { + if c == '#' && whitespace { + return value[..index].trim_end(); + } + whitespace = c.is_whitespace(); + } + value +} + +/// Parses `sketch.yaml` without external YAML parser dependencies. +fn yaml_board(text: &str) -> Option { + let mut default_fqbn = None; + let mut default_profile = None; + let mut current_profile: Option = None; + let mut profile_fqbns = std::collections::BTreeMap::new(); + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.is_empty() { + continue; + } + let indent = line.chars().take_while(|c| c.is_whitespace()).count(); + if let Some((k, v)) = trimmed.split_once(':') { + let key = k.trim(); + let val = yaml_scalar(v); + if indent == 0 { + if key == "default_fqbn" && !val.is_empty() { + default_fqbn = Some(val.to_owned()); + } else if key == "default_profile" && !val.is_empty() { + default_profile = Some(val.to_owned()); + } + } else if indent <= 2 { + if key != "profiles" && val.is_empty() { + current_profile = Some(key.to_owned()); + } + } else if key == "fqbn" + && !val.is_empty() + && let Some(p) = ¤t_profile + { + profile_fqbns.insert(p.clone(), val.to_owned()); + } + } + } + if let Some(fqbn) = default_fqbn { + return Some(fqbn); + } + if let Some(prof) = default_profile + && let Some(fqbn) = profile_fqbns.get(&prof) + { + return Some(fqbn.clone()); + } + None +} + +/// Resolves board FQBN from settings, `sketch.yaml`, or standard AVR Uno fallback. +pub fn board(_root: &Path, sketch: &Path, settings: &ArduinoSettings) -> IdeResult { + let configured = settings.fqbn.trim(); + if !configured.is_empty() { + return Ok(configured.to_owned()); + } + let yaml = sketch.join("sketch.yaml"); + if let Ok(text) = std::fs::read_to_string(&yaml) + && let Some(fqbn) = yaml_board(&text) + { + return Ok(fqbn); + } + // Fall back to default board for the language server if not configured, + // so code intelligence functions immediately out of the box. + Ok("arduino:avr:uno".into()) +} + +fn argument<'a>(args: &'a [String], flag: &str) -> IdeResult> { + for (index, arg) in args.iter().enumerate() { + let key = arg.trim_start_matches('-'); + let value = if key == flag { + Some(args.get(index + 1).map(String::as_str).filter(|v| !v.starts_with('-')).unwrap_or("")) + } else { + key.strip_prefix(flag).and_then(|tail| tail.strip_prefix('=')) + }; + if let Some(value) = value { + if value.trim().is_empty() { + return Err(IdeError::invalid(format!("Arduino Language Server argument -{flag} requires a value."))); + } + return Ok(Some(value)); + } + } + Ok(None) +} + +fn set_path_argument(args: &mut [String], flag: &str, path: &Path) { + let value = path.to_string_lossy().into_owned(); + for index in 0..args.len() { + let key = args[index].trim_start_matches('-'); + if key == flag { + args[index + 1] = value; + return; + } + if key.strip_prefix(flag).is_some_and(|tail| tail.starts_with('=')) { + args[index] = format!("-{flag}={value}"); + return; + } + } +} + +fn configure_inner(cfg: &mut LanguageServerConfig, root: &Path, sketch: &Path, settings: &Settings) -> IdeResult<()> { + let arduino = &settings.languages.arduino; + let explicit_server = settings.language_servers.get(SERVER).is_some_and(|s| !s.command.trim().is_empty()); + if !explicit_server && cfg.command == SERVER { + cfg.command = resolve_tool(root, &arduino.language_server, SERVER)?.to_string_lossy().into_owned(); + } else { + cfg.command = resolve_tool(root, &cfg.command, SERVER)?.to_string_lossy().into_owned(); + } + for (flag, configured, executable) in [("cli", &arduino.cli, "arduino-cli"), ("clangd", &arduino.clangd, "clangd")] + { + if let Some(value) = argument(&cfg.args, flag)? { + let path = resolve_tool(sketch, value, executable)?; + set_path_argument(&mut cfg.args, flag, &path); + } else { + let path = resolve_tool(root, configured, executable)?; + cfg.args.extend([format!("-{flag}"), path.to_string_lossy().into_owned()]); + } + } + if let Some(value) = argument(&cfg.args, "cli-config")? { + if let Some(path) = cli_config(sketch, value)? { + set_path_argument(&mut cfg.args, "cli-config", &path); + } + } else if let Some(path) = cli_config(root, &arduino.cli_config)? { + cfg.args.extend(["-cli-config".into(), path.to_string_lossy().into_owned()]); + } + if argument(&cfg.args, "fqbn")?.is_none() { + cfg.args.extend(["-fqbn".into(), board(root, sketch, arduino)?]); + } + Ok(()) +} + +/// Applies discovered Arduino CLI, clangd and board FQBN to the LanguageServerConfig. +pub fn configure(cfg: &mut LanguageServerConfig, root: &Path, sketch: &Path, settings: &Settings) { + if cfg.name != SERVER { + return; + } + if let Err(error) = configure_inner(cfg, root, sketch, settings) { + cfg.launch_error = Some(error.message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_tool_arguments_are_resolved_before_launch() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let settings = Settings::default(); + for name in [SERVER, "arduino-cli", "clangd", "arduino-cli.yaml"] { + std::fs::write(root.join(name), "").unwrap(); + } + let registry = ide_language::LanguageRegistry::load(None).unwrap(); + let language = registry.get(&ide_core::LanguageId::new("arduino")).unwrap(); + for equals in [false, true] { + let mut cfg = language.language_servers[0].clone(); + cfg.command = root.join(SERVER).to_string_lossy().into_owned(); + for (flag, name) in [("cli", "arduino-cli"), ("clangd", "clangd"), ("cli-config", "arduino-cli.yaml")] { + if equals { + cfg.args.push(format!("-{flag}=./{name}")); + } else { + cfg.args.extend([format!("-{flag}"), format!("./{name}")]); + } + } + configure_inner(&mut cfg, root, root, &settings).unwrap(); + for (flag, name) in [("cli", "arduino-cli"), ("clangd", "clangd"), ("cli-config", "arduino-cli.yaml")] { + assert_eq!(Path::new(argument(&cfg.args, flag).unwrap().unwrap()), root.join(name)); + } + } + } + + #[test] + fn parses_sketch_yaml_default_fqbn() { + let yaml = "default_fqbn: arduino:avr:nano\ndefault_port: COM3\n"; + assert_eq!(yaml_board(yaml), Some("arduino:avr:nano".into())); + } + + #[test] + fn sketch_yaml_scalar_comments_preserve_quoted_hashes() { + for (scalar, expected) in [ + ("arduino:avr:uno # board", Some("arduino:avr:uno")), + ("arduino:avr:uno # board", Some("arduino:avr:uno")), + ("arduino:avr:uno\t# board", Some("arduino:avr:uno")), + ("arduino:avr:uno#board", Some("arduino:avr:uno#board")), + ("'arduino:avr:uno # board'", Some("arduino:avr:uno # board")), + ("\"arduino:avr:uno # board\"", Some("arduino:avr:uno # board")), + ("'arduino:avr:uno # board' # comment", Some("arduino:avr:uno # board")), + ("\"arduino:avr:uno # board\" # comment", Some("arduino:avr:uno # board")), + (r#""arduino:avr:uno \" # board" # comment"#, Some(r#"arduino:avr:uno \" # board"#)), + ("'arduino:avr:uno '' # board' # comment", Some("arduino:avr:uno '' # board")), + ("# no board", None), + ] { + assert_eq!(yaml_board(&format!("default_fqbn: {scalar}\n")), expected.map(str::to_owned), "{scalar}"); + } + } + + #[test] + fn sketch_yaml_profile_comments_preserve_selection() { + let yaml = r#" +default_profile: esp32 # selected profile +profiles: + uno: # other profile + fqbn: arduino:avr:uno # other board + esp32: # selected profile + fqbn: esp32:esp32:esp32 # selected board +"#; + assert_eq!(yaml_board(yaml), Some("esp32:esp32:esp32".into())); + let yaml = r#" +default_profile: "esp32#profile" # selected profile +profiles: + esp32#profile: + fqbn: 'esp32:esp32:esp32 # board' # selected board +"#; + assert_eq!(yaml_board(yaml), Some("esp32:esp32:esp32 # board".into())); + } + + #[test] + fn parses_sketch_yaml_profiles() { + let yaml = r#" +default_profile: esp32 +profiles: + uno: + fqbn: arduino:avr:uno + esp32: + fqbn: esp32:esp32:esp32 +"#; + assert_eq!(yaml_board(yaml), Some("esp32:esp32:esp32".into())); + } + + #[test] + fn board_falls_back_to_avr_uno() { + let dir = tempfile::tempdir().unwrap(); + let settings = ArduinoSettings::default(); + let fqbn = board(dir.path(), dir.path(), &settings).unwrap(); + assert_eq!(fqbn, "arduino:avr:uno"); + } + + #[test] + fn board_prefers_settings_fqbn() { + let dir = tempfile::tempdir().unwrap(); + let settings = ArduinoSettings { fqbn: "arduino:samd:mkr1000".into(), ..Default::default() }; + let fqbn = board(dir.path(), dir.path(), &settings).unwrap(); + assert_eq!(fqbn, "arduino:samd:mkr1000"); + } +} diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 90c5cae24..cdec6fd61 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -7,6 +7,7 @@ mod angular; pub mod apply; +pub mod arduino; mod discover; mod exec; pub mod go; diff --git a/languages/arduino.toml b/languages/arduino.toml index a8165a1b4..f4c1faac3 100644 --- a/languages/arduino.toml +++ b/languages/arduino.toml @@ -1,10 +1,29 @@ id = "arduino" name = "Arduino" -# Sketches are C++ with an implicit `Arduino.h` and generated prototypes; clangd can't compile -# them on its own, so they get C++ highlighting without a language server. +# Arduino Language Server supplies Arduino's generated compile context to clangd. extensions = ["ino"] line-comment = "//" block-comment = ["/*", "*/"] indent = { tab-size = 2, insert-spaces = true } ui-mode = "C++" tree-sitter = "cpp" + +[[language-servers]] +name = "arduino-language-server" +command = "arduino-language-server" +root-markers = ["*.ino", "sketch.yaml"] +require-root-marker = true +features = [ + "diagnostics", + "completion", + "hover", + "signature-help", + "definition", + "type-definition", + "implementation", + "document-highlight", + "formatting", + "rename", + "code-action", +] +install-hint = "Install Arduino IDE 2 (includes Arduino Language Server, Arduino CLI and clangd) and the board's core. Configure the server command and arguments in Settings > Languages & Frameworks > Language Servers, or set languages.arduino.cli, languages.arduino.clangd, languages.arduino.languageServer and languages.arduino.cliConfig in settings.toml. Set the board FQBN in sketch.yaml or languages.arduino.fqbn in settings.toml." diff --git a/src/bindings.ts b/src/bindings.ts index 21785d2a3..78d0a23e4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1974,6 +1974,15 @@ export type ApplyOutcome = { conflicts: string[], }; +/** Arduino tool discovery paths and default board FQBN, configured in `settings.toml`. */ +export type ArduinoSettings = { + cli?: string, + clangd?: string, + languageServer?: string, + cliConfig?: string, + fqbn?: string, +}; + /** Packaging artifact definition (Project Structure › Artifacts). */ export type ArtifactConfig = { name?: string, @@ -5501,6 +5510,7 @@ export type LanguagesSettings = { java?: JavaSettings, ruby?: RubySettings, markdown?: MarkdownSettings, + arduino?: ArduinoSettings, }; /** What a freshly opened window should do once its frontend is up. */ diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 9126c779b..945c8e3df 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,7 @@ { + "Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino.": "Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino.", + "Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino.": "Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino.", + "Install Arduino IDE 2 (includes Arduino Language Server, Arduino CLI and clangd) and the board's core. Configure the server command and arguments in Settings > Languages & Frameworks > Language Servers, or set languages.arduino.cli, languages.arduino.clangd, languages.arduino.languageServer and languages.arduino.cliConfig in settings.toml. Set the board FQBN in sketch.yaml or languages.arduino.fqbn in settings.toml.": "Install Arduino IDE 2 (includes Arduino Language Server, Arduino CLI and clangd) and the board's core. Configure the server command and arguments in Settings > Languages & Frameworks > Language Servers, or set languages.arduino.cli, languages.arduino.clangd, languages.arduino.languageServer and languages.arduino.cliConfig in settings.toml. Set the board FQBN in sketch.yaml or languages.arduino.fqbn in settings.toml.", "Separate Procedures and Functions": "Separate Procedures and Functions", "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.", "Share JDT LS across builds": "Share JDT LS across builds", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 37b219a6e..c0e2efa63 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,7 @@ { + "Arduino tool '{configured}' was not found. Set its path in Settings > Languages & Frameworks > Language Servers, or in settings.toml under languages.arduino.": "未找到 Arduino 工具“{configured}”。请在设置 > 语言和框架 > 语言服务器中配置其路径,或在 settings.toml 的 languages.arduino 下配置。", + "Arduino tool '{name}' was not found on PATH or in Arduino IDE. Install Arduino IDE 2, or set its path in Settings > Languages & Frameworks > Language Servers or settings.toml under languages.arduino.": "在 PATH 或 Arduino IDE 中未找到 Arduino 工具“{name}”。请安装 Arduino IDE 2,或在设置 > 语言和框架 > 语言服务器中配置其路径,也可在 settings.toml 的 languages.arduino 下配置。", + "Install Arduino IDE 2 (includes Arduino Language Server, Arduino CLI and clangd) and the board's core. Configure the server command and arguments in Settings > Languages & Frameworks > Language Servers, or set languages.arduino.cli, languages.arduino.clangd, languages.arduino.languageServer and languages.arduino.cliConfig in settings.toml. Set the board FQBN in sketch.yaml or languages.arduino.fqbn in settings.toml.": "请安装 Arduino IDE 2(包含 Arduino Language Server、Arduino CLI 和 clangd)及开发板核心。在设置 > 语言和框架 > 语言服务器中配置服务器命令和参数,或在 settings.toml 中设置 languages.arduino.cli、languages.arduino.clangd、languages.arduino.languageServer 和 languages.arduino.cliConfig。在 sketch.yaml 或 settings.toml 的 languages.arduino.fqbn 中设置开发板 FQBN。", "Separate Procedures and Functions": "分别显示过程和函数", "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "共享 JDT LS 仅适用于本机的普通项目窗口。请将 Java 语言服务器的运行位置设为本机后再使用。", "Share JDT LS across builds": "在构建间共享 JDT LS",