дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Порівняти коміти
17
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
50f541fa64 | ||
|
|
27401ad438 | ||
|
|
3ae6bddcc0 | ||
|
|
b6cb416036 | ||
|
|
4126822be8 | ||
|
|
29af5994e3 | ||
|
|
226ac26954 | ||
|
|
658c38a193 | ||
|
|
20be9ff646 | ||
|
|
394e9919c7 | ||
|
|
e839e5dfb7 | ||
|
|
ff17c3753f | ||
|
|
5f970d97d5 | ||
|
|
16bf9b53a2 | ||
|
|
68dd88993b | ||
|
|
b50219b6fa | ||
|
|
7090a8db56 |
No files matched your search
+3
-1
@@ -132,7 +132,9 @@ debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
|
||||
# (Linux arm64 was killed mid-link from v0.1.39 on).
|
||||
lto = "thin"
|
||||
opt-level = 3
|
||||
# Unwind, so a panic fails one task or command instead of quitting the IDE.
|
||||
panic = "unwind"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -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")]
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.4.0";
|
||||
pub const ENGINE_VERSION: &str = "1.5.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
//! Storage policy and best-effort migration of rebuildable desktop data.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
|
||||
/// configuration and roaming data keep following the OS.
|
||||
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
|
||||
|
||||
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
|
||||
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
|
||||
/// debug builds apart under one root. A set but unusable path is an error, never a
|
||||
/// silent fallback to the OS location.
|
||||
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
|
||||
}
|
||||
|
||||
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
|
||||
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
|
||||
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
|
||||
};
|
||||
// The working directory depends on how the IDE was launched.
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
|
||||
}
|
||||
let dir = root.join(identifier);
|
||||
let probe = dir.join(".openidea-write-check");
|
||||
fs::create_dir_all(&dir)
|
||||
.and_then(|()| fs::write(&probe, b""))
|
||||
.and_then(|()| fs::remove_file(&probe))
|
||||
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Unknown entries and recovery/user content deliberately stay in roaming data.
|
||||
pub fn is_local(entry: &Path) -> bool {
|
||||
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
|
||||
@@ -276,6 +306,55 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_dir_follows_the_os_unless_the_variable_is_set() {
|
||||
let os = Some(PathBuf::from("/os-local"));
|
||||
for var in [None, Some(OsString::new())] {
|
||||
assert_eq!(
|
||||
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
|
||||
Path::new("/os-local/ai.l0ng.openidea")
|
||||
);
|
||||
}
|
||||
assert!(resolve_local(None, None, "id").is_err());
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("missing/nested");
|
||||
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
|
||||
assert_eq!(dir, root.join("ai.l0ng.openidea"));
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
|
||||
// Debug worktrees have their own identifiers and stay apart under one root.
|
||||
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_rejects_relative_and_unwritable_paths() {
|
||||
for relative in ["local", "./local", "../local"] {
|
||||
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let blocker = tmp.path().join("file");
|
||||
fs::write(&blocker, "").unwrap();
|
||||
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains("cannot write"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_dir_rejects_an_existing_read_only_directory() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores permission bits.
|
||||
if fs::write(dir.join("probe"), "").is_err() {
|
||||
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
|
||||
}
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
|
||||
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct Reports(RwLock<State>);
|
||||
struct State {
|
||||
windows: HashMap<String, Window>,
|
||||
focused: Option<String>,
|
||||
/// Windows in the order they were last focused, the most recent last.
|
||||
focus_order: Vec<String>,
|
||||
remote: HashMap<String, PathBuf>,
|
||||
libraries: HashMap<String, Vec<PathBuf>>,
|
||||
}
|
||||
@@ -46,8 +48,16 @@ impl Reports {
|
||||
state.windows.entry(window.into()).or_default().focused = editor;
|
||||
if focused {
|
||||
state.focused = Some(window.into());
|
||||
state.focus_order.retain(|w| w != window);
|
||||
state.focus_order.push(window.into());
|
||||
}
|
||||
}
|
||||
/// Sorts `windows` by when they were last focused, the most recent first; windows
|
||||
/// never focused keep their order after them.
|
||||
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
|
||||
let order = &self.0.read().focus_order;
|
||||
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
|
||||
}
|
||||
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
|
||||
let mut state = self.0.write();
|
||||
let problems = &mut state.windows.entry(window.into()).or_default().problems;
|
||||
@@ -68,6 +78,7 @@ impl Reports {
|
||||
state.windows.remove(window);
|
||||
state.remote.remove(window);
|
||||
state.libraries.remove(window);
|
||||
state.focus_order.retain(|w| w != window);
|
||||
if state.focused.as_deref() == Some(window) {
|
||||
state.focused = None;
|
||||
}
|
||||
@@ -122,3 +133,24 @@ impl Reports {
|
||||
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_sort_by_their_last_focus() {
|
||||
let reports = Reports::default();
|
||||
for window in ["a", "b", "a", "c"] {
|
||||
reports.set_focused_editor(window, None, true);
|
||||
}
|
||||
reports.set_focused_editor("b", None, false);
|
||||
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["c", "a", "b", "never"]);
|
||||
reports.forget_window("c");
|
||||
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["a", "b", "never", "c"]);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
|
||||
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
|
||||
}
|
||||
|
||||
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
|
||||
}
|
||||
@@ -167,6 +172,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
|
||||
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
|
||||
|
||||
@@ -110,6 +110,10 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn trusted(&self, root: &Path) -> bool;
|
||||
/// Root of the project open in `window`.
|
||||
fn project_of(&self, window: &str) -> Option<PathBuf>;
|
||||
/// Windows showing the open project at `root`, the most recently focused first.
|
||||
fn project_windows(&self, _root: &Path) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
|
||||
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
|
||||
-> Result<(), HostError>;
|
||||
@@ -1758,17 +1762,31 @@ impl HostApi for PluginApi {
|
||||
body: String,
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
if !actions.is_empty() {
|
||||
let commands = self.shared.ui.items("commands", Some(self.id()));
|
||||
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
|
||||
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
|
||||
}
|
||||
}
|
||||
check_actions(self, &actions)?;
|
||||
let plugin_id = self.id().to_owned();
|
||||
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn notify_project(
|
||||
&self,
|
||||
root: &Path,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
check_actions(self, &actions)?;
|
||||
let windows = self.shared.workspace().map(|w| w.project_windows(root)).unwrap_or_default();
|
||||
let window = windows
|
||||
.into_iter()
|
||||
.find(|w| !self.shared.client_closed(w))
|
||||
.ok_or_else(|| HostError::NotFound(format!("the project {} is not open", root.display())))?;
|
||||
let plugin_id = self.id().to_owned();
|
||||
self.shared.sink.emit(Some(&window), PluginEvent::Notification { plugin_id, level, title, body, actions });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "copy to the clipboard")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
|
||||
@@ -2718,6 +2736,15 @@ fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification buttons must name commands the plugin declares.
|
||||
fn check_actions(api: &PluginApi, actions: &[ide_plugin::NotificationAction]) -> Result<(), HostError> {
|
||||
let commands = if actions.is_empty() { Vec::new() } else { api.shared.ui.items("commands", Some(api.id())) };
|
||||
match actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
|
||||
Some(a) => Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PluginWorkspace`] over the desktop host's open projects.
|
||||
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
|
||||
|
||||
@@ -3016,6 +3043,13 @@ impl PluginWorkspace for HostWorkspace {
|
||||
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
|
||||
}
|
||||
|
||||
fn project_windows(&self, root: &Path) -> Vec<String> {
|
||||
let Some(host) = self.0.upgrade() else { return Vec::new() };
|
||||
let mut windows = host.windows_for_root(root);
|
||||
host.mcp_reports.most_recently_focused_first(&mut windows);
|
||||
windows
|
||||
}
|
||||
|
||||
fn publish_diagnostics(
|
||||
&self,
|
||||
source: &str,
|
||||
|
||||
@@ -290,6 +290,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
| "find_files"
|
||||
| "find_files_named"
|
||||
| "find_dirs"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
|
||||
@@ -126,6 +126,10 @@ impl PluginWorkspace for TestWorkspace {
|
||||
fn trusted(&self, _root: &Path) -> bool {
|
||||
self.trusted
|
||||
}
|
||||
fn project_windows(&self, root: &Path) -> Vec<String> {
|
||||
// `main` shows the fixture project; other open roots have no window left.
|
||||
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
|
||||
}
|
||||
fn project_of(&self, _window: &str) -> Option<PathBuf> {
|
||||
if let Some(left) = self.project_queries_left.lock().as_mut() {
|
||||
if *left == 0 {
|
||||
@@ -4801,6 +4805,40 @@ fn notification_actions_keep_commands_and_window_scope() {
|
||||
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_notifications_go_to_a_window_of_that_project_only() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.host.set_capability("jobs", "project.read", false).unwrap();
|
||||
f.host.forget_client("closing");
|
||||
let other = f.project.parent().unwrap().join("other");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
f.workspace.other_roots.lock().push(other.clone());
|
||||
f.take_events();
|
||||
let notifications = |f: &Fixture| {
|
||||
f.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, event)| match event {
|
||||
PluginEvent::Notification { title, actions, .. } => Some((client, title, actions)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// A scheduled call has no invoking window; a closing window is skipped.
|
||||
let path = f.project.join("notes.txt").to_string_lossy().into_owned();
|
||||
f.host.run_command("jobs", "notify-project", Some(&path), Invocation::default()).unwrap();
|
||||
let sent = notifications(&f);
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!((sent[0].0.as_deref(), sent[0].1.as_str()), (Some("main"), "Project update"));
|
||||
assert_eq!((sent[0].2[0].command.as_str(), sent[0].2[0].argument.as_deref()), ("context", Some(path.as_str())));
|
||||
// A project without a window fails without falling back to another one, even when invoked from one.
|
||||
let path = other.join("notes.txt").to_string_lossy().into_owned();
|
||||
let error = f.host.run_command("jobs", "notify-project", Some(&path), f.inv()).unwrap_err().message;
|
||||
assert!(error.contains("not open"), "{error}");
|
||||
assert!(notifications(&f).is_empty());
|
||||
assert_eq!(f.status("jobs"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
|
||||
use ide_plugin::Prompt;
|
||||
|
||||
@@ -234,6 +234,15 @@ impl ProjectIndex {
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
}
|
||||
|
||||
/// All indexed files with this exact basename, for resolving printed source locations.
|
||||
/// Unlike interactive fuzzy search, this must not truncate candidates before package matching.
|
||||
pub fn files_named(&self, name: &str) -> Vec<PathBuf> {
|
||||
let data = self.shared.data.read();
|
||||
let mut files: Vec<_> = data.files.iter().flatten().filter(|file| file.name() == name).collect();
|
||||
files.sort_by(|a, b| a.rel.len().cmp(&b.rel.len()).then_with(|| a.rel.cmp(&b.rel)));
|
||||
files.into_iter().map(|file| file.path.clone()).collect()
|
||||
}
|
||||
|
||||
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
|
||||
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
|
||||
/// indexed yet (first scan without a cache).
|
||||
|
||||
@@ -57,6 +57,27 @@ fn files(idx: &ProjectIndex, q: &str) -> Vec<String> {
|
||||
idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_file_names_keep_all_source_candidates() {
|
||||
let f = Fixture::new(&[
|
||||
("src/OtherOrders.kt", "package other"),
|
||||
("src/Orders.kt.bak", "backup"),
|
||||
("target/Orders.kt", "excluded"),
|
||||
("src/orders.kt", "different case"),
|
||||
]);
|
||||
for i in 0..65 {
|
||||
f.write(&format!("module{i}/Orders.kt"), &format!("package module{i}"));
|
||||
}
|
||||
let source = f.write("service/src/Orders.kt", "package com.example.orders");
|
||||
let idx = f.open();
|
||||
let candidates = idx.files_named("Orders.kt");
|
||||
assert_eq!(candidates.len(), 66);
|
||||
assert!(candidates.contains(&source));
|
||||
assert!(candidates.iter().all(|path| path.file_name().unwrap() == "Orders.kt"));
|
||||
assert_eq!(candidates, idx.files_named("Orders.kt"));
|
||||
assert!(idx.files_named("Missing.kt").is_empty());
|
||||
}
|
||||
|
||||
fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec<String> {
|
||||
idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect()
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ 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::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
|
||||
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
|
||||
WebReferenceKind, merge_visibility, set_external_syntax,
|
||||
WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
@@ -332,3 +332,15 @@ pub struct SymbolTag {
|
||||
pub container: Option<String>,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
/// One inline TS/JS `<script>` block of a Vue SFC / HTML document.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ScriptRegion {
|
||||
/// Document-absolute UTF-8 byte offset of `text`, for tree-sitter parsing.
|
||||
/// Convert to UTF-16 positions before exposing ranges through IPC.
|
||||
pub base: usize,
|
||||
/// The language the block's `lang` attribute picks.
|
||||
pub language: LanguageId,
|
||||
/// The script text.
|
||||
pub text: String,
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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`).
|
||||
|
||||
@@ -195,8 +195,16 @@ impl SyntaxCache {
|
||||
|| {
|
||||
self.with_tree(doc, lang, version, text, |g, tree, text| {
|
||||
let query = g.outline_query()?;
|
||||
Some(outline::outline(g, query, tree, text))
|
||||
// HTML documents answer from the cached tree, with nested
|
||||
// <script> declarations.
|
||||
Some(outline::outline_nested(g, query, tree, text, &|rl, rt| self.syntax.outline(rl, rt)))
|
||||
})
|
||||
// Web containers (Vue SFC / Svelte / JSP) have no grammar of their
|
||||
// own, hence no cached tree: the syntax layer answers with a fresh
|
||||
// HTML parse, which the memo covers per version.
|
||||
// shortcut: no incremental re-parse for containers, upgrade when
|
||||
// their trees join the cache.
|
||||
.or_else(|| self.syntax.web_outline(lang, text))
|
||||
.or_else(|| super::external::outline(lang, text))
|
||||
},
|
||||
)
|
||||
@@ -256,8 +264,11 @@ impl SyntaxCache {
|
||||
let Some(result) = compute() else { return Vec::new() };
|
||||
if let Some(e) = &entry {
|
||||
let mut e = e.lock();
|
||||
// Not if the document moved on (or its tree was dropped) meanwhile.
|
||||
if e.version == version && e.tree.is_some() {
|
||||
// Containers without their own grammar memoize their web outline too.
|
||||
// Never store an answer after the document moves on.
|
||||
if e.version == version
|
||||
&& (e.tree.is_some() || (e.lang == *lang && self.syntax.web_grammar_name(lang) == Some("html")))
|
||||
{
|
||||
*slot(e.memo.at(version, lang)) = Some(Arc::new(result.clone()));
|
||||
}
|
||||
}
|
||||
@@ -474,6 +485,35 @@ mod tests {
|
||||
assert!(cache.outline(id, &LanguageId::new("plaintext"), 1, &text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_outline_memo_tracks_versions_without_a_cached_tree() {
|
||||
for language in ["html", "vue", "svelte", "jsp"] {
|
||||
let cache = cache();
|
||||
let id = DocId::next();
|
||||
let lang = LanguageId::new(language);
|
||||
let mut doc = Document::new("<script setup lang=ts>const first = 1;</script>");
|
||||
cache.open(id, &lang, 0);
|
||||
let first = cache.outline(id, &lang, 0, &doc.text());
|
||||
let hits = cache.stats().hits;
|
||||
assert_eq!(cache.outline(id, &lang, 0, &doc.text()), first);
|
||||
assert_eq!(cache.stats().hits, hits + 1, "{language}: memo hit");
|
||||
|
||||
let edits = edit(&mut doc, 0, 0, "<script>const second = 2;</script>\n");
|
||||
cache.edit(id, doc.version(), &edits);
|
||||
let second = cache.outline(id, &lang, doc.version(), &doc.text());
|
||||
assert_ne!(second, first);
|
||||
assert_eq!(second, cache.syntax().outline(&lang, &doc.text()));
|
||||
// An older query must not replace the current version's memo.
|
||||
cache.outline(id, &lang, 0, "<script>const old = 0;</script>");
|
||||
let hits = cache.stats().hits;
|
||||
assert_eq!(cache.outline(id, &lang, doc.version(), &doc.text()), second);
|
||||
assert_eq!(cache.stats().hits, hits + 1);
|
||||
cache.clear();
|
||||
assert!(cache.entries.lock().get(&id).unwrap().lock().memo.outline.is_none());
|
||||
cache.close(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_and_lifecycle() {
|
||||
let cache = cache();
|
||||
|
||||
@@ -96,6 +96,19 @@ pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
|
||||
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
|
||||
}
|
||||
|
||||
/// Shifts a position from a document suffix that starts at `base` into document
|
||||
/// coordinates: the suffix's first line continues `base`'s line, later lines
|
||||
/// add the base line. Used to map inline `<script>` region answers (Vue SFC /
|
||||
/// HTML) back onto the whole document; positions are 0-based lines / UTF-16 cols.
|
||||
pub fn shift_pos(pos: Pos, base: Pos) -> Pos {
|
||||
if pos.line == 0 { Pos::new(base.line, base.col + pos.col) } else { Pos::new(base.line + pos.line, pos.col) }
|
||||
}
|
||||
|
||||
/// [`shift_pos`] for both ends of a range.
|
||||
pub fn shift_range(range: Range, base: Pos) -> Range {
|
||||
Range::new(shift_pos(range.start, base), shift_pos(range.end, base))
|
||||
}
|
||||
|
||||
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
|
||||
let mut units = 0;
|
||||
for (i, c) in s.char_indices() {
|
||||
@@ -160,6 +173,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifts_suffix_positions_into_document_coordinates() {
|
||||
let base = Pos::new(3, 7);
|
||||
assert_eq!(shift_pos(Pos::new(0, 2), base), Pos::new(3, 9));
|
||||
assert_eq!(shift_pos(Pos::new(0, 0), base), base);
|
||||
assert_eq!(shift_pos(Pos::new(2, 1), base), Pos::new(5, 1));
|
||||
assert_eq!(
|
||||
shift_range(Range::new(Pos::new(0, 1), Pos::new(1, 4)), base),
|
||||
Range::new(Pos::new(3, 8), Pos::new(4, 4))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_minified_line_is_linear() {
|
||||
// 200k symbols-worth of positions on one 4 MB line.
|
||||
|
||||
@@ -10,15 +10,17 @@ pub(crate) mod lines;
|
||||
mod literals;
|
||||
mod outline;
|
||||
mod selection;
|
||||
mod sfc;
|
||||
mod web;
|
||||
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
|
||||
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
|
||||
pub use configuration::ConfigurationKey;
|
||||
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
|
||||
use grammars::{SPECS, Spec};
|
||||
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, Range};
|
||||
pub use lines::{shift_pos, shift_range};
|
||||
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
|
||||
pub use outline::merge_visibility;
|
||||
use std::cell::RefCell;
|
||||
@@ -165,6 +167,12 @@ impl Syntax {
|
||||
}
|
||||
|
||||
pub fn outline(&self, lang: &LanguageId, text: &str) -> Vec<OutlineItem> {
|
||||
// HTML containers (Vue SFC / Svelte / JSP have no grammar of their own;
|
||||
// HTML itself parses alike): HTML outline with each inline `<script>`'s
|
||||
// TS/JS declarations nested under its entry, in document coordinates.
|
||||
if let Some(items) = self.web_outline(lang, text) {
|
||||
return items;
|
||||
}
|
||||
self.analyze(lang, text, |g, tree, text| {
|
||||
let query = g.outline_query()?;
|
||||
Some(outline::outline(g, query, tree, text))
|
||||
@@ -172,6 +180,20 @@ impl Syntax {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Outline of an HTML container document (html, vue, svelte, jsp): `None`
|
||||
/// for every other language, CSS containers included.
|
||||
pub(crate) fn web_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
|
||||
let g = self.web_grammar(lang)?;
|
||||
if g.spec.name != "html" || text.len() > MAX_PARSE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let tree = parse(&g.language, text, None)?;
|
||||
let query = g.outline_query()?;
|
||||
Some(outline::outline_nested(g, query, &tree, text, &|region_lang, region_text| {
|
||||
self.outline(region_lang, region_text)
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
|
||||
let tokens = self.comment_tokens(lang);
|
||||
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
|
||||
@@ -248,6 +270,19 @@ impl Syntax {
|
||||
self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Inline TS/JS `<script>` regions of a Vue SFC / HTML document, in
|
||||
/// document order. Empty for other languages or non-JavaScript blocks.
|
||||
pub fn script_regions(&self, lang: &LanguageId, text: &str) -> Vec<ScriptRegion> {
|
||||
self.with_web_tree(lang, text, |_g, _css, tree| sfc::script_regions(tree, text)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`Self::script_regions`] of an already parsed document (`ParsedTree`
|
||||
/// lends the tree and text) — the cache-aware way for other crates, which
|
||||
/// reuses a cached incremental HTML tree instead of parsing again.
|
||||
pub fn script_regions_of(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
|
||||
sfc::script_regions(tree, text)
|
||||
}
|
||||
|
||||
/// Highlighting from the grammar's own query; `None` without one.
|
||||
pub fn highlights(&self, lang: &LanguageId, text: &str) -> Option<SyntaxHighlights> {
|
||||
self.analyze(lang, text, |g, tree, text| {
|
||||
@@ -280,6 +315,13 @@ impl Syntax {
|
||||
self.grammar(lang).map(|g| g.spec.name)
|
||||
}
|
||||
|
||||
/// Name of the grammar web features parse `lang` with (html for Vue SFC /
|
||||
/// Svelte / JSP, css for SCSS / Less); `None` when `lang` has its own
|
||||
/// grammar or no web features. Lets other crates recognize containers.
|
||||
pub fn web_grammar_name(&self, lang: &LanguageId) -> Option<&'static str> {
|
||||
self.web_grammar(lang).map(|g| g.spec.name)
|
||||
}
|
||||
|
||||
/// Grammar name and tree-sitter language for `lang`, for engines that parse
|
||||
/// on their own (structural search patterns).
|
||||
pub fn ts_language(&self, lang: &LanguageId) -> Option<(&'static str, Language)> {
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
//! macro event operator key heading impl extension.
|
||||
|
||||
use super::Grammar;
|
||||
use super::lines::LineIndex;
|
||||
use super::lines::{LineIndex, shift_range};
|
||||
use crate::SymbolTag;
|
||||
use ide_core::{OutlineItem, SymbolKind, Visibility};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, SymbolKind, Visibility};
|
||||
use std::collections::HashMap;
|
||||
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
|
||||
|
||||
@@ -296,6 +296,69 @@ pub(crate) fn outline(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) ->
|
||||
collect(g, q, tree, text).into_iter().map(convert).collect()
|
||||
}
|
||||
|
||||
/// [`outline`], plus, for the HTML grammar, each inline `<script>` region's
|
||||
/// TS/JS declarations nested under its `script` entry and shifted to document
|
||||
/// coordinates (Vue SFC / HTML documents). `block` outlines one region's
|
||||
/// language, answering in region-local coordinates.
|
||||
pub(crate) fn outline_nested(
|
||||
g: &Grammar,
|
||||
q: &OutlineQuery,
|
||||
tree: &Tree,
|
||||
text: &str,
|
||||
block: &impl Fn(&LanguageId, &str) -> Vec<OutlineItem>,
|
||||
) -> Vec<OutlineItem> {
|
||||
let mut items = outline(g, q, tree, text);
|
||||
if g.spec.name != "html" {
|
||||
return items;
|
||||
}
|
||||
let lines = LineIndex::new(text);
|
||||
for region in super::sfc::script_regions(tree, text) {
|
||||
let mut decls = block(®ion.language, ®ion.text);
|
||||
if decls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let base = lines.pos(region.base);
|
||||
shift_outline(&mut decls, base);
|
||||
if !attach_script(&mut items, base, &mut Some(decls)) {
|
||||
// The region and the entry come from the same `script_element` node,
|
||||
// so this means the outline query no longer matches script elements.
|
||||
tracing::debug!("outline: no script entry for the <script> region at byte {}", region.base);
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Recursively shifts region-local positions in `items` to document coordinates.
|
||||
fn shift_outline(items: &mut [OutlineItem], base: Pos) {
|
||||
for item in items {
|
||||
item.range = shift_range(item.range, base);
|
||||
item.selection_range = shift_range(item.selection_range, base);
|
||||
shift_outline(&mut item.children, base);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nests `decls` under the innermost `script` entry covering `base`. The
|
||||
/// region and the entry both come from the same `script_element` node, so the
|
||||
/// entry always exists; a miss means the outline query no longer matches it.
|
||||
fn attach_script(items: &mut [OutlineItem], base: Pos, decls: &mut Option<Vec<OutlineItem>>) -> bool {
|
||||
let covers = |range: &ide_core::Range| {
|
||||
(range.start.line, range.start.col) <= (base.line, base.col)
|
||||
&& (base.line, base.col) < (range.end.line, range.end.col)
|
||||
};
|
||||
for item in items.iter_mut() {
|
||||
if item.name.eq_ignore_ascii_case("script") && covers(&item.range) {
|
||||
item.children.extend(decls.take().unwrap_or_default());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for item in items.iter_mut() {
|
||||
if attach_script(&mut item.children, base, decls) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn tags(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<SymbolTag> {
|
||||
fn walk(items: Vec<Built>, container: Option<&str>, out: &mut Vec<SymbolTag>) {
|
||||
for b in items {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Inline TS/JS `<script>` regions located with the HTML grammar's
|
||||
//! `script_element` nodes. The block's `lang` attribute selects the grammar.
|
||||
//!
|
||||
//! Blocks whose `type` attribute names anything but JavaScript are skipped
|
||||
//! (`application/json`, `text/template`, `importmap`, …), as are unknown
|
||||
//! `lang` values.
|
||||
|
||||
use super::web::walk;
|
||||
use crate::ScriptRegion;
|
||||
use ide_core::LanguageId;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
/// The TS/JS grammar a `lang` attribute value names; `None` skips the block.
|
||||
fn lang_grammar(value: &str) -> Option<&'static str> {
|
||||
match value {
|
||||
"ts" | "typescript" => Some("typescript"),
|
||||
"tsx" => Some("tsx"),
|
||||
"" | "js" | "jsx" | "javascript" => Some("javascript"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when a `type` attribute value names JavaScript (or is absent).
|
||||
fn is_js_type(value: &str) -> bool {
|
||||
matches!(value, "" | "module" | "text/javascript" | "application/javascript")
|
||||
}
|
||||
|
||||
/// The `<script>` regions of a document parsed with the HTML grammar,
|
||||
/// in document order. `base` is each block's byte offset into the document.
|
||||
pub(crate) fn script_regions(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
|
||||
let mut out = Vec::new();
|
||||
walk(tree.root_node(), &mut |n| {
|
||||
if n.kind() != "script_element" {
|
||||
return true;
|
||||
}
|
||||
let mut lang = "javascript";
|
||||
let mut skip = false;
|
||||
let mut c = n.walk();
|
||||
for ch in n.children(&mut c) {
|
||||
match ch.kind() {
|
||||
// The start tag precedes the raw text, so the attributes are
|
||||
// known before the region is taken.
|
||||
"start_tag" => {
|
||||
let mut c2 = ch.walk();
|
||||
for attr in ch.children(&mut c2) {
|
||||
if attr.kind() != "attribute" {
|
||||
continue;
|
||||
}
|
||||
let (name, value) = attribute(attr, text);
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"lang" => match lang_grammar(&value.to_ascii_lowercase()) {
|
||||
Some(g) => lang = g,
|
||||
None => skip = true,
|
||||
},
|
||||
"type" if !is_js_type(&value.trim().to_ascii_lowercase()) => skip = true,
|
||||
"src" => skip = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"raw_text" if !skip => {
|
||||
out.push(ScriptRegion {
|
||||
base: ch.start_byte(),
|
||||
language: LanguageId::new(lang),
|
||||
text: text[ch.start_byte()..ch.end_byte()].to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false // the raw text is not markup
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// An attribute's `(name, value)`; a bare attribute (`setup`) has an empty value.
|
||||
fn attribute<'t>(attr: Node<'t>, text: &'t str) -> (&'t str, &'t str) {
|
||||
let mut c = attr.walk();
|
||||
let mut name = "";
|
||||
let mut value = "";
|
||||
for ch in attr.children(&mut c) {
|
||||
match ch.kind() {
|
||||
"attribute_name" => name = ch.utf8_text(text.as_bytes()).unwrap_or(""),
|
||||
"quoted_attribute_value" => {
|
||||
value = ch.named_child(0).and_then(|v| v.utf8_text(text.as_bytes()).ok()).unwrap_or("");
|
||||
}
|
||||
"attribute_value" => value = ch.utf8_text(text.as_bytes()).unwrap_or(""),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(name, value)
|
||||
}
|
||||
@@ -759,6 +759,7 @@ fn outline_html_css_xml() {
|
||||
Field body
|
||||
Field div
|
||||
Field script
|
||||
Variable a
|
||||
",
|
||||
);
|
||||
check_outline(
|
||||
@@ -1409,3 +1410,231 @@ fn spelling_regions_cover_declared_names_only() {
|
||||
.collect();
|
||||
assert_eq!(names, ["Bird", "fly", "text"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Script regions of Vue SFCs and HTML documents
|
||||
|
||||
#[test]
|
||||
fn script_regions_skip_typed_blocks() {
|
||||
let src = "<html><body><script type=\"module\">var x = 1;</script>\
|
||||
<script type=\"application/json\">{\"a\":1}</script>\
|
||||
<script type=\"importmap\">{}</script>\
|
||||
<script>var y = 2;</script></body></html>";
|
||||
let regions = syntax().script_regions(&lang("html"), src);
|
||||
assert_eq!(regions.len(), 2, "module and plain scripts; json/importmap skipped");
|
||||
assert_eq!(regions[0].language.as_str(), "javascript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_pick_the_lang_attribute() {
|
||||
let src = "<script setup lang=\"tsx\">\nconst App = () => <div/>\n</script>\n";
|
||||
let regions = syntax().script_regions(&lang("vue"), src);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].language.as_str(), "tsx");
|
||||
assert!(regions[0].text.contains("const App"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_empty_for_other_languages() {
|
||||
assert!(syntax().script_regions(&lang("rust"), "fn main() {}").is_empty());
|
||||
assert!(syntax().script_regions(&lang("vue"), "<template><div/></template>").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_classify_attributes() {
|
||||
for language in ["vue", "html"] {
|
||||
for (attributes, expected) in [
|
||||
("", Some("javascript")),
|
||||
("setup lang=ts", Some("typescript")),
|
||||
("lang='typescript'", Some("typescript")),
|
||||
("lang=tsx", Some("tsx")),
|
||||
("lang=js", Some("javascript")),
|
||||
("lang=jsx", Some("javascript")),
|
||||
("LANG=TS", Some("typescript")),
|
||||
("type=module", Some("javascript")),
|
||||
("TYPE=' text/JAVASCRIPT '", Some("javascript")),
|
||||
("type=application/javascript", Some("javascript")),
|
||||
("type=application/json", None),
|
||||
("type='application/ld+json'", None),
|
||||
("type=text/template", None),
|
||||
("type=importmap", None),
|
||||
("type=speculationrules", None),
|
||||
("lang=coffee", None),
|
||||
("lang=ts type=application/json", None),
|
||||
("src='./external.ts' lang=ts", None),
|
||||
("SRC=external.js", None),
|
||||
("src", None),
|
||||
] {
|
||||
let src = format!("<script {attributes}>const value = 1;</script>");
|
||||
let regions = syntax().script_regions(&lang(language), &src);
|
||||
assert_eq!(regions.len(), usize::from(expected.is_some()), "{language}: {attributes}");
|
||||
if let Some(expected) = expected {
|
||||
assert_eq!(regions[0].language.as_str(), expected, "{attributes}");
|
||||
assert_eq!(regions[0].text, "const value = 1;");
|
||||
assert_eq!(&src[regions[0].base..regions[0].base + regions[0].text.len()], regions[0].text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_preserve_document_offsets_and_line_endings() {
|
||||
use super::lines::LineIndex;
|
||||
|
||||
let src = "<template>中文😀</template>\r\n<script lang=ts>const 名 = '😀';\r\n</script>\r\n<script setup lang=jsx>const View = () => <div/>;</script>";
|
||||
let regions = syntax().script_regions(&lang("vue"), src);
|
||||
assert_eq!(regions.len(), 2);
|
||||
let idx = LineIndex::new(src);
|
||||
let doc = ide_text::Document::new(src);
|
||||
for (region, (body, pos)) in
|
||||
regions.iter().zip([("const 名 = '😀';\r\n", Pos::new(1, 16)), ("const View = () => <div/>;", Pos::new(3, 23))])
|
||||
{
|
||||
assert_eq!(region.base, src.find(body).unwrap());
|
||||
assert_eq!(region.text, body);
|
||||
assert_eq!(idx.pos(region.base), pos);
|
||||
assert_eq!(idx.pos(region.base), doc.byte_to_pos(src[..region.base].replace("\r\n", "\n").len()));
|
||||
assert_eq!(
|
||||
idx.pos(region.base + body.len()),
|
||||
doc.byte_to_pos(src[..region.base + body.len()].replace("\r\n", "\n").len())
|
||||
);
|
||||
if let Some(name) = body.find('名') {
|
||||
assert_eq!(
|
||||
idx.range(region.base + name, region.base + name + '名'.len_utf8()),
|
||||
Range::new(Pos::new(1, 22), Pos::new(1, 23))
|
||||
);
|
||||
}
|
||||
}
|
||||
let same_line = "<template>中文😀</template><script>let x = 1;</script>";
|
||||
let region = &syntax().script_regions(&lang("html"), same_line)[0];
|
||||
assert_eq!(LineIndex::new(same_line).pos(region.base), Pos::new(0, 33));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_tolerate_incomplete_markup() {
|
||||
for src in [
|
||||
"<script",
|
||||
"<script lang=",
|
||||
"<script lang='ts",
|
||||
"<script>const unfinished =",
|
||||
"<template><div><script lang=ts>const x = 1;</script>",
|
||||
"<script></script>",
|
||||
"<script src='missing.js'>ignored</script><script>const x = 1;",
|
||||
] {
|
||||
for region in syntax().script_regions(&lang("vue"), src) {
|
||||
assert_eq!(src.get(region.base..region.base + region.text.len()), Some(region.text.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_large_vue_document() {
|
||||
for count in [2_000, 4_000, 8_000] {
|
||||
let src =
|
||||
"<template><div>中文😀</div></template>\r\n<script setup lang=ts>const x = 1;</script>\r\n".repeat(count);
|
||||
let start = std::time::Instant::now();
|
||||
let regions = syntax().script_regions(&lang("vue"), &src);
|
||||
eprintln!("script regions: {} bytes, {count} blocks: {:?}", src.len(), start.elapsed());
|
||||
assert_eq!(regions.len(), count);
|
||||
assert!(regions.windows(2).all(|pair| pair[0].base < pair[1].base));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_tolerate_deep_markup() {
|
||||
let src = "<div>".repeat(1_000) + "<script>const x = 1;</script>";
|
||||
let regions = syntax().script_regions(&lang("html"), &src);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].base, src.find("const").unwrap());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outline with nested <script> declarations (Vue SFC / HTML)
|
||||
|
||||
#[test]
|
||||
fn html_outline_nests_script_declarations() {
|
||||
let src = "<html><body>\n<script>\nfunction boot() {}\nconst app = 1;\n</script>\n\
|
||||
<script type=\"application/json\">{\"a\": 1}</script>\n</body></html>";
|
||||
check_outline(
|
||||
"html",
|
||||
src,
|
||||
"
|
||||
Field html
|
||||
Field body
|
||||
Field script
|
||||
Function boot
|
||||
Constant app
|
||||
Field script
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vue_outline_nests_script_declarations_without_a_grammar() {
|
||||
let src = "<template>\n <div class=\"app\"/>\n</template>\n\
|
||||
<script setup lang=\"ts\">\ninterface Props { size: number }\nfunction scale(n: number) {}\n</script>\n\
|
||||
<style scoped>\n.app { color: red }\n</style>";
|
||||
check_outline(
|
||||
"vue",
|
||||
src,
|
||||
"
|
||||
Field template
|
||||
Field script
|
||||
Interface Props
|
||||
Property size
|
||||
Function scale
|
||||
Field style
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_outline_positions_are_document_coordinates() {
|
||||
use super::lines::LineIndex;
|
||||
let src = "<template><!-- 😀 --></template>\r\n<script lang=\"ts\">\r\nconst answer = 42;\r\n</script>";
|
||||
let outline = syntax().outline(&lang("vue"), src);
|
||||
let decl = &outline.iter().find(|i| i.name == "script").unwrap().children[0];
|
||||
// Cross-check against the document's own byte -> UTF-16 conversion.
|
||||
let idx = LineIndex::new(src);
|
||||
let at = src.find("answer").unwrap();
|
||||
assert_eq!(decl.selection_range, idx.range(at, at + "answer".len()));
|
||||
// `@def.constant` captures the declarator (`answer = 42`), not the `const` keyword.
|
||||
assert_eq!(decl.range, idx.range(at, src.find("42;").unwrap() + 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_outline_matches_syntax_outline_for_web_documents() {
|
||||
use ide_core::DocId;
|
||||
use std::sync::Arc;
|
||||
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
|
||||
let id = DocId::next();
|
||||
let src = "<template><div/></template>\n<script lang=\"ts\">\nconst app = 1;\n</script>\n";
|
||||
cache.open(id, &lang("vue"), 0);
|
||||
assert_eq!(cache.outline(id, &lang("vue"), 0, src), syntax().outline(&lang("vue"), src));
|
||||
cache.open(id, &lang("html"), 0);
|
||||
assert_eq!(cache.outline(id, &lang("html"), 0, src), syntax().outline(&lang("html"), src));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_outlines_compose_positions_across_multiple_blocks() {
|
||||
use super::lines::LineIndex;
|
||||
|
||||
let src = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { return 1; }</script>\r\n<script setup lang=ts>/* 中文😀 */ const second = 2;\r\nfunction third() {}\r\n</script>";
|
||||
let lines = LineIndex::new(src);
|
||||
for id in ["html", "vue", "svelte", "jsp"] {
|
||||
let items = syntax().outline(&lang(id), src);
|
||||
let scripts: Vec<_> = items.iter().filter(|item| item.name == "script").collect();
|
||||
assert_eq!(scripts.len(), 2, "{id}");
|
||||
assert_eq!(scripts[0].children.len(), 1);
|
||||
assert_eq!(scripts[1].children.len(), 2);
|
||||
for (item, name) in scripts.iter().flat_map(|s| &s.children).zip(["first", "second", "third"]) {
|
||||
let byte = src.find(name).unwrap();
|
||||
assert_eq!(item.name, name);
|
||||
assert_eq!(item.selection_range, lines.range(byte, byte + name.len()), "{id}: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_outline_attaches_to_case_insensitive_html_tags() {
|
||||
check_outline("html", "<SCRIPT>function boot() {}</SCRIPT>", "Field SCRIPT\n Function boot");
|
||||
}
|
||||
@@ -81,13 +81,20 @@ fn css_regions<'t>(grammar: &str, tree: &Tree, text: &'t str) -> Vec<(usize, &'t
|
||||
}
|
||||
|
||||
/// Pre-order walk; `f` returns whether to descend.
|
||||
fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
|
||||
if !f(n) {
|
||||
return;
|
||||
}
|
||||
let mut c = n.walk();
|
||||
for child in n.children(&mut c) {
|
||||
walk(child, f);
|
||||
pub(crate) fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
|
||||
let mut cursor = n.walk();
|
||||
loop {
|
||||
if f(cursor.node()) && cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
if cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<Vec<Arc<Server>>> {
|
||||
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<Server>, 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::<Vec<_>>(), [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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Server> {
|
||||
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<Arc<Server>> {
|
||||
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<Location> = 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))
|
||||
|
||||
@@ -54,7 +54,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.4";
|
||||
pub const API_VERSION: &str = "1.5";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -259,6 +259,19 @@ pub trait HostApi: Send + Sync {
|
||||
body: String,
|
||||
actions: Vec<crate::NotificationAction>,
|
||||
) -> Result<(), HostError>;
|
||||
/// [`HostApi::notify`] in the most recently focused window of the open project at
|
||||
/// `root`; not-found (and nothing shown) once it has closed.
|
||||
fn notify_project(
|
||||
&self,
|
||||
root: &Path,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<crate::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (root, level, title, body, actions);
|
||||
Err(HostError::NotFound("project notifications unavailable".into()))
|
||||
}
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let _ = (client, text);
|
||||
Ok(())
|
||||
|
||||
@@ -362,6 +362,25 @@ impl StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks `ui.notify` buttons; the host checks that their commands are declared.
|
||||
fn notification_actions(actions: Vec<ui::NotificationAction>) -> Result<Vec<crate::NotificationAction>, wit::Error> {
|
||||
const MAX_ACTIONS: usize = 3;
|
||||
if actions.len() > MAX_ACTIONS {
|
||||
return Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions")));
|
||||
}
|
||||
let mut checked = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label)));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into()));
|
||||
}
|
||||
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
||||
}
|
||||
Ok(checked)
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn notify(
|
||||
&mut self,
|
||||
@@ -370,22 +389,29 @@ impl ui::Host for StoreData {
|
||||
body: String,
|
||||
actions: Vec<ui::NotificationAction>,
|
||||
) -> Fallible<()> {
|
||||
const MAX_ACTIONS: usize = 3;
|
||||
if actions.len() > MAX_ACTIONS {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
|
||||
}
|
||||
let mut checked = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
|
||||
}
|
||||
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
||||
}
|
||||
let actions = match notification_actions(actions) {
|
||||
Ok(actions) => actions,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
|
||||
Ok(self.host.notify(self.client(), notification_level(level), title, body, actions).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn notify_project(
|
||||
&mut self,
|
||||
target: Resource<ProjectRes>,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ui::NotificationAction>,
|
||||
) -> Fallible<()> {
|
||||
let actions = match notification_actions(actions) {
|
||||
Ok(actions) => actions,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let root = self.table.get(&target)?.root.clone();
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
Ok(self.host.notify_project(&root, notification_level(level), title, body, actions).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn show_notification(
|
||||
|
||||
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.4.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] {
|
||||
let host = v("1.5.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.5.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.6.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.6") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.5 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4647fa9aec9732e3dd824efccd61b1c6b9c36645a3586f402f6a7a47419db61f
|
||||
9963d6606bb6b859bebe1f55892488944f6d5e3308e6c0109f38f4b1a67b01f5
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6787b195df1057176ea9fcf11d0fcaf8cd347fc182fab94fe3a229063deed831
|
||||
7ab9937fdb2e78d0d62d9b85b78a0a6d9511224f85908c3a5a5e9c0d4b5e7e14
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c2d17076337b1225ef8b0323b335a3b03bf022485088957c520e0cb797d7df7d
|
||||
cf1f91d8accf8339bf6e22bef5067bfe3cba75d38cd30d8748b0aa451ee2495b
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
415a4cd17c573aeeb1b45d4b1af55834eab5e686c4f624495323e4fb63c3ac71
|
||||
12ad7d649d752f2a40b28dee718a3e04b64597496ab43787c084e3cb774ccfe6
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
5f502b7650acd5d07bffba8016680248b5ceac0926013916ff351562e3c6d831
|
||||
4bfce353b3c94490c5f95a046cd2371042b243c326bd63c65619e1489d7ab889
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
19e8d460269999aeac7b4021bfedb803ad0d68c24ce1c22f058070fb406a64c3
|
||||
cfbd6aac217a7baea907affe4e1e7f89d1330843398e913e68b71f3a99cfb71b
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
63809abb0f8a4f40b0c0f465062cc8ba0ebfd03d41a108ff19866e6565eda931
|
||||
dcf00138820979f523bd93e1f6b08c627c6f061ae5b6e91b340646f905593cf3
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
99c968b98d8f1d895c428b4048451275bd322e7070aa0a020737097efea17eb1
|
||||
98e0e01e8405ae7501e89c0dab06e6672b5240a831b19e8fbf0604a3171b31e9
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c40a0fd8c6cf791e70ba0335c6cf0b2c82b250670ebad59b185e3e8719172409
|
||||
fcee11f27ff9ae0038e8067712e269ccd49423372f692e9351c24f82906aafad
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
162774155d8b49d53b0c0c055736bd098723c66b02c0e1dc4389985982c57282
|
||||
5c777d7b146277fa803587d1788a7359b08478def00c4574dd565d784200e39a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
39e3de11f69c09348906878367318157fca9a777f9ac1c4fb6ed303fa058e5a2
|
||||
825b9ee483796efd24fcfe358b94f4667d87e311475769ec9e41ba9af6d03a6a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4a3f595d2a6a9b79da7e316a3da86c287e9761ea670319c331467e7b10639663
|
||||
45b8cbd2ed7635ce04651937574dde9cf441dd619d76b8996f522c671950eaee
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
bede1f0eaec356e3e35302e65e85531870625560bc9a3197f1acb58969beb4bd
|
||||
e456b7ddf087ffbf2df62431bf9df47fad1929079c99a941a5deaf30599109f9
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
ad2dc0ad12adb9c6b25017b2cef78b7496e96d1bb6e88b24ea9b7b682120e786
|
||||
e0d8b590ad6ffd9569c6362802b2be909cb34b37b18cc8cc8492f9c460610e20
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
2c329cf7af36c9d65ed5b6be7c2a017c7a5a6214a4893cab7ae574acb76fe62f
|
||||
d2512203db3e5c6df93e9ccd5ff8daed8f1ebd28462b8caf7d500e814d3b6671
|
||||
@@ -75,6 +75,14 @@ impl Plugin for Jobs {
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"notify-project" => {
|
||||
// The argument is a path in the target project, as a scheduled check would have.
|
||||
let path = arg.unwrap_or_default();
|
||||
let project = host::project_of(path).ok_or("no project contains the path")?;
|
||||
let action = openidea_plugin::NotificationAction::new("Disable", "context").argument(path);
|
||||
project.notify(NotificationLevel::Info, "Project update", path, &[action])?;
|
||||
Ok(None)
|
||||
}
|
||||
"copy" => {
|
||||
host::copy_to_clipboard(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
39c85942177e937e48f32bcde86a0068dac484ba028a8d6785a5a2728db9e11b
|
||||
b32da5137b552cfad2557716343ed8ac9236b34fec018e3f2facc7b78b67e72a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
30a8cc05a40c7b99a78700430ae87d025ad7567b44384fdb696119aa421f8174
|
||||
ae58467cdb6e98a491d739884eee96d239f80451de749950d81f4fd480edba58
|
||||
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.4.0;
|
||||
export openidea:plugin/providers@1.4.0;
|
||||
export openidea:plugin/file-actions@1.4.0;
|
||||
include openidea:plugin/plugin-syntax@1.5.0;
|
||||
export openidea:plugin/providers@1.5.0;
|
||||
export openidea:plugin/file-actions@1.5.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
5f9c92b23c00269ec2b5dc8829ce526a8fc4c0d76ecb2925ec9a06ffeac5f0b4
|
||||
e378c85a4fe395c7b172065a33a2f15ad76852ac42d4099d2cf1f64540e13143
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
d695409029a66c8a44f524072be1d47ed974fc8810f6fe1d8d068765184d4028
|
||||
23dc9ab1affb5b0c0166a5bb2bffb8a273a5c9eed0c06d402f16a1440b835639
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
d5e81a43dc2c5298d0aaafd70c0bd91ab6cc040d70224d2936349de41253735f
|
||||
d82fa182c8d5a029451addbf3b9792419446849911949e61052fca181d7758ce
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
c29076023ccedf2da8fd9e5a851a47bbe7131b97713c8140a20cd55608a1cc0d
|
||||
12337d77078aeb678567c0dc87c746a78454656ed52a2e51dd0bb81f19d90da3
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
616157bd77dc272f9e2dfed4c54c9d65fca110e55ad01f92b7c4f8a6136537b4
|
||||
9048b66162a39a518d117cd473f6192827bc05737ff5132324c54eb8295b060b
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
022f216f0f4ca5da56f4b2fabf5ee4a987d5e9f9a752feecbb438f9b00b1acf4
|
||||
274f7e74f19aabf9368c2b9a107494d82a2013d4adda645bde6d9ccda4007769
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
626f0e0c83e69704c0365c273b6f63d43278ee37786f38ed467ce908c30eadf7
|
||||
e9082dc0f4c9c4fc682e16821d36f7fa42be9c82b75ac28edbee6a869dc86b8b
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
aa141701049f5c6c0d045341c3337344ed7a368800f08237b1b7812cce216814
|
||||
0028a0439e8bb234fb0cc5138e1c27d5c2c8443bed12963a69f0bd8b3818ec7b
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d6c40099cc959ef2dead996beeb4acd73c1327597cd029674026c6f2bc878300
|
||||
f3b633e9e34a5343f318e4e451ffe1c87a6d1ac6d0dc01a57588ea99380dd8d4
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3201f643cf868f7a358ba812f9c90e06b982d864ed141b6b4c3fdeee13cb1559
|
||||
02fba523571a945b16055e83ee72400f69ecb64238556e933cfd5c2c92c8ea10
|
||||
@@ -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
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<Option<PathBuf>> {
|
||||
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<String> {
|
||||
let mut default_fqbn = None;
|
||||
let mut default_profile = None;
|
||||
let mut current_profile: Option<String> = 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<String> {
|
||||
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<Option<&'a str>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
mod angular;
|
||||
pub mod apply;
|
||||
pub mod arduino;
|
||||
mod discover;
|
||||
mod exec;
|
||||
pub mod go;
|
||||
|
||||
@@ -33,7 +33,7 @@ pub use jvm::{JvmPresentation, JvmTypeKind, jvm_type_header};
|
||||
pub use types::*;
|
||||
|
||||
use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range};
|
||||
use ide_language::{ParsedTree, SyntaxCache, TreeError};
|
||||
use ide_language::{ParsedTree, Syntax, SyntaxCache, TreeError, shift_range};
|
||||
use kinds::Kinds;
|
||||
use lines::Lines;
|
||||
use std::sync::Arc;
|
||||
@@ -143,8 +143,15 @@ impl StructureService {
|
||||
|
||||
/// What the service can answer for `language`.
|
||||
pub fn support(&self, language: &LanguageId) -> StructureSupport {
|
||||
match self.cache.syntax().grammar_name(language) {
|
||||
Some(g) => StructureSupport { tree: true, locals: kinds::locals_source(g).is_some() },
|
||||
let syn = self.cache.syntax();
|
||||
// Web containers (Vue SFC / Svelte / JSP have no grammar of their own)
|
||||
// answer from the HTML tree; inline <script> regions add the block
|
||||
// grammar's tables — TypeScript / JavaScript ship locals.scm — so both
|
||||
// capabilities are on whenever HTML is.
|
||||
let g =
|
||||
syn.grammar_name(language).or_else(|| (syn.web_grammar_name(language) == Some("html")).then_some("html"));
|
||||
match g {
|
||||
Some(g) => StructureSupport { tree: true, locals: kinds::locals_source(g).is_some() || g == "html" },
|
||||
None => StructureSupport::default(),
|
||||
}
|
||||
}
|
||||
@@ -157,6 +164,17 @@ impl StructureService {
|
||||
range: Range,
|
||||
cancel: &CancellationToken,
|
||||
) -> StructureResult<Option<NodeAt>> {
|
||||
let bytes = Lines::new(snap.text).bytes(range);
|
||||
if let Some(at) = self.with_script_tree(snap, bytes, cancel, |cx, local, base| {
|
||||
Ok(nodes::node_at(cx, local).map(|at| shift_node_at(at, base)))
|
||||
})? {
|
||||
return Ok(at);
|
||||
}
|
||||
// Outside any <script> region a web container (template markup) has no
|
||||
// identifier to offer: an empty answer, not an error.
|
||||
if self.web_container(snap.language) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.run(snap, cancel, |cx| Ok(nodes::node_at(cx, range)))
|
||||
}
|
||||
|
||||
@@ -178,6 +196,17 @@ impl StructureService {
|
||||
|
||||
/// Scope analysis for the identifier at `pos` (`locals.scm`).
|
||||
pub fn locals(&self, snap: &Snapshot, pos: Pos, cancel: &CancellationToken) -> StructureResult<Locals> {
|
||||
let byte = Lines::new(snap.text).byte(pos);
|
||||
if let Some(locals) = self.with_script_tree(snap, (byte, byte), cancel, |cx, local, base| {
|
||||
locals::locals(cx, local.start).map(|l| shift_locals(l, base))
|
||||
})? {
|
||||
return Ok(locals);
|
||||
}
|
||||
if self.web_container(snap.language) {
|
||||
// Template markup: no TypeScript identifier here; the frontend
|
||||
// treats `noIdentifier` as "nothing at this position".
|
||||
return Ok(Locals::NoIdentifier);
|
||||
}
|
||||
self.run(snap, cancel, |cx| locals::locals(cx, pos))
|
||||
}
|
||||
|
||||
@@ -255,6 +284,62 @@ impl StructureService {
|
||||
Ok(ranges)
|
||||
}
|
||||
|
||||
/// Whether `language` is an HTML container without a grammar of its own
|
||||
/// (Vue SFC / Svelte / JSP): structure answers come from the HTML tree
|
||||
/// plus the inline `<script>` regions' block grammars.
|
||||
fn web_container(&self, language: &LanguageId) -> bool {
|
||||
let syn = self.cache.syntax();
|
||||
syn.grammar_name(language).is_none() && syn.web_grammar_name(language) == Some("html")
|
||||
}
|
||||
|
||||
/// Runs `f` on the block grammar's tree when one of the document's inline
|
||||
/// `<script>` regions covers `bytes`; `f` sees the region's `Ctx`, the
|
||||
/// query converted into region coordinates, and the region's document
|
||||
/// `base`. `Ok(None)` when no region covers `bytes`.
|
||||
fn with_script_tree<R>(
|
||||
&self,
|
||||
snap: &Snapshot,
|
||||
bytes: (usize, usize),
|
||||
cancel: &CancellationToken,
|
||||
f: impl FnOnce(&Ctx, Range, Pos) -> StructureResult<R>,
|
||||
) -> StructureResult<Option<R>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(StructureError::Cancelled);
|
||||
}
|
||||
let cancelled = || cancel.is_cancelled();
|
||||
let syn = self.cache.syntax();
|
||||
if syn.web_grammar_name(snap.language) != Some("html") {
|
||||
return Ok(None);
|
||||
}
|
||||
let extract = |p: ParsedTree<'_>| Syntax::script_regions_of(p.tree, p.text);
|
||||
let regions = match snap.doc {
|
||||
Some(doc) if syn.grammar_name(snap.language).is_some() => {
|
||||
self.cache.with_parsed(doc, snap.language, snap.version, snap.text, &cancelled, extract)
|
||||
}
|
||||
_ => syn.with_parsed(&LanguageId::new("html"), snap.text, &cancelled, extract),
|
||||
};
|
||||
let Some(region) = regions?.into_iter().find(|r| bytes.0 >= r.base && bytes.1 <= r.base + r.text.len()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let base = Lines::new(snap.text).pos(region.base);
|
||||
let (a, b) = (bytes.0 - region.base, bytes.1 - region.base);
|
||||
syn.with_parsed(®ion.language, ®ion.text, &cancelled, |p| {
|
||||
let cx = Ctx {
|
||||
grammar: p.grammar,
|
||||
language: p.language,
|
||||
tree: p.tree,
|
||||
kinds: Kinds::for_grammar(p.grammar, p.language),
|
||||
lines: Lines::new(p.text),
|
||||
cancel,
|
||||
};
|
||||
let local = Range::new(cx.lines.pos(a), cx.lines.pos(b));
|
||||
f(&cx, local, base)
|
||||
})
|
||||
.map_err(StructureError::from)
|
||||
.and_then(|r| r)
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
fn run<R>(
|
||||
&self,
|
||||
snap: &Snapshot,
|
||||
@@ -288,3 +373,28 @@ fn utf16_offset(text: &str, pos: Pos) -> u32 {
|
||||
let lines = Lines::new(text);
|
||||
lines::utf16_len(&text[..lines.byte(pos)])
|
||||
}
|
||||
|
||||
/// Shifts a `<script>` region's answer to document coordinates (`base` is the
|
||||
/// region start): the node and every ancestor move by [`shift_range`].
|
||||
fn shift_node_at(at: NodeAt, base: Pos) -> NodeAt {
|
||||
let shift = |n: SyntaxNode| SyntaxNode { range: shift_range(n.range, base), ..n };
|
||||
NodeAt { node: shift(at.node), ancestors: at.ancestors.into_iter().map(shift).collect() }
|
||||
}
|
||||
|
||||
/// [`shift_node_at`] for a `locals` answer: every range moves (`NotLocal`'s,
|
||||
/// the symbol's definition, scope, references and conflicts), names stay.
|
||||
fn shift_locals(l: Locals, base: Pos) -> Locals {
|
||||
match l {
|
||||
Locals::NoIdentifier => Locals::NoIdentifier,
|
||||
Locals::NotLocal { name, range, reason } => Locals::NotLocal { name, range: shift_range(range, base), reason },
|
||||
Locals::Local { symbol } => Locals::Local {
|
||||
symbol: LocalSymbol {
|
||||
definition: shift_range(symbol.definition, base),
|
||||
scope: shift_range(symbol.scope, base),
|
||||
references: symbol.references.into_iter().map(|r| shift_range(r, base)).collect(),
|
||||
conflicts: symbol.conflicts.into_iter().map(|r| shift_range(r, base)).collect(),
|
||||
..symbol
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -287,3 +287,123 @@ fn identifiers_in_files_loads_in_parallel_and_skips_misses() {
|
||||
let got: Vec<(String, usize)> = found.iter().map(|f| (f.path.display().to_string(), f.occurrences.len())).collect();
|
||||
assert_eq!(got, [("/p/a.ts".to_owned(), 1), ("/p/d.rs".to_owned(), 2)]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vue SFC / HTML: answers from the inline <script> regions' block grammars
|
||||
|
||||
#[test]
|
||||
fn web_containers_support_the_tree_and_locals() {
|
||||
let s = service();
|
||||
for id in ["vue", "html"] {
|
||||
let support = s.support(&lang(id));
|
||||
assert!(support.tree, "{id}");
|
||||
assert!(support.locals, "{id}");
|
||||
}
|
||||
let rust = s.support(&lang("rust"));
|
||||
assert!(rust.tree && rust.locals, "languages with their own grammar are unchanged");
|
||||
let plain = s.support(&lang("plaintext"));
|
||||
assert!(!plain.tree && !plain.locals);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_at_inside_a_script_region_answers_from_the_block_grammar() {
|
||||
let src = "<template>\n <div>{{ n }}</div>\n</template>\n<script lang=\"ts\">\nconst size = 1;\n</script>\n";
|
||||
let vue = lang("vue");
|
||||
// `size` inside the script: a TypeScript identifier, at document coordinates.
|
||||
let at = service().node_at(&snap(&vue, src), occ(src, "size", 1), &none()).unwrap().unwrap();
|
||||
assert_eq!(at.node.kind, "identifier");
|
||||
assert_eq!(at.node.range, occ(src, "size", 1), "document coordinates");
|
||||
assert_eq!(at.node.field.as_deref(), Some("name"));
|
||||
// Template markup is not TypeScript: an empty answer, not markup nodes.
|
||||
assert!(service().node_at(&snap(&vue, src), occ(src, "div", 1), &none()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locals_inside_a_script_region_shift_every_range() {
|
||||
let src = "<template><div/></template>\n<script lang=\"ts\">\nfunction f() {\n let count = 0;\n count += 1;\n}\n</script>\n";
|
||||
let vue = lang("vue");
|
||||
let byte = occurrences(src, "count")[0];
|
||||
let l = service().locals(&snap(&vue, src), pos_of(src, byte), &none()).unwrap();
|
||||
let Locals::Local { symbol } = l else {
|
||||
panic!("expected a local, got {l:?}");
|
||||
};
|
||||
assert_eq!(symbol.name, "count");
|
||||
assert!(symbol.certain);
|
||||
assert_eq!(symbol.definition, occ(src, "count", 1), "definition in document coordinates");
|
||||
assert_eq!(symbol.references, vec![occ(src, "count", 2)], "references in document coordinates");
|
||||
// Template: no TypeScript identifier here, so nothing local.
|
||||
let byte = occurrences(src, "div")[0];
|
||||
let l = service().locals(&snap(&vue, src), pos_of(src, byte), &none()).unwrap();
|
||||
assert!(matches!(l, Locals::NoIdentifier));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_script_queries_honour_cancellation_outside_regions() {
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
for id in ["html", "vue", "svelte", "jsp"] {
|
||||
let language = lang(id);
|
||||
let src = "<template>中文😀</template><script>let value = 1;</script>";
|
||||
let snap = snap(&language, src);
|
||||
assert_eq!(service().node_at(&snap, Range::point(Pos::new(0, 2)), &cancel), Err(StructureError::Cancelled));
|
||||
assert_eq!(service().locals(&snap, Pos::new(0, 2), &cancel), Err(StructureError::Cancelled));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_script_queries_compose_utf16_positions_and_isolate_blocks() {
|
||||
let src = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { let 名 = '😀'; 名; }</script>\r\n<script setup lang=ts>function second() {\r\nlet 名 = 2;\r\n名;\r\n}</script>";
|
||||
for id in ["html", "vue", "svelte", "jsp"] {
|
||||
let language = lang(id);
|
||||
for (at, def, reference) in [(2, 1, 2), (4, 3, 4)] {
|
||||
assert_local(id, src, "名", at, def, &[reference]);
|
||||
let r = occ(src, "名", at);
|
||||
let node = service().node_at(&snap(&language, src), r, &none()).unwrap().unwrap();
|
||||
assert_eq!(node.node.range, r);
|
||||
assert_eq!(node.node.kind, "identifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_script_queries_follow_open_document_edits() {
|
||||
use ide_core::{ChangeSet, TextChange};
|
||||
|
||||
for id in ["html", "vue", "svelte", "jsp"] {
|
||||
let cache = Arc::new(SyntaxCache::new(Arc::new(Syntax::new())));
|
||||
let service = StructureService::new(cache.clone());
|
||||
let language = lang(id);
|
||||
let id = DocId::next();
|
||||
let mut doc = ide_text::Document::new("<script>function f() { let x = 1; x; }</script>");
|
||||
cache.open(id, &language, 0);
|
||||
for version in 0..2 {
|
||||
let text = doc.text();
|
||||
let snap = Snapshot::doc(id, doc.version(), &language, &text);
|
||||
let range = occ(&text, "x", 2);
|
||||
let at = service.node_at(&snap, range, &none()).unwrap().unwrap();
|
||||
assert_eq!(at.node.range, range);
|
||||
let Locals::Local { symbol } = service.locals(&snap, range.start, &none()).unwrap() else {
|
||||
panic!("expected local");
|
||||
};
|
||||
assert_eq!(symbol.definition, occ(&text, "x", 1));
|
||||
assert_eq!(symbol.references, [range]);
|
||||
if version == 0 {
|
||||
let changes =
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "<!-- 中文😀 -->\n".into() }] };
|
||||
let (_, edits) = doc.apply_detailed(doc.version(), &changes).unwrap();
|
||||
cache.edit(id, doc.version(), &edits);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_script_queries_preserve_parse_size_errors() {
|
||||
let src = " ".repeat(ide_language::MAX_PARSE_BYTES + 1);
|
||||
for id in ["html", "vue", "svelte", "jsp"] {
|
||||
let language = lang(id);
|
||||
let snap = snap(&language, &src);
|
||||
assert_eq!(service().node_at(&snap, Range::point(Pos::new(0, 0)), &none()), Err(StructureError::TooLarge));
|
||||
assert_eq!(service().locals(&snap, Pos::new(0, 0), &none()), Err(StructureError::TooLarge));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Data directories
|
||||
|
||||
OpenIDEA keeps its rebuildable data, such as downloaded tools, language servers,
|
||||
JDKs and index caches, in the operating system's local data directory:
|
||||
|
||||
- **Windows:** `%LOCALAPPDATA%\ai.l0ng.openidea`
|
||||
- **macOS:** `~/Library/Application Support/ai.l0ng.openidea`
|
||||
- **Linux:** `~/.local/share/ai.l0ng.openidea`
|
||||
|
||||
To keep this data elsewhere, for example on another drive, set
|
||||
`OPENIDEA_LOCAL_DATA_DIR` to an absolute path before starting OpenIDEA. The IDE
|
||||
then uses `<OPENIDEA_LOCAL_DATA_DIR>/ai.l0ng.openidea` and creates it when
|
||||
missing. If the path is relative or cannot be written, OpenIDEA shows an error
|
||||
and exits instead of falling back to the default location.
|
||||
|
||||
```bat
|
||||
setx OPENIDEA_LOCAL_DATA_DIR "E:\OpenIDEA"
|
||||
```
|
||||
|
||||
Settings, plugins, scratches and other user data stay in the operating system's
|
||||
data and configuration directories. Logs stay in their usual location, so they
|
||||
are still written when the variable is wrong. Existing data is not moved: quit
|
||||
OpenIDEA and move the old directory's contents to the new location, or let the
|
||||
IDE download tools and rebuild indexes again.
|
||||
@@ -48,11 +48,12 @@ const REPLY = [
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
async function openReply(page: Page, text: string, imageData?: string) {
|
||||
async function openReply(page: Page, text: string, imageData?: string, theme?: "dark" | "light") {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, text, imageData }) => {
|
||||
async ({ task, text, imageData, theme }) => {
|
||||
if (theme) document.documentElement.dataset.theme = theme;
|
||||
if (imageData) {
|
||||
const routePath = "/src/platform/tauriCore.ts";
|
||||
const { setInvokeRoute } = await import(routePath);
|
||||
@@ -80,7 +81,7 @@ async function openReply(page: Page, text: string, imageData?: string) {
|
||||
});
|
||||
store.dockTask(task.id);
|
||||
},
|
||||
{ task: TASK, text, imageData },
|
||||
{ task: TASK, text, imageData, theme },
|
||||
);
|
||||
const reply = page.locator("[class*='_markdown_']").first();
|
||||
await reply.locator("h1, p").first().waitFor();
|
||||
@@ -360,3 +361,88 @@ test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewritin
|
||||
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
|
||||
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
|
||||
});
|
||||
|
||||
test("keeps mermaid flowchart nodes and sequence control blocks off solid black", async ({ page }) => {
|
||||
const flow = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
' subgraph Client["Client"]',
|
||||
' A["Local agent"]',
|
||||
' B["Voice input"]',
|
||||
" end",
|
||||
' subgraph Cloud["Cloud"]',
|
||||
' C["Cloud agent"]',
|
||||
' D["Memory service"]',
|
||||
" end",
|
||||
" B --> A",
|
||||
" A --> C",
|
||||
" C --> D",
|
||||
"```",
|
||||
].join("\n");
|
||||
const sequence = ["```mermaid", "sequenceDiagram", " loop every minute", " A->>B: ping", " end", "```"].join(
|
||||
"\n",
|
||||
);
|
||||
const reply = await openReply(page, `Flow:\n\n${flow}\n\nSequence:\n\n${sequence}`);
|
||||
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toHaveCount(2);
|
||||
expect(await css(reply, ".node rect", "fill")).not.toBe("rgb(0, 0, 0)");
|
||||
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
|
||||
expect(await css(reply, ".labelBox", "fill")).not.toBe("rgb(0, 0, 0)");
|
||||
});
|
||||
|
||||
const DIAGRAMS = [
|
||||
"flowchart LR\n subgraph Client\n A[Local agent] --> B[Voice input]\n end",
|
||||
"sequenceDiagram\n loop every minute\n A->>B: ping\n end",
|
||||
"classDiagram\n class Agent {\n +String name\n +run()\n }",
|
||||
"stateDiagram-v2\n [*] --> Ready\n Ready --> Running\n Running --> [*]",
|
||||
'pie title Work\n "Ready" : 40\n "Running" : 35\n "Done" : 25',
|
||||
"gantt\n dateFormat YYYY-MM-DD\n section Delivery\n Shipped :done, shipped, 2026-01-01, 5d\n Building :active, building, after shipped, 5d\n Next :next, after building, 5d\n Critical :crit, critical, after next, 5d",
|
||||
]
|
||||
.map((source) => `\`\`\`mermaid\n${source}\n\`\`\``)
|
||||
.join("\n\n");
|
||||
|
||||
for (const theme of ["dark", "light"] as const) {
|
||||
test(`keeps Mermaid shapes, labels and chart series readable in ${theme} theme`, async ({ page }) => {
|
||||
const reply = await openReply(page, `Diagrams:\n\n${DIAGRAMS}`, undefined, theme);
|
||||
const svgs = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
|
||||
await expect(svgs).toHaveCount(6);
|
||||
for (const [index, selector] of [
|
||||
[0, ".node rect"],
|
||||
[1, ".labelBox"],
|
||||
[2, ".outer-path path"],
|
||||
[3, ".node rect"],
|
||||
] as const) {
|
||||
expect(await css(svgs.nth(index), selector, "fill")).not.toBe("rgb(0, 0, 0)");
|
||||
expect(await css(svgs.nth(index), selector, "stroke")).not.toContain("url(");
|
||||
}
|
||||
expect(await css(svgs.nth(2), ".nodeLabel", "color")).not.toBe(await css(svgs.nth(2), ".outer-path path", "fill"));
|
||||
const slices = await svgs
|
||||
.nth(4)
|
||||
.locator(".pieCircle")
|
||||
.evaluateAll((els) => els.map((el) => getComputedStyle(el).fill));
|
||||
expect(new Set(slices).size).toBe(3);
|
||||
expect(slices).not.toContain("rgb(0, 0, 0)");
|
||||
const contrasts = await svgs
|
||||
.nth(5)
|
||||
.locator("text.taskText")
|
||||
.evaluateAll((els) => {
|
||||
const luminance = (color: string) => {
|
||||
const [r, g, b] = color
|
||||
.match(/\d+/g)!
|
||||
.map(Number)
|
||||
.map((v) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
|
||||
};
|
||||
return els.map((el) => {
|
||||
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
|
||||
const fg = luminance(getComputedStyle(el).fill);
|
||||
const bg = luminance(getComputedStyle(bar).fill);
|
||||
return { text: el.textContent, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) };
|
||||
});
|
||||
});
|
||||
expect(contrasts).toHaveLength(4);
|
||||
for (const { text, ratio } of contrasts) expect(ratio, `${theme}: ${text}`).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,40 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
for (const windowKind of ["project", "welcome", "lightEdit"]) {
|
||||
test(`manual update answers stay visible in ${windowKind} with saved Notifications layout and logging off`, async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (kind) => {
|
||||
const projectPath = "/src/platform/project.ts";
|
||||
const layoutPath = "/src/workbench/toolwindows/store.ts";
|
||||
const updatesPath = "/src/features/app/updates.tsx";
|
||||
const { useProject } = await import(/* @vite-ignore */ projectPath);
|
||||
const { useToolWindowLayout } = await import(/* @vite-ignore */ layoutPath);
|
||||
const { checkForUpdates } = await import(/* @vite-ignore */ updatesPath);
|
||||
const settings = useProject.getState().settings;
|
||||
useProject.setState({
|
||||
...(kind === "project" ? {} : { project: null, lightEdit: kind === "lightEdit" }),
|
||||
settings: {
|
||||
...settings,
|
||||
notifications: { balloons: false, groups: { updates: { display: "none", log: false } } },
|
||||
},
|
||||
});
|
||||
useToolWindowLayout.setState({
|
||||
visible: { ...useToolWindowLayout.getState().visible, right: "notifications" },
|
||||
maximized: null,
|
||||
});
|
||||
window.__ipc.handlers.app_check_update = () => ({
|
||||
enabled: true,
|
||||
update: { version: "99.0.0", currentVersion: "0.1.40", notes: null, date: null },
|
||||
});
|
||||
window.__ipc.handlers.app_update_announcement = () => true;
|
||||
await checkForUpdates();
|
||||
}, windowKind);
|
||||
await expect(page.getByRole("status").filter({ hasText: "OpenIDEA 99.0.0 is available" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Install and Restart", exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("Updates shows check status and keeps manual checks available when automatic checks are off", async ({
|
||||
workbench: page,
|
||||
|
||||
@@ -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."
|
||||
@@ -37,7 +37,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit, inlay-hints
|
||||
```
|
||||
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.4.0`, world
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.5.0`, world
|
||||
`plugin`).
|
||||
|
||||
## 1. Quick start
|
||||
@@ -365,9 +365,19 @@ are always available. Calling a gated function without its capability returns
|
||||
|
||||
## 3. Writing code (Rust SDK)
|
||||
|
||||
### API 1.5
|
||||
|
||||
API 1.5.0 is planned for the next OpenIDEA release. Use
|
||||
`engines.openidea = "^1.5"` and the matching SDK. Released 1.4 plugins keep
|
||||
working. New in 1.5:
|
||||
|
||||
- [Notifications for a project](#notifications-with-commands):
|
||||
`Project::notify` shows a notification in a window of that project only,
|
||||
also from scheduled or background work.
|
||||
|
||||
### API 1.4
|
||||
|
||||
API 1.4.0 is planned for the next OpenIDEA release. Use
|
||||
API 1.4.0 shipped with OpenIDEA 0.1.40. Use
|
||||
`engines.openidea = "^1.4"` and the matching SDK. Released 1.3 plugins keep
|
||||
working. New in 1.4:
|
||||
|
||||
@@ -525,6 +535,7 @@ then run `openidea-plugin check`.
|
||||
| `host::output_append(channel, text)`, `output_clear`, `output_reveal` | `output.{append,clear,reveal}` | API 1.4: read-only Output tabs; 32 named channels per plugin, each a 1 MiB UTF-8 ring buffer. Reveal requires the invoking window. |
|
||||
| `host::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin.<id>.<key>` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. |
|
||||
| `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
|
||||
| `Project::notify(level, title, body, actions)` | `ui.notify-project` | API 1.5: balloon only in a window of that project (the most recently focused one), with or without an invoking window; see _Notifications with commands_. |
|
||||
| `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. |
|
||||
| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. |
|
||||
| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Picks key(s) from `PickItem`s, or `None` when dismissed. From a command/event or code-action resolution in its editor window (else `invalid`); waiting is excluded from the budget. |
|
||||
@@ -2254,8 +2265,8 @@ the API itself follows _Evolving the plugin API_ below.
|
||||
## 10. Evolving the plugin API
|
||||
|
||||
Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36, 1.1.0
|
||||
with 0.1.37, 1.2.0 with 0.1.38 and 1.3.0 with 0.1.39. API 1.4.0 is planned
|
||||
with the next release. wasmtime decides what stays compatible: it resolves interface names
|
||||
with 0.1.37, 1.2.0 with 0.1.38, 1.3.0 with 0.1.39 and 1.4.0 with 0.1.40.
|
||||
API 1.5.0 is planned with the next release. wasmtime decides what stays compatible: it resolves interface names
|
||||
by semver (a component importing `openidea:plugin/[email protected]` links against a
|
||||
host defining `@1.1.0`, and the host finds its exports the same way), but
|
||||
function types must match exactly — a record with another field count, or a
|
||||
@@ -2382,6 +2393,29 @@ its normal editor and project context. No capability is needed. Invalid or
|
||||
undeclared actions reject the whole notification. Labels are limited to 256
|
||||
bytes and arguments to 64 KiB. Plain `host::notify` behaves as before.
|
||||
|
||||
Both go to the window the command or event came from, or to every window when
|
||||
there is none (a scheduled call, for instance). To reach one project from such
|
||||
work, call `notify` on that [`Project`](#the-api) (API 1.5, `ui.notify-project`),
|
||||
with the same actions and limits:
|
||||
|
||||
```rust
|
||||
for project in host::projects()? {
|
||||
if let Some(update) = check_for_update(&project)? {
|
||||
let disable = NotificationAction::new("Disable automatic checks", "disable-checks")
|
||||
.argument(project.root()?);
|
||||
project.notify(NotificationLevel::Info, "Update available", &update, &[disable])?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- It shows only in a window of that project: the most recently focused one
|
||||
when the project is open in several.
|
||||
- A button runs its command with an invocation from that window, so
|
||||
`ctx.project()` is the notification's project.
|
||||
- If the project has closed by the time of the call, the call fails with
|
||||
`Error::NotFound` and shows nothing; it never falls back to another window.
|
||||
A balloon already shown goes away with its window.
|
||||
|
||||
### Selecting plugin tests (API 1.4, unreleased)
|
||||
|
||||
A plugin using `framework = "plugin"` can implement `TestResults::select_tests`
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e3c11d8969a1b8b52e7e745b558d12f804ebf533e7f0f3fe614ffe6f7b9c9468
|
||||
bb67a1b003840ecc2d0c056a0b0af3474b08ac095239c1f4d02b7a7aab5b526c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b28ccfd442c6348e0826680ec97391cbe4ec6a93a27ff02e40f47341e21586b4
|
||||
a6289739f5b644ea559ba18298741205ea0bedce7bd1dd2b367309101e8ffb1e
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d669a2600b0c59e91ac8a9b875a24bddf0eeddd404ce7b67da31733861a846b8
|
||||
767ff8329456fe3a1f324f571b22124707244a61ff5b6ea2c7ed88f909d202e8
|
||||
Бінарний файл не відображається.
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача