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",