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