Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai cbcd1818a7 fix(plugins): accept empty navigation anchor ranges as positions
A document indexer that anchors a whole-file definition at the file's start
(an empty range at 0:0) had the whole document rejected with "invalid
navigation anchors". Empty ranges inside the document are now accepted:
Go to Declaration lands on them, while Find Usages and Rename skip them so
a rename never inserts the new name at that position. The error for a
document that is still rejected names the plugin and the file.
2026-11-12 00:11:01 +08:00
183 змінених файлів з 496 додано та 5769 видалено

No files matched your search

-1
Переглянути файл
@@ -12,7 +12,6 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"src/editor/core/cm/django/parser*.ts",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
Створено
-1
Переглянути файл
@@ -4618,7 +4618,6 @@ name = "ide-lsp"
version = "0.1.42"
dependencies = [
"dunce",
"encoding_rs",
"futures",
"globset",
"ide-core",
+15 -20
Переглянути файл
@@ -1338,19 +1338,15 @@ impl AgentManager {
/// Files the task changed since it started.
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let at = task.base();
let (cwd, base) = task.cwd_and_base();
let before = task.state.lock().before.clone();
blocking(move || at.changes(&before)).await
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
}
/// Content of `path` (in the task's directory) when the task started.
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
let at = self.task(id)?.base();
blocking(move || match at.rev()? {
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
None => Ok(None),
})
.await
let (cwd, base) = self.task(id)?.cwd_and_base();
blocking(move || worktree::file_at(&cwd, &path, &base)).await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
@@ -1360,17 +1356,16 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let at = task.base();
let (cwd, base) = task.cwd_and_base();
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = at.changes(&before)?;
let rev = at.rev()?;
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
@@ -1474,15 +1469,15 @@ impl Task {
})
}
fn base(&self) -> worktree::TaskBase {
fn cwd_and_base(&self) -> (PathBuf, String) {
let s = self.state.lock();
let cwd = s.task.cwd.0.clone();
match s.task.isolation {
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
Isolation::Worktree => {
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
}
}
// In the project's own checkout the user commits too: compare against the current HEAD,
// as the Commit tool window does, so commits made since the task started drop out.
let base = match s.task.isolation {
Isolation::Local => None,
Isolation::Worktree => s.task.base_commit.clone(),
};
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
}
fn emit_task(&self) {
-152
Переглянути файл
@@ -2869,158 +2869,6 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
struct Checkout {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
impl Checkout {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap();
let checkout = Self { _tmp: tmp, dir };
checkout.git(&["init", "-q", "-b", "main"]);
checkout.git(&["config", "core.autocrlf", "false"]);
checkout.write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
checkout
}
fn git(&self, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&self.dir)
.status()
.unwrap()
.success();
assert!(ok, "{args:?}");
}
fn commit(&self, message: &str) {
self.git(&["add", "."]);
self.git(&["commit", "-q", "-m", message]);
}
fn write(&self, rel: &str, text: &str) {
std::fs::write(self.dir.join(rel), text).unwrap();
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap()
}
/// A local task, stopped so it can be rolled back.
async fn task(&self) -> (AgentManager, AgentTask) {
let mgr = manager(&self.dir, Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
(mgr, task)
}
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.write("mine.txt", "base\n");
repo.commit("init");
repo.write("mine.txt", "my work\n");
repo.commit("my work");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The user takes the last commit back into the working tree (`git reset --soft`, an
// undone commit): its changes are the user's, not the agent's.
repo.git(&["reset", "-q", "--soft", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"]);
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
assert_eq!(base.as_deref(), Some("base\n"));
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n");
assert_eq!(repo.read("mine.txt"), "my work\n", "the user's work survives the rollback");
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.write("other.txt", "base\n");
repo.commit("init");
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.write("other.txt", "feature\n");
repo.write("only-feature.txt", "feature\n");
repo.commit("feature");
repo.git(&["checkout", "-q", "main"]);
repo.write("only-main.txt", "main\n");
repo.commit("main");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The agent's uncommitted edit comes along to a branch that doesn't contain the task's start.
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"]);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n");
assert_eq!(repo.read("other.txt"), "feature\n");
assert_eq!(repo.read("only-feature.txt"), "feature\n");
assert!(!repo.dir.join("only-main.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
let repo = Checkout::new();
repo.write("a.txt", "base\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
repo.write("b.txt", "agent\n");
// The user commits what the agent did to `a.txt` so far: it drops out of the task's changes.
repo.git(&["add", "a.txt"]);
repo.git(&["commit", "-q", "-m", "keep"]);
assert_eq!(changed(&mgr, &task.id).await, ["b.txt"]);
repo.write("a.txt", "agent\nagain\n");
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"]);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "back to the commit, not to before it");
assert!(!repo.dir.join("b.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
let repo = Checkout::new();
repo.write("mine.txt", "mine\n");
let (mgr, task) = repo.task().await;
repo.write("new.txt", "agent\n");
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"]);
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None);
mgr.rollback_changes(&task.id, None).await.unwrap();
assert!(!repo.dir.join("new.txt").exists());
assert_eq!(repo.read("mine.txt"), "mine\n");
assert!(changed(&mgr, &task.id).await.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
use ide_core::settings::AgentSecuritySettings;
+9 -123
Переглянути файл
@@ -117,92 +117,14 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
for s in repo.status()? {
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
out.push(added(s.path, s.rel_path));
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
}
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(out)
}
fn added(path: IpcPath, rel_path: String) -> AgentChange {
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
}
/// Where a task works and what its changes are measured against.
pub(crate) enum TaskBase {
/// A worktree of its own: everything since the commit it was created from, the agent's
/// own commits included.
Worktree { cwd: PathBuf, base: String },
/// The project's checkout, where the user works and commits too. `start` is the commit it
/// started from (`None` in a repository without commits, or an imported session).
Local { cwd: PathBuf, start: Option<String> },
}
impl TaskBase {
pub(crate) fn cwd(&self) -> &Path {
match self {
Self::Worktree { cwd, .. } | Self::Local { cwd, .. } => cwd,
}
}
/// The files the task changed, less those changed before it started and untouched since.
pub(crate) fn changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<Vec<AgentChange>> {
let (cwd, start) = match self {
Self::Worktree { cwd, base } => return Ok(without_untouched(changes(cwd, base)?, before)),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
// What is uncommitted, as the Commit tool window lists it: commits made since the task
// started drop out.
let head = repo.branch_info()?.head_commit;
let listed = match &head {
Some(head) => changes(cwd, head)?,
None => {
let mut all: Vec<AgentChange> = repo
.status()?
.into_iter()
.filter(|s| s.path.is_file())
.map(|s| added(s.path, s.rel_path))
.collect();
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
all
}
};
let listed = without_untouched(listed, before);
let Some(start) = start.filter(|s| head.as_deref() != Some(*s)) else { return Ok(listed) };
// HEAD moved (a reset, a checkout, a pull): a file that is as it was when the task
// started is the user's doing, not the task's.
Ok(listed
.into_iter()
.filter(|c| {
before.contains_key(&c.rel_path)
|| !repo.file_bytes_at(&c.path.0, Some(start)).is_ok_and(|at| at == std::fs::read(&c.path.0).ok())
})
.collect())
}
/// The revision the task's changed files go back to on rollback, and are compared with:
/// the commit the task started from, or the current `HEAD` once the user committed on top
/// of it, so those commits stay. `None` while the repository has no commits.
pub(crate) fn rev(&self) -> IdeResult<Option<String>> {
let (cwd, start) = match self {
Self::Worktree { base, .. } => return Ok(Some(base.clone())),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(None) };
let head = repo.branch_info()?.head_commit;
// A start commit git no longer knows (garbage-collected) can't be gone back to.
let start = start.filter(|s| repo.merge_base(s, s).ok().flatten().is_some());
Ok(match (start, head) {
(Some(start), Some(head)) if start != head && repo.merge_base(start, &head)?.as_deref() != Some(start) => {
Some(start.to_owned())
}
(start, head) => head.or_else(|| start.map(str::to_owned)),
})
}
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
@@ -298,11 +220,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
/// it, or when there is no `rev`: the repository had no commits).
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
pub(crate) fn restore(
cwd: &Path,
rev: Option<&str>,
base: &str,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
@@ -320,13 +242,10 @@ pub(crate) fn restore(
})?;
Some(std::fs::read(copy)?)
}
None => match rev {
Some(rev) => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(rev))?
}
None => None,
},
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
};
match content {
Some(bytes) => {
@@ -571,39 +490,6 @@ mod tests {
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
}
#[test]
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
let head = || head_of(&repo).unwrap().1;
let commit = |name: &str| {
std::fs::write(repo.join(name), "x\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", name]);
head_of(&repo).unwrap().1
};
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
let first = commit("a.txt");
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
let start = commit("b.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
let ahead = commit("c.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
git(&repo, &["reset", "-q", "--soft", &first]);
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
git(&repo, &["checkout", "-q", "-b", "side"]);
commit("d.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
let gone = "0123456789abcdef0123456789abcdef01234567";
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
assert_eq!(worktree.rev().unwrap(), Some(start));
}
#[test]
fn accept_leaves_files_the_checkout_changed_itself() {
let (_tmp, repo, created) = accept_repo();
+2 -10
Переглянути файл
@@ -12,16 +12,11 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
pub fn to_ipc(p: &Path) -> String {
#[cfg(windows)]
{
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
// The UI compares path strings; a drive letter is case-insensitive.
if s.as_bytes().get(1) == Some(&b':') {
s[..1].make_ascii_uppercase();
}
s
dunce::simplified(p).to_string_lossy().replace('\\', "/")
}
#[cfg(not(windows))]
{
@@ -149,8 +144,5 @@ mod tests {
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
}
}
+2 -35
Переглянути файл
@@ -138,11 +138,8 @@ impl WorkspaceEdit {
continue;
}
*expected = expected.or(version);
// Repeats within one entry are intended (LSP inserts at one position
// apply in order); only an alias entry repeats what is already merged.
let merged = own.len();
for edit in edits {
if !own[..merged].contains(&edit) {
if !own.contains(&edit) {
own.push(edit);
}
}
@@ -280,40 +277,10 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(test)]
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
#[test]
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
let path = PathBuf::from("missing-dir/main.ts");
let at = |line, text: &str| TextEdit {
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
new_text: text.into(),
};
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
for (entries, expected) in [
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
(
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
vec![nl.clone(), other.clone(), other.clone()],
),
] {
let mut workspace =
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
workspace.deduplicate();
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once, "{entries:?}");
}
}
#[cfg(unix)]
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
+11 -7
Переглянути файл
@@ -621,9 +621,9 @@ fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Valu
sent
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
@@ -632,10 +632,14 @@ fn remove_delve_binaries(cwd: &Path) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
// Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
+4 -5
Переглянути файл
@@ -1472,7 +1472,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -2096,20 +2095,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
-30
Переглянути файл
@@ -169,25 +169,6 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
state.remote.remote_rename(&connection, &from, &to).await
}
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
/// own name.
pub async fn remote_download_into(
window: &str,
state: &Host,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
done();
state.end_request(request);
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
@@ -216,17 +197,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_download_into", |c| async move {
remote_download_into(
&c.window,
&c.host,
c.arg("connection")?,
c.arg("paths")?,
c.arg("target")?,
c.arg("request")?,
)
.await
});
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
+1 -21
Переглянути файл
@@ -19,17 +19,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
blocking(move || {
project.documents.open_virtual(
&vd.uri,
vd.language_id,
&vd.text,
&vd.encoding,
vd.has_bom,
vd.can_reload_encoding,
)
})
.await
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
@@ -40,8 +30,6 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state.
/// Virtual documents re-read their origin (archive, language server, Git blob)
/// instead of a disk file; converting them would write to it, so it stays refused.
pub async fn doc_set_encoding(
host: &Host,
window: &str,
@@ -50,14 +38,6 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
project.lsp.reload_virtual(&uri, &encoding).await?
};
return host.blocking(move || project.documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom)).await;
}
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
}
+2 -5
Переглянути файл
@@ -176,11 +176,8 @@ async fn binary_and_virtual_documents_are_skipped() {
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc = f
.project
.documents
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
.unwrap();
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
+1 -1
Переглянути файл
@@ -544,7 +544,7 @@ pub async fn lsp_goto_implementation(
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri, None).await;
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
+1 -59
Переглянути файл
@@ -20,13 +20,10 @@ struct LogAccess {
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
/// `encoding` re-decodes the blob with it ("Reload with Encoding") instead of
/// the project's detection.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
encoding: Option<&str>,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
@@ -50,7 +47,7 @@ pub async fn revision_virtual_document(
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let (text, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
@@ -60,63 +57,8 @@ pub async fn revision_virtual_document(
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
encoding: charset.name().to_ascii_lowercase(),
has_bom,
can_reload_encoding: true,
})
}
/// Revision text with the charset it was decoded with and whether a matching
/// BOM was stripped; `None` when the file is absent at the revision.
async fn revision_text(
host: &Host,
window: &str,
path: &Path,
rev: &str,
encoding: Option<&str>,
) -> IdeResult<Option<(String, &'static encoding_rs::Encoding, bool)>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let (path, rev, encoding) = (path.to_path_buf(), rev.to_owned(), encoding.map(str::to_owned));
blocking(move || {
fn with_repo<T>(
project: &Option<std::sync::Arc<Project>>,
external: &Option<std::sync::Arc<ide_vcs::Repo>>,
path: &Path,
f: impl FnOnce(&ide_vcs::Repo) -> IdeResult<T>,
) -> IdeResult<T> {
match project {
Some(project) => project.with_log_repo_of(path, f),
None => f(external
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?),
}
}
let (text_encoding, charset) = match &encoding {
Some(label) => {
let charset = encoding_rs::Encoding::for_label(label.trim().as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid(format!("unknown encoding {label:?}")))?;
(ide_vcs::TextEncoding::from(charset), charset)
}
None => match &project {
Some(project) => {
let text_encoding = project.vcs_encoding(&path, Some(&rev))?;
(text_encoding, text_encoding.charset)
}
None => (ide_vcs::TextEncoding::from(encoding_rs::UTF_8), encoding_rs::UTF_8),
},
};
let bytes = with_repo(&project, &external, &path, |r| r.file_bytes_at(&path, Some(&rev)))?;
let Some(bytes) = bytes else { return Ok(None) };
let has_bom = encoding_rs::Encoding::for_bom(&bytes).is_some_and(|(enc, _)| enc == charset);
let text =
with_repo(&project, &external, &path, |r| r.file_at(&path, Some(&rev), text_encoding))?.unwrap_or_default();
Ok(Some((text, charset, has_bom)))
})
.await
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
+1 -1
Переглянути файл
@@ -1231,7 +1231,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Project Structure › Dependencies —
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
+2 -8
Переглянути файл
@@ -93,15 +93,9 @@ async fn external_history_is_readable_and_never_becomes_a_write_target() {
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let uri = format!("git-revision:{hex}/deleted.txt");
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).await.unwrap();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
assert_eq!(document.encoding, "utf-8");
assert!(document.can_reload_encoding);
// Reload with Encoding re-decodes the blob with the chosen charset.
let reloaded = vcs::revision_virtual_document(&host, "standalone", &uri, Some("utf-16le")).await.unwrap();
assert_eq!(reloaded.encoding, "utf-16le");
assert_ne!(reloaded.text, document.text);
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
-1
Переглянути файл
@@ -330,7 +330,6 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"db_sources",
"db_save_sources",
"db_set_password",
-1
Переглянути файл
@@ -94,7 +94,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
-16
Переглянути файл
@@ -55,10 +55,6 @@ pub struct LanguageConfig {
/// the first one whose root marker is found applies.
#[serde(default)]
pub framework_ui_modes: Vec<FrameworkUiMode>,
/// Files of this language in a framework's projects that their extension alone would
/// give to another language (Django templates are `.html` files in `templates`).
#[serde(default)]
pub framework_files: Vec<FrameworkFiles>,
#[serde(default)]
pub tree_sitter: Option<String>,
#[serde(default)]
@@ -94,18 +90,6 @@ pub struct FrameworkUiMode {
pub root_markers: Vec<String>,
}
/// Files in a framework's folder: a file with one of `extensions` below a folder named
/// `folder`, which has a root marker in a directory above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FrameworkFiles {
pub folder: String,
/// Without the dot, compared ignoring case.
pub extensions: Vec<String>,
/// Matched like language servers' `root-markers`, e.g. `manage.py`.
pub root_markers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
pub struct IndentConfig {
+7 -54
Переглянути файл
@@ -22,7 +22,6 @@ const BUILTIN: &[(&str, &str)] = &[
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("django-html", include_str!("../../../languages/django-html.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
("dotenv", include_str!("../../../languages/dotenv.toml")),
("elixir", include_str!("../../../languages/elixir.toml")),
@@ -372,13 +371,14 @@ impl LanguageRegistry {
.map(|f| f.ui_mode.clone())
}
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
/// language (`framework-files`): it is a framework's root marker.
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
let state = self.state();
let modes = state.languages.iter().flat_map(|l| &l.framework_ui_modes).map(|f| &f.root_markers);
let files = state.languages.iter().flat_map(|l| &l.framework_files).map(|f| &f.root_markers);
modes.chain(files).flatten().any(|m| crate::markers::is_marker_path(path, m))
self.state()
.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
}
/// Detects by Override File Type, exact file name, then file-name pattern
@@ -400,9 +400,6 @@ impl LanguageRegistry {
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
return languages[*i].id.clone();
}
if let Some(l) = languages.iter().find(|l| l.framework_files.iter().any(|f| is_framework_file(f, path))) {
return l.id.clone();
}
let lower = file_name.to_ascii_lowercase();
let mut rest = lower.as_str();
while let Some(dot) = rest.find('.') {
@@ -423,19 +420,6 @@ impl LanguageRegistry {
}
}
/// `path` is one of `files`: its extension is listed, and the nearest folder above it named
/// `files.folder` has a root marker in a directory above that folder.
fn is_framework_file(files: &crate::FrameworkFiles, path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false };
if !files.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
return false;
}
path.ancestors()
.skip(1)
.find(|dir| dir.file_name().is_some_and(|name| name == files.folder.as_str()))
.is_some_and(|folder| crate::marked_root(None, folder, &files.root_markers).is_some())
}
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
fn wildcard_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
@@ -513,7 +497,6 @@ mod tests {
"Brainfuck",
"Cobol",
"C#",
"Django Template",
"Clojure",
"ClojureScript",
"Closure Stylesheets (GSS)",
@@ -818,36 +801,6 @@ mod tests {
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
#[test]
fn django_projects_make_html_in_templates_folders_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("src/orders/templates/orders/email")).unwrap();
std::fs::create_dir_all(root.join("site/templates")).unwrap();
std::fs::write(root.join("src/manage.py"), "").unwrap();
assert_eq!(detect("src/orders/templates/orders/detail.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/email/body.TXT"), "django-html");
assert_eq!(detect("src/templates/base.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/data.json"), "json");
assert_eq!(detect("src/orders/static/orders/page.html"), "html");
// No `manage.py` above this `templates` folder.
assert_eq!(detect("site/templates/index.html"), "html");
assert_eq!(detect("page.djhtml"), "django-html");
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/django-app");
let detect = |file: &str| reg.detect(&fixture.join(file), None).as_str().to_owned();
assert_eq!(detect("shop/templates/shop/product_list.html"), "django-html");
assert_eq!(detect("shop/templates/shop/email/order.txt"), "django-html");
assert_eq!(detect("pages/promo.html"), "html");
}
#[test]
fn markers_outside_the_project_do_not_make_angular_templates() {
let reg = reg();
-1
Переглянути файл
@@ -29,5 +29,4 @@ zip.workspace = true
[dev-dependencies]
tempfile.workspace = true
encoding_rs.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
-223
Переглянути файл
@@ -1,223 +0,0 @@
//! go.mod as GoLand shows it: available upgrades, vulnerabilities and deprecated modules are
//! reported as soon as the file is opened. gopls reports upgrades only from its "Check for
//! upgrades" code lens, so that runs once per go.mod and session; its results (and the
//! vulnerabilities from the `vulncheck` setting) come back as gopls diagnostics with quick fixes.
//! gopls never reports a deprecated module, so `go list -m -u` does, on open and on save.
use crate::LspManager;
use crate::server::Server;
use ide_core::{DocId, Pos, Range};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
pub(crate) const CHECK_UPGRADES: &str = "gopls.check_upgrades";
/// gopls answers code lenses once it has loaded the module.
const ATTEMPTS: u32 = 10;
const RETRY: Duration = Duration::from_secs(2);
/// Producer of the deprecation diagnostics (one full set per go.mod).
const DEPRECATIONS: &str = "go-mod-deprecations";
fn is_go_mod(path: &Path) -> bool {
path.file_name().is_some_and(|n| n == "go.mod")
}
impl LspManager {
pub(crate) async fn check_go_mod(&self, doc: DocId, path: &Path, server: Arc<Server>) {
if server.name != "gopls" || !is_go_mod(path) {
return;
}
self.report_deprecated_modules(doc, &server).await;
if !self.go_mod_checked.lock().insert(path.to_owned()) {
return;
}
for attempt in 0..ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(RETRY).await;
}
if !self.docs.lock().contains_key(&doc) {
break;
}
let Ok(lenses) = self.code_lens(doc).await else { continue };
let Some(command) = lenses.into_iter().filter_map(|l| l.command).find(|c| c.command == CHECK_UPGRADES)
else {
continue;
};
if let Err(e) = self.execute_command(doc, command).await {
tracing::debug!("gopls: {CHECK_UPGRADES} for {}: {}", path.display(), e.message);
}
return;
}
// Not checked after all (closed, or gopls never offered the lens): a later open retries.
self.go_mod_checked.lock().remove(path);
}
/// After a save of a go.mod: its requirements may have changed.
pub(crate) fn go_mod_saved(&self, doc: DocId) {
let server = {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc).filter(|d| is_go_mod(&d.path)) else { return };
let Some(a) = d.servers.iter().find(|a| a.server.name == "gopls") else { return };
a.server.clone()
};
let Some(me) = self.me.upgrade() else { return };
self.runtime.spawn(async move { me.report_deprecated_modules(doc, &server).await });
}
async fn report_deprecated_modules(&self, doc: DocId, server: &Server) {
let Some((path, text)) = self.docs.lock().get(&doc).map(|d| (d.path.clone(), d.text.as_str().to_owned()))
else {
return;
};
let required = required_modules(&text);
if required.is_empty() {
return;
}
let config = server.effective_config();
let search = crate::launch::server_search_path(&config);
let Some(dir) = path.parent().map(Path::to_path_buf) else { return };
let modules: Vec<String> = required.iter().map(|r| r.path.clone()).collect();
let listed = tokio::task::spawn_blocking(move || list_deprecations(&dir, &search, &modules)).await;
let Ok(Some(deprecated)) = listed else { return };
// The document changed meanwhile: its own save runs again.
if self.docs.lock().get(&doc).is_none_or(|d| d.text.as_str() != text) {
return;
}
let items = required
.into_iter()
.filter_map(|r| {
let message = deprecated.iter().find(|(p, _)| *p == r.path)?.1.trim();
Some(ide_core::Diagnostic {
range: r.range,
severity: ide_core::Severity::Warning,
message: format!("{} is deprecated: {message}", r.path),
source: Some("go".into()),
code: None,
unnecessary: false,
deprecated: true,
server: Some("gopls".into()),
})
})
.collect();
self.sink().emit(ide_core::IdeEvent::Diagnostics { path: path.into(), source: DEPRECATIONS.into(), items });
}
}
#[derive(Debug, PartialEq)]
struct Required {
path: String,
/// The module path in go.mod (UTF-16 columns).
range: Range,
}
/// Modules of go.mod's `require` directives, single-line or in a block.
fn required_modules(text: &str) -> Vec<Required> {
let mut out = Vec::new();
let mut in_block = false;
for (line, raw) in text.lines().enumerate() {
let code = raw.split("//").next().unwrap_or("");
let trimmed = code.trim_start();
let entry = if in_block {
if trimmed.starts_with(')') {
in_block = false;
continue;
}
trimmed
} else if let Some(rest) = trimmed.strip_prefix("require").filter(|r| r.starts_with(char::is_whitespace)) {
let rest = rest.trim_start();
if rest.starts_with('(') {
in_block = true;
continue;
}
rest
} else {
continue;
};
let mut words = entry.split_whitespace();
let (Some(module), Some(_version)) = (words.next(), words.next()) else { continue };
let module = module.trim_matches('"');
let Some(start) = code.find(module) else { continue };
let col = |byte: usize| code[..byte].encode_utf16().count() as u32;
let line = line as u32;
out.push(Required {
path: module.to_owned(),
range: Range { start: Pos { line, col: col(start) }, end: Pos { line, col: col(start + module.len()) } },
});
}
out
}
#[derive(Deserialize)]
struct ListedModule {
#[serde(rename = "Path")]
path: String,
#[serde(rename = "Deprecated", default)]
deprecated: Option<String>,
}
/// `(module, message)` of each deprecated module in `modules`; `None` when `go` failed.
fn list_deprecations(dir: &Path, search: &std::ffi::OsStr, modules: &[String]) -> Option<Vec<(String, String)>> {
let go: PathBuf = ide_core::tool_path::find_command("go", search, dir)?;
let output = ide_core::process::prepare(&mut std::process::Command::new(go))
.args(["list", "-m", "-u", "-e", "-json"])
.args(modules)
.current_dir(dir)
.env("PATH", search)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.stdout.is_empty() {
tracing::debug!("go list -m -u in {}: {}", dir.display(), output.status);
return None;
}
Some(parse_deprecations(&output.stdout))
}
fn parse_deprecations(json: &[u8]) -> Vec<(String, String)> {
serde_json::Deserializer::from_slice(json)
.into_iter::<ListedModule>()
.map_while(Result::ok)
.filter_map(|m| Some((m.path, m.deprecated.filter(|d| !d.trim().is_empty())?)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn at(line: u32, from: u32, to: u32) -> Range {
Range { start: Pos { line, col: from }, end: Pos { line, col: to } }
}
#[test]
fn finds_required_modules_in_blocks_and_single_lines() {
let text = "module example.com/m\n\ngo 1.24\n\nrequire github.com/a/b v1.0.0 // indirect\n\
require (\n\tgithub.com/c/d v1.2.0\n\t\"github.com/e/é\" v0.1.0 // indirect\n)\n\
// require github.com/x/y v1.0.0\nreplace github.com/c/d => ../d\ntool golang.org/x/tools/cmd/stringer\n";
assert_eq!(
required_modules(text),
vec![
Required { path: "github.com/a/b".into(), range: at(4, 8, 22) },
Required { path: "github.com/c/d".into(), range: at(6, 1, 15) },
Required { path: "github.com/e/é".into(), range: at(7, 2, 16) },
]
);
}
#[test]
fn reads_deprecations_from_the_go_list_stream() {
let json = br#"{"Path": "github.com/golang/protobuf", "Version": "v1.5.4",
"Deprecated": "Use the \"google.golang.org/protobuf\" module instead."}
{"Path": "github.com/sergi/go-diff", "Version": "v1.3.1"}
{"Path": "example.com/blank", "Deprecated": " "}"#;
assert_eq!(
parse_deprecations(json),
vec![(
"github.com/golang/protobuf".to_owned(),
"Use the \"google.golang.org/protobuf\" module instead.".to_owned()
)]
);
}
}
+1 -1
Переглянути файл
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub(crate) const SERVER: &str = "jdtls";
const SERVER: &str = "jdtls";
pub(crate) mod configuration;
mod formatter;
+1 -1
Переглянути файл
@@ -146,7 +146,7 @@ fn required_env_set(config: &LanguageServerConfig) -> bool {
}
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
pub(crate) fn server_search_path(config: &LanguageServerConfig) -> OsString {
fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
}
+26 -234
Переглянути файл
@@ -15,7 +15,6 @@ mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod go_mod;
mod go_scope;
mod handler;
mod injections;
@@ -222,8 +221,6 @@ pub struct LspManager {
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// go.mod files whose upgrades gopls has been asked to check this session.
go_mod_checked: Mutex<HashSet<PathBuf>>,
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
/// Contents fetched for virtual URIs.
@@ -329,41 +326,6 @@ const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis
/// Implementation requests of one line-marker pass in flight at once.
const IMPLEMENTATION_CONCURRENCY: usize = 4;
/// How a virtual document's content was decoded, and whether it can be
/// re-decoded ("Reload with Encoding").
struct Decoding {
encoding: String,
has_bom: bool,
can_reload: bool,
}
impl Decoding {
/// Text the server decoded itself: no raw bytes here to re-read.
fn server_text() -> Self {
Decoding { encoding: "utf-8".into(), has_bom: false, can_reload: false }
}
}
/// jdtls' source attachment of a `jdt:` class (`java.project.resolveSourceAttachment`).
struct SourceAttachment {
path: Option<String>,
encoding: Option<String>,
can_edit_encoding: bool,
}
/// Reads a `jar:` document's source: an entry as is, a class from the sources
/// jar next to its binary. `encoding` decodes with it instead of detecting.
fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs::LoadedText)> {
match library_source::parse(uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry, encoding).map(|loaded| (e.entry, loaded))
}
Some(_) => library_source::class_source(uri, encoding)
.and_then(|(source, loaded)| Some((library_source::parse(&source)?.entry, loaded))),
None => None,
}
}
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
/// in the artifact's sources jar when it is cached, at the declaration of the
/// name navigated from; [`LspManager::virtual_document`] serves it.
@@ -378,9 +340,8 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
return loc;
};
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
let Some((source, text)) = library_source::class_source(uri) else { return loc };
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
Location { path: source.clone().into(), range, uri: Some(source) }
})
.collect()
@@ -472,7 +433,6 @@ impl LspManager {
debug_servers: Mutex::new(HashMap::new()),
configuration_cache: Arc::new(Mutex::new(Default::default())),
shown_messages: Mutex::new(HashSet::new()),
go_mod_checked: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
injections: injections::State::default(),
@@ -1072,7 +1032,6 @@ impl LspManager {
servers: Vec<Arc<Server>>,
) {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let canonical_path = canonical.clone();
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
let mut state = DocState {
path,
@@ -1103,13 +1062,6 @@ impl LspManager {
self.schedule_pull(doc, None, Duration::ZERO);
for s in &servers {
self.ensure_started(s);
// Waits for a server still starting, so it is not tied to `running()` below.
if s.name == "gopls"
&& let Some(me) = self.me.upgrade()
{
let (path, server) = (canonical_path.clone(), s.clone());
self.runtime.spawn(async move { me.check_go_mod(doc, &path, server).await });
}
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
@@ -1251,7 +1203,6 @@ impl LspManager {
drop(docs);
self.schedule_pull(doc, None, Duration::ZERO);
self.pull_dependents(doc);
self.go_mod_saved(doc);
}
pub fn did_close(&self, doc: DocId) {
@@ -3069,9 +3020,8 @@ impl LspManager {
let original = uri.to_owned();
let archive = sources.clone();
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&original, &archive, None).map(|(uri, loaded)| {
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
Location { path: uri.clone().into(), uri: Some(uri), range }
})
})
@@ -3113,10 +3063,28 @@ impl LspManager {
if scheme == "jar" {
let found = {
let uri = uri.to_owned();
tokio::task::spawn_blocking(move || read_jar_source(&uri, None)).await.ok().flatten()
tokio::task::spawn_blocking(move || match library_source::parse(&uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text))
}
Some(_) => library_source::class_source(&uri)
.and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))),
None => None,
})
.await
.ok()
.flatten()
};
if let Some((entry, loaded)) = found {
return Ok(self.store_jar_entry(uri, entry, loaded));
if let Some((entry, text)) = found {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
return Ok(self.store_virtual(
uri,
text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
));
}
}
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
@@ -3187,21 +3155,7 @@ impl LspManager {
continue;
}
};
// jdtls decodes attached sources itself: its attachment encoding is
// the document's real charset, and editable ones can be re-decoded.
let decoding = if scheme == "jdt" && t.server.name == jdtls::SERVER && !virtual_doc::is_decompiled(&text) {
match self.jdt_source_attachment(uri).await {
Some(att) if att.path.is_some() => Decoding {
encoding: att.encoding.unwrap_or_else(|| "utf-8".into()),
has_bom: false,
can_reload: att.can_edit_encoding,
},
_ => Decoding::server_text(),
}
} else {
Decoding::server_text()
};
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id), decoding));
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
}
if jar_class && error.is_none() {
return Err(IdeError::not_found(format!(
@@ -3213,7 +3167,6 @@ impl LspManager {
}
/// Caches `text` as the virtual document `uri`, titled `title` (else after the URI).
#[allow(clippy::too_many_arguments)]
fn store_virtual(
&self,
uri: &str,
@@ -3222,7 +3175,6 @@ impl LspManager {
description: Option<String>,
languages: &[LanguageId],
server: Option<ide_core::ServerId>,
decoding: Decoding,
) -> VirtualDocument {
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
virtual_doc::is_csharp_decompiled(uri)
@@ -3243,9 +3195,6 @@ impl LspManager {
title,
description: description.or_else(|| virtual_doc::description(uri)),
decompiled,
encoding: decoding.encoding,
has_bom: decoding.has_bom,
can_reload_encoding: decoding.can_reload,
};
if let Some(server) = server {
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
@@ -3258,163 +3207,6 @@ impl LspManager {
doc
}
/// Caches a decoded `jar:` entry under the document's original URI, titled
/// after the entry; the backend owns these bytes, so they can be re-decoded.
fn store_jar_entry(&self, uri: &str, entry: String, loaded: ide_vfs::LoadedText) -> VirtualDocument {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
self.store_virtual(
uri,
loaded.text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
)
}
/// The source attachment jdtls resolves for a `jdt:` class; `None` when the
/// command is unavailable or the class's container cannot be queried.
async fn jdt_source_attachment(&self, uri: &str) -> Option<SourceAttachment> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.resolveSourceAttachment",
vec![json!({ "classFileUri": uri })],
)
.await
.ok()?;
if result.get("errorMessage").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
return None;
}
let attrs = result.get("attributes")?;
let non_empty = |key: &str| attrs.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_owned);
Some(SourceAttachment {
path: non_empty("sourceAttachmentPath"),
encoding: non_empty("sourceAttachmentEncoding"),
can_edit_encoding: attrs.get("canEditEncoding").and_then(Value::as_bool).unwrap_or(true),
})
}
/// Updates a `jdt:` class's source attachment encoding. The command drops
/// the attachment when the path is omitted, so the resolved path is always
/// sent along with the encoding.
async fn update_jdt_source_encoding(&self, uri: &str, path: &str, encoding: &str) -> IdeResult<()> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.updateSourceAttachment",
vec![json!({
"classFileUri": uri,
"attributes": { "sourceAttachmentPath": path, "sourceAttachmentEncoding": encoding },
})],
)
.await?;
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
return Err(IdeError::unavailable(error));
}
Ok(())
}
/// Waits until the resolved attachment reports `encoding`; `false` once
/// `until` passes. Maven applies attachment updates in a background job,
/// so the new encoding is not visible immediately after the update.
async fn wait_for_jdt_source_encoding(&self, uri: &str, encoding: &str, until: tokio::time::Instant) -> bool {
loop {
match self.jdt_source_attachment(uri).await {
Some(att) if att.encoding.as_deref() == Some(encoding) => return true,
_ if tokio::time::Instant::now() < until => {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
_ => return false,
}
}
}
/// "Reload with Encoding" for a virtual document: re-reads its origin with
/// `encoding` and replaces the cached content. `jar:` entries are re-read
/// here; `jdt:` sources are decoded by jdtls, so its source attachment
/// encoding is updated and the contents fetched again (keeping the server's
/// text and positions in sync). Server-generated content cannot be re-decoded.
pub async fn reload_virtual(&self, uri: &str, encoding: &str) -> IdeResult<VirtualDocument> {
ide_vfs::encode("", encoding, false)?;
match virtual_doc::scheme(uri) {
"jar" => {
let (owned_uri, owned_encoding) = (uri.to_owned(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || read_jar_source(&owned_uri, Some(&owned_encoding)))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
let Some((entry, loaded)) = found else {
return Err(IdeError::not_found(format!(
"no source is available for {}",
virtual_doc::title(uri, false)
)));
};
Ok(self.store_jar_entry(uri, entry, loaded))
}
"jdt" if self.origin_server(uri).is_none_or(|s| s.name == jdtls::SERVER) => {
let Some(att) = self.jdt_source_attachment(uri).await else {
return Err(IdeError::unavailable("jdtls cannot resolve the source attachment of this class"));
};
let Some(path) = att.path else {
return Err(IdeError::invalid("this class has no attached sources to re-decode"));
};
if !att.can_edit_encoding {
return Err(IdeError::invalid("the source attachment encoding of this library cannot be changed"));
}
self.update_jdt_source_encoding(uri, &path, encoding).await?;
// Maven containers apply the update in a background job (persist
// attached sources, then refresh the classpath): wait until the
// resolved attachment reports the new encoding, else the refetch
// below would still decode with the old one.
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
let first_try = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
if !self.wait_for_jdt_source_encoding(uri, encoding, first_try).await {
// jdtls matches the existing encoding attribute by reference
// (`==`), so an entry restored from the saved container state
// gains a duplicate attribute and keeps the old value. That
// update still refreshes the container, after which a second
// update replaces the value correctly.
self.update_jdt_source_encoding(uri, &path, encoding).await?;
if !self.wait_for_jdt_source_encoding(uri, encoding, deadline).await {
return Err(IdeError::unavailable(
"jdtls did not apply the source attachment encoding; try reloading again",
));
}
}
// JDT keeps the class file's decoded buffer cached, so
// classFileContents would still return the old decoding: re-read
// the attached source entry here, like `jar:` documents. The
// server's open document is kept in sync by the regular
// document change flow.
let (owned_uri, owned_path, owned_encoding) = (uri.to_owned(), path.clone(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&owned_uri, Path::new(&owned_path), Some(&owned_encoding))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if let Some((_source_uri, loaded)) = found {
return Ok(self.store_virtual(
uri,
loaded.text,
None,
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
));
}
// Attachments that are not readable archives (e.g. folders):
// fall back to the server's contents.
self.virtual_docs.lock().remove(uri);
self.virtual_document(uri).await
}
_ => Err(IdeError::invalid(
"this document's content is generated by a language server and cannot be re-decoded with another encoding",
)),
}
}
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
/// so edits flow through `ide-workspace::Documents`.
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
+33 -119
Переглянути файл
@@ -7,8 +7,8 @@
//! it in the Maven or Gradle cache.
use ide_core::{Pos, Range};
use ide_vfs::LoadedText;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use std::io::Read;
use std::path::{Path, PathBuf};
/// An entry of an archive named by a `jar:` URI.
@@ -57,32 +57,29 @@ impl JarEntry {
}
}
/// The text of `entry` in the archive `jar`, decoded like a file opened in the
/// editor; `encoding` decodes with it instead of detecting ("Reload with
/// Encoding"). `None` for binary or unreadable entries.
pub(crate) fn read_entry(jar: &Path, entry: &str, encoding: Option<&str>) -> Option<LoadedText> {
let path = ide_vfs::archive_entry_path(jar, entry);
let loaded = match encoding {
Some(enc) => ide_vfs::load_text_as(&path, enc).ok()?,
None => ide_vfs::load_text(&path).ok()?,
};
(!loaded.binary).then_some(loaded)
/// The text of `entry` in the archive `jar`, when it is a UTF-8 file.
pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option<String> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
let mut file = zip.by_name(entry).ok()?;
let mut text = String::new();
file.read_to_string(&mut text).ok()?;
Some(text)
}
/// The library source for a class entry: its `.kt` / `.java` file in the
/// artifact's sources jar, as `(uri, text)`.
pub(crate) fn class_source(uri: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
let jar = parse(uri)?;
let class = jar.entry.strip_suffix(".class")?;
for sources in sources_jars(&jar.jar) {
if let Some((entry, text)) = source_entry(&sources, class, encoding) {
if let Some((entry, text)) = source_entry(&sources, class) {
return Some((jar.uri_of(&sources, &entry), text));
}
}
None
}
pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str>) -> Option<(String, LoadedText)> {
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
let jar = if uri.starts_with("jdt://contents/") {
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
let parts: Vec<_> = path.split('/').collect();
@@ -99,7 +96,7 @@ pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str
parse(uri)?
};
let class = jar.entry.strip_suffix(".class")?;
let (entry, text) = source_entry(sources, class, encoding)?;
let (entry, text) = source_entry(sources, class)?;
Some((jar.uri_of(sources, &entry), text))
}
@@ -120,8 +117,8 @@ fn sources_jars(jar: &Path) -> Vec<PathBuf> {
}
/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`.
fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
let (package, name) = class.rsplit_once('/').unwrap_or(("", class));
let outer = name.split('$').next().unwrap_or(name);
let dir = if package.is_empty() { String::new() } else { format!("{package}/") };
@@ -130,9 +127,13 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
candidates.push(format!("{dir}{file}.kt"));
}
let read = |entry: &str| read_entry(sources, entry, encoding);
let read = |zip: &mut zip::ZipArchive<std::fs::File>, entry: &str| {
let mut text = String::new();
zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?;
Some(text)
};
for entry in &candidates {
if let Some(text) = read(entry) {
if let Some(text) = read(&mut zip, entry) {
return Some((entry.clone(), text));
}
}
@@ -144,8 +145,8 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
.collect();
let jvm_name = format!("@file:JvmName(\"{outer}\")");
for entry in kotlin_files {
if let Some(text) = read(&entry)
&& text.text.contains(&jvm_name)
if let Some(text) = read(&mut zip, &entry)
&& text.contains(&jvm_name)
{
return Some((entry, text));
}
@@ -232,15 +233,11 @@ mod tests {
use std::io::Write;
fn jar(path: &Path, entries: &[(&str, &str)]) {
jar_bytes(path, &entries.iter().map(|(n, t)| (*n, t.as_bytes())).collect::<Vec<_>>());
}
fn jar_bytes(path: &Path, entries: &[(&str, &[u8])]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, bytes) in entries {
for (name, text) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
@@ -277,12 +274,12 @@ mod tests {
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
] {
let (uri, text) = source_in_archive(uri, &sources, None).unwrap();
let (uri, text) = source_in_archive(uri, &sources).unwrap();
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
assert!(text.text.contains("class Lib"));
assert!(text.contains("class Lib"));
}
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources, None).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources, None).is_none());
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
}
#[test]
@@ -315,103 +312,20 @@ mod tests {
("org/springframework/boot/Builder.class", "Builder.kt"),
("Root.class", "Root.java"),
] {
let (source_uri, text) =
class_source(&uri(class), None).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/"));
assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}");
assert_eq!(
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry, None)).map(|l| l.text),
Some(text.text.clone())
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
Some(text.as_str())
);
}
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
}
// No sources jar: nothing to show from here.
let lone = tmp.path().join("lone/x-1.jar");
jar(&lone, &[]);
assert!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/")), None).is_none());
}
/// The reported case: a library source file saved as GBK bytes, not UTF-8.
const GBK_SAMPLE: &str = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {\n public static String message() {\n return \"中文编码测试:是否启用\";\n }\n}\n";
#[test]
fn archive_entries_decode_like_files_opened_in_the_editor() {
let tmp = tempfile::tempdir().unwrap();
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
// encoding_rs' encode() falls back to UTF-8 for UTF-16 (WHATWG has no encoder for it).
let mut utf16 = vec![0xFF, 0xFE];
utf16.extend(GBK_SAMPLE.encode_utf16().flat_map(u16::to_le_bytes));
let cases: Vec<(&str, Vec<u8>, &str, &str, bool)> = vec![
("utf8.java", GBK_SAMPLE.as_bytes().to_vec(), GBK_SAMPLE, "utf-8", false),
("bom.java", [b"\xEF\xBB\xBF", GBK_SAMPLE.as_bytes()].concat(), GBK_SAMPLE, "utf-8", true),
("gbk.java", gbk, GBK_SAMPLE, "gbk", false),
("utf16.java", utf16, GBK_SAMPLE, "utf-16le", true),
];
for (name, bytes, text, encoding, has_bom) in cases {
let jar_path = tmp.path().join(format!("lib-{name}.jar"));
jar_bytes(&jar_path, &[(&format!("p/{name}"), &bytes)]);
let loaded = read_entry(&jar_path, &format!("p/{name}"), None).unwrap_or_else(|| panic!("{name}"));
assert_eq!(loaded.text, text, "{name}");
assert_eq!(loaded.encoding, encoding, "{name}");
assert_eq!(loaded.has_bom, has_bom, "{name}");
assert!(!loaded.binary, "{name}");
}
// Binary entries are not shown as text.
let jar_path = tmp.path().join("bin.jar");
jar_bytes(&jar_path, &[("p/img.png", &[0x89, b'P', b'N', b'G', 0, 0, 0, 0])]);
assert!(read_entry(&jar_path, "p/img.png", None).is_none());
assert!(read_entry(&jar_path, "p/missing.java", None).is_none());
}
#[test]
fn reload_decodes_archive_entries_with_the_chosen_encoding() {
let tmp = tempfile::tempdir().unwrap();
let jar_path = tmp.path().join("lib.jar");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
jar_bytes(&jar_path, &[("p/EncodingSample.java", &gbk)]);
let loaded = read_entry(&jar_path, "p/EncodingSample.java", Some("gbk")).unwrap();
assert_eq!((loaded.text.as_str(), loaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
// An explicit encoding wins over detection, even when it decodes to replacement characters.
let forced = read_entry(&jar_path, "p/EncodingSample.java", Some("utf-8")).unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
assert!(read_entry(&jar_path, "p/EncodingSample.java", Some("not-an-encoding")).is_none());
}
#[test]
fn class_sources_in_maven_caches_decode_non_utf8() {
let tmp = tempfile::tempdir().unwrap();
let m2 = tmp.path().join("m2/com/example/encoding-sample/1.0.0");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
let facade = "@file:JvmName(\"SampleUtils\")\npackage example.encoding\n\n/** 中文注释。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let facade_gbk = encoding_rs::GBK.encode(facade).0.into_owned();
jar(&m2.join("encoding-sample-1.0.0.jar"), &[]);
jar_bytes(
&m2.join("encoding-sample-1.0.0-sources.jar"),
&[("example/encoding/EncodingSample.java", &gbk), ("example/encoding/Renamed.kt", &facade_gbk)],
);
let uri = |class: &str| {
format!("jar://{}!/{class}", m2.join("encoding-sample-1.0.0.jar").to_string_lossy().replace('\\', "/"))
};
for (class, text) in
[("example/encoding/EncodingSample.class", GBK_SAMPLE), ("example/encoding/SampleUtils.class", facade)]
{
let (source_uri, loaded) = class_source(&uri(class), None).unwrap_or_else(|| panic!("{class}"));
assert!(
source_uri.ends_with("-sources.jar!/example/encoding/EncodingSample.java")
|| source_uri.ends_with("-sources.jar!/example/encoding/Renamed.kt")
);
assert_eq!(loaded.text, text, "{class}");
assert_eq!(loaded.encoding, "gbk", "{class}");
// The manually attached archive path decodes the same way.
let jdt = "jdt://contents/encoding-sample-1.0.0.jar/example.encoding/EncodingSample.class?=project/x";
let (_, attached) = source_in_archive(jdt, &m2.join("encoding-sample-1.0.0-sources.jar"), None).unwrap();
assert_eq!((attached.text.as_str(), attached.encoding.as_str()), (GBK_SAMPLE, "gbk"));
}
// Reload with an explicit encoding re-reads the same source.
let (_, reloaded) = class_source(&uri("example/encoding/EncodingSample.class"), Some("gbk")).unwrap();
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
}
#[test]
-8
Переглянути файл
@@ -357,14 +357,6 @@ pub struct VirtualDocument {
pub description: Option<String>,
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
pub decompiled: bool,
/// Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
/// `utf-8` for text the server decoded itself.
pub encoding: String,
pub has_bom: bool,
/// "Reload with Encoding" can re-decode the origin's bytes (archive
/// entries, JDT sources with an editable attachment encoding); false for
/// server-generated content such as decompiled code.
pub can_reload_encoding: bool,
}
/// Reference updates for a file / folder move (`workspace/willRenameFiles`).
+10 -64
Переглянути файл
@@ -23,20 +23,9 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'}');
pub fn path_to_uri_string(path: &Path) -> String {
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
} else {
path.to_string_lossy().into_owned()
};
let s = path.to_string_lossy();
let s = if cfg!(windows) { s.replace('\\', "/") } else { s.into_owned() };
let mut out = String::from("file://");
// `\\server\share\p` → `file://server/share/p` (RFC 8089 Appendix E.3).
let unc = s.strip_prefix("//?/UNC/").or_else(|| s.strip_prefix("//")).filter(|_| cfg!(windows));
if let Some(unc) = unc {
let (host, rest) = unc.split_at(unc.find('/').unwrap_or(unc.len()));
out.extend(utf8_percent_encode(host, PATH));
out.extend(utf8_percent_encode(rest, PATH));
return out;
}
if !s.starts_with('/') {
out.push('/');
}
@@ -52,30 +41,21 @@ pub fn path_to_uri(path: &Path) -> lsp_types::Uri {
})
}
/// Converts a `file:` URI to a path; `None` for other schemes. On Windows a
/// host names a share (`file://server/share/p` → `\\server\share\p`) and the
/// drive letter is upper-cased, as the rest of the IDE spells it.
/// Converts a `file:` URI to a path; `None` for other schemes.
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("FILE://"))?;
let (host, path) = rest.split_at(rest.find('/')?);
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
// Skip an authority (`file://localhost/...`).
let path = match rest.find('/') {
Some(0) => rest,
Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..],
_ => return None,
};
let path = path.split(['?', '#']).next().unwrap_or(path);
let decoded = percent_decode_str(path).decode_utf8().ok()?;
if cfg!(windows) {
if !local {
let host = percent_decode_str(host).decode_utf8().ok()?;
return Some(PathBuf::from(format!(r"\\{host}{}", decoded.replace('/', "\\"))));
}
// `/C:/x` → `C:\x`
let trimmed = decoded.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
let mut path = trimmed.replace('/', "\\");
if path.as_bytes().get(1) == Some(&b':') {
path[..1].make_ascii_uppercase();
}
return Some(PathBuf::from(path));
}
if !local {
return None;
return Some(PathBuf::from(trimmed.replace('/', "\\")));
}
Some(PathBuf::from(decoded.into_owned()))
}
@@ -109,40 +89,6 @@ mod tests {
assert_eq!(uri_to_path("jdt://contents/x"), None);
}
#[cfg(unix)]
#[test]
fn remote_hosts_are_not_local_paths() {
assert_eq!(uri_to_path("file://server/share/a.ts"), None);
}
#[cfg(windows)]
#[test]
fn windows_uris() {
for (uri, path) in [
("file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
("file:///c:/x/y.ts", r"C:\x\y.ts"),
("file:///c%3A/x/y.ts", r"C:\x\y.ts"),
("file:///C%3a/x", r"C:\x"),
("file://localhost/d:/x", r"D:\x"),
("file://server/share/a.ts", r"\\server\share\a.ts"),
("file://server/share/a%20b/c.ts", r"\\server\share\a b\c.ts"),
("file:////server/share/a.ts", r"\\server\share\a.ts"),
] {
let got = uri_to_path(uri).unwrap();
assert_eq!(got.as_os_str(), path, "{uri}");
}
assert_eq!(uri_to_path("file://server"), None);
for (path, uri, back) in [
(r"C:\a b\c.rs", "file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
(r"\\?\C:\x\y.ts", "file:///C:/x/y.ts", r"C:\x\y.ts"),
(r"\\server\share\a b.ts", "file://server/share/a%20b.ts", r"\\server\share\a b.ts"),
(r"\\?\UNC\server\share\a.ts", "file://server/share/a.ts", r"\\server\share\a.ts"),
] {
assert_eq!(path_to_uri(Path::new(path)).as_str(), uri, "{path}");
assert_eq!(uri_to_path(uri).unwrap().as_os_str(), back, "{path}");
}
}
#[test]
fn virtual_targets() {
let jdt = "jdt://contents/rt.jar/java.lang/String.class?=p/%5C/x.jar%3Cjava.lang(String.class";
-335
Переглянути файл
@@ -306,7 +306,6 @@ fn harness_configured(
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -3669,340 +3668,6 @@ async fn jdt_class_file_contents() {
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_library_sources_reload_with_the_attachment_encoding() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "import example.encoding.EncodingSample;\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/encoding-sample-1.0.jar/example.encoding/EncodingSample.class?=demo/%5C/m2%5C/encoding-sample-1.0.jar%3Cexample.encoding%28EncodingSample.class";
// The mock jdtls decodes the GBK attachment with the configured charset:
// until the encoding is updated, the text arrives garbled (U+FFFD).
let source = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {}";
let garbled =
"package example.encoding;\n\n/** \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} */\npublic final class EncodingSample {}";
let attachment_encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = attachment_encoding.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let text = if encoding.lock().as_deref() == Some("gbk") { source } else { garbled };
Some(json!(text))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"errorMessage": null,
"attributes": {
"jarPath": "/m2/encoding-sample-1.0.jar",
"sourceAttachmentPath": "/m2/encoding-sample-1.0-sources.jar",
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let attrs = &params["arguments"][0]["attributes"];
*encoding.lock() = attrs["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null, "attributes": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert_eq!(vd.text, garbled);
assert_eq!(vd.encoding, "utf-8", "no attachment encoding configured yet");
assert!(vd.can_reload_encoding);
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source);
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
// The update kept the attachment path (omitting it would drop the sources)
// and set the encoding; the contents were fetched again.
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 1);
assert_eq!(updates[0]["arguments"][0]["classFileUri"], jdt);
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentPath"], "/m2/encoding-sample-1.0-sources.jar");
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentEncoding"], "gbk");
assert!(s.requested("java/classFileContents").len() >= 2);
// The cache serves the re-decoded text without another round trip.
assert_eq!(h.mgr.virtual_document(jdt).await.unwrap().text, source);
// Round trip back to UTF-8.
let back = h.mgr.reload_virtual(jdt, "utf-8").await.unwrap();
assert_eq!(back.text, garbled);
assert_eq!(back.encoding, "utf-8");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_waits_for_the_maven_container_refresh() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// m2e persists and refreshes the container in a background job: resolve
// keeps reporting the old encoding for a few calls after the update.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
let reported = if st.1 > 2 { st.0.clone() } else { None };
Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": reported,
"canEditEncoding": true,
}
}))
}
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
st.1 = 0;
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
assert!(state.lock().1 >= 3, "polled until the container reported the new encoding");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_retries_when_a_restored_container_ignores_the_update() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// jdtls replaces the encoding attribute by reference equality: an entry
// restored from the saved container state keeps the old value on the first
// update (a duplicate attribute is appended); only a second update, after
// the container was refreshed, applies the new one.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": state.lock().0.clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
if st.1 > 1 {
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"]
.as_str()
.map(str::to_owned);
}
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 2, "the ignored first update was retried once");
assert!(updates.iter().all(|u| u["arguments"][0]["attributes"]["sourceAttachmentPath"] == "/m2/lib-sources.jar"));
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_reads_the_attachment_when_the_server_buffer_is_stale() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let sources = tmp.path().join("lib-sources.jar");
let source = "package p; /** 中文 */ class Lib {}";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let mut zip = zip::ZipWriter::new(std::fs::File::create(&sources).unwrap());
zip.start_file("p/Lib.java", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(&gbk).unwrap();
zip.finish().unwrap();
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// JDT caches the decoded buffer of a class file: even after the attachment
// encoding is updated, classFileContents keeps returning the old decoding.
let encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = encoding.clone();
let sources = sources.clone();
move |_, method, params| match method {
"java/classFileContents" => Some(json!("package p; /** \u{FFFD} */ class Lib {}")),
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": sources.to_string_lossy(),
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
*encoding.lock() =
params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source, "read from the attachment, not the stale server buffer");
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_is_refused_when_the_sources_cannot_be_re_decoded() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let class =
|name: &str| format!("jdt://contents/lib.jar/p/{name}.class?=demo/%5C/m2%5C/lib.jar%3Cp%28{name}.class");
s.respond("java/classFileContents", json!("package p;\npublic class Lib {}"));
// A container that does not allow editing the attachment encoding.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": "/m2/lib-sources.jar", "canEditEncoding": false }
})),
_ => None,
}));
let vd = h.mgr.virtual_document(&class("ReadOnly")).await.unwrap();
assert!(!vd.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("ReadOnly"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot be changed"), "{err}");
// Decompiled content has no attached sources to re-decode.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": null, "canEditEncoding": true }
})),
_ => None,
}));
s.respond(
"java/classFileContents",
json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Lib {}"),
);
let decompiled = h.mgr.virtual_document(&class("Decompiled")).await.unwrap();
assert!(decompiled.decompiled && !decompiled.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Decompiled"), "gbk").await.unwrap_err();
assert!(err.message.contains("no attached sources"), "{err}");
// The resolve command fails (unqueryable container): no reload offered.
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "unsupported".into()));
let unresolved = h.mgr.virtual_document(&class("Unresolved")).await.unwrap();
assert_eq!(unresolved.encoding, "utf-8");
assert!(!unresolved.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Unresolved"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot resolve"), "{err}");
// Server-generated content of other schemes cannot be re-decoded at all.
let err = h.mgr.reload_virtual("csharp:/p/App.csproj/decompiled/Lib.cs", "gbk").await.unwrap_err();
assert!(err.message.contains("generated by a language server"), "{err}");
// Unknown encodings are refused before any server round trip.
assert!(h.mgr.reload_virtual(&class("ReadOnly"), "not-an-encoding").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn jar_library_sources_reload_with_another_encoding() {
use std::io::Write;
let repo = tempfile::tempdir().unwrap();
let dir = repo.path().join("com/example/encoding-sample/1.0.0");
std::fs::create_dir_all(&dir).unwrap();
let source = "package example.encoding\n\n/** 中文编码测试。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let jar_bytes = |path: &Path, entries: &[(&str, &[u8])]| {
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, bytes) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
};
jar_bytes(&dir.join("encoding-sample-1.0.jar"), &[("example/encoding/EncodingSampleKt.class", b"\0")]);
jar_bytes(&dir.join("encoding-sample-1.0-sources.jar"), &[("example/encoding/EncodingSample.kt", &gbk)]);
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
h.open("Main.kt", "fun main() {}\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let class = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/EncodingSampleKt.class");
let entry = jar_uri(&dir.join("encoding-sample-1.0-sources.jar"), "example/encoding/EncodingSample.kt");
let vd = h.mgr.virtual_document(&class).await.unwrap();
assert_eq!(vd.text, source);
assert_eq!(vd.encoding, "gbk");
assert!(vd.can_reload_encoding);
// An explicit encoding wins over detection, even when it garbles the text.
let forced = h.mgr.reload_virtual(&class, "utf-8").await.unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
let back = h.mgr.reload_virtual(&class, "gbk").await.unwrap();
assert_eq!((back.text.as_str(), back.encoding.as_str()), (source, "gbk"));
// Non-class entries reload the same way.
let entry_vd = h.mgr.virtual_document(&entry).await.unwrap();
assert_eq!((entry_vd.text.as_str(), entry_vd.encoding.as_str()), (source, "gbk"));
let entry_reload = h.mgr.reload_virtual(&entry, "gbk").await.unwrap();
assert_eq!(entry_reload.text, source);
// Missing sources fail cleanly.
let missing = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/Missing.class");
assert!(h.mgr.reload_virtual(&missing, "gbk").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
-1
Переглянути файл
@@ -55,7 +55,6 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
+1 -1
Переглянути файл
@@ -69,7 +69,7 @@ pub struct TransferError {
}
impl TransferReport {
pub(crate) fn fail(&mut self, path: impl Into<String>, e: IdeError) {
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
self.errors.push(TransferError { path: path.into(), message: e.message });
}
}
-131
Переглянути файл
@@ -286,70 +286,6 @@ impl RemoteService {
r
}
/// Remote Host › Download to…: `remotes` (folders recursively) into the local folder `dir`,
/// each under its own name, without the server path above it. Existing files are replaced.
pub async fn download_into(
&self,
connection: &FsConnection,
remotes: &[String],
dir: &Path,
cancel: &CancellationToken,
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
) -> IdeResult<TransferReport> {
let fs = self.fs(connection).await?;
let mut report = TransferReport::default();
let mut folders = Vec::new();
let mut jobs = Vec::new();
for remote in remotes {
let Some(top) = download_target(dir, remote, remote) else {
report.skipped += 1;
continue;
};
match fs.stat(remote).await {
Ok(Some(e)) if e.is_dir => {
folders.push(top);
let mut entries = Vec::new();
if let Err(e) = remote_tree(&fs, remote, cancel, &mut entries).await {
report.fail(remote.clone(), e);
}
for entry in entries {
match download_target(dir, remote, &entry.path) {
Some(local) if entry.is_dir => folders.push(local),
Some(local) => jobs.push((entry.path, local)),
None => report.skipped += 1,
}
}
}
Ok(Some(_)) => jobs.push((remote.clone(), top)),
Ok(None) => report.fail(remote.clone(), IdeError::not_found(format!("{remote} does not exist"))),
Err(e) => report.fail(remote.clone(), e),
}
}
for folder in &folders {
if let Err(e) = tokio::fs::create_dir_all(folder).await {
report.fail(folder.display().to_string(), e.into());
}
}
for (i, (remote, local)) in jobs.iter().enumerate() {
if cancel.is_cancelled() {
break;
}
progress(remote, i, jobs.len());
if let Some(parent) = local.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await
{
report.fail(remote.clone(), e.into());
continue;
}
match fs.download(remote, local).await {
Ok(()) => report.files += 1,
Err(e) => report.fail(remote.clone(), e),
}
}
fs.close().await;
Ok(report)
}
// --- save hook -----------------------------------------------------------------
/// After a document save: uploads a remote file's copy, or (automatic upload)
@@ -561,70 +497,3 @@ fn cache_key(connection: &FsConnection) -> String {
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
}
}
/// Files and folders under remote folder `dir` (symlinked folders are not followed).
async fn remote_tree(
fs: &RemoteFs,
dir: &str,
cancel: &CancellationToken,
out: &mut Vec<RemoteEntry>,
) -> IdeResult<()> {
for e in fs.list(dir).await? {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let descend = e.is_dir && !e.is_symlink;
let path = e.path.clone();
out.push(e);
if descend {
Box::pin(remote_tree(fs, &path, cancel, out)).await?;
}
}
Ok(())
}
/// Where remote `path`, inside the downloaded `selected` file or folder, lands under local `dir`:
/// `dir/<name of selected>/<rest>`. `None` for a name that would leave `dir`.
fn download_target(dir: &Path, selected: &str, path: &str) -> Option<PathBuf> {
let selected = selected.trim_end_matches('/');
let name = selected.rsplit('/').next()?;
let rest = path.trim_end_matches('/').strip_prefix(selected)?;
if !rest.is_empty() && !rest.starts_with('/') {
return None;
}
let mut local = dir.to_path_buf();
for part in std::iter::once(name).chain(rest.split('/').filter(|p| !p.is_empty())) {
if matches!(part, "" | "." | "..") || part.contains('\\') || (cfg!(windows) && part.contains(':')) {
return None;
}
local.push(part);
}
Some(local)
}
#[cfg(test)]
mod download_into_tests {
use super::*;
#[test]
fn downloads_keep_only_the_selected_name() {
let dir = Path::new("/p/project");
let selected = "/home/u/www/site/current/drush";
assert_eq!(download_target(dir, selected, selected), Some(dir.join("drush")));
assert_eq!(download_target(dir, "/home/u/www/site/current/drush/", selected), Some(dir.join("drush")));
assert_eq!(
download_target(dir, selected, "/home/u/www/site/current/drush/src/a.php"),
Some(dir.join("drush").join("src").join("a.php"))
);
assert_eq!(download_target(dir, "/srv/app.log", "/srv/app.log"), Some(dir.join("app.log")));
}
#[test]
fn names_that_would_leave_the_folder_are_refused() {
let dir = Path::new("/p/project");
assert_eq!(download_target(dir, "/srv/a", "/srv/a/../../etc/passwd"), None);
assert_eq!(download_target(dir, "/srv/a", "/srv/ab/x"), None, "a sibling with the same prefix");
assert_eq!(download_target(dir, "/", "/etc"), None, "the root has no name");
assert_eq!(download_target(dir, "/srv/..", "/srv/.."), None);
}
}
-34
Переглянути файл
@@ -319,37 +319,3 @@ async fn browser_mappings_resolve_symlinks_and_download_to_the_project() {
assert!(report.errors.is_empty());
assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server");
}
#[tokio::test]
async fn download_into_copies_only_the_selected_items() {
let tmp = tempfile::tempdir().unwrap();
let server = tmp.path().join("srv/www/site/current");
let project = tmp.path().join("project");
std::fs::create_dir_all(server.join("drush/sub")).unwrap();
std::fs::create_dir_all(server.join("drush/empty")).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(server.join("drush/a.txt"), "a").unwrap();
std::fs::write(server.join("drush/sub/b.txt"), "b").unwrap();
std::fs::write(server.join("index.php"), "php").unwrap();
std::fs::write(project.join("index.php"), "old").unwrap();
let service = service(tmp.path());
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().to_owned());
let report = service
.download_into(&FsConnection::Local, &remotes, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!(report.files, 3, "{report:?}");
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!(std::fs::read_to_string(project.join("drush/a.txt")).unwrap(), "a");
assert_eq!(std::fs::read_to_string(project.join("drush/sub/b.txt")).unwrap(), "b");
assert!(project.join("drush/empty").is_dir());
assert_eq!(std::fs::read_to_string(project.join("index.php")).unwrap(), "php");
assert!(!project.join("srv").exists() && !project.join("www").exists());
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!((report.files, report.errors.len()), (0, 1), "{report:?}");
}
+1 -1
Переглянути файл
@@ -1541,7 +1541,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
install_bundle(
data_dir,
"java-usages",
"1.13.0",
"1.12.0",
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
)
}
+21 -142
Переглянути файл
@@ -5,7 +5,6 @@ use crate::{ChangeKind, FileStatus, Repo};
use gix::status::index_worktree::Item as WtItem;
use gix::status::plumbing::index_as_worktree::{Change as WtChange, EntryStatus};
use ide_core::IdeResult;
use ide_core::ignored::IgnoredNames;
use std::collections::{BTreeMap, HashSet};
#[derive(Default)]
@@ -16,43 +15,10 @@ struct Entry {
}
pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// gitoxide stops at the first IO error (a directory it may not read), where git warns
// and lists the rest: git answers then.
let (map, conflicts) = gix_status(r, &ignored).or_else(|e| {
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
cli_status(r, &ignored)
})?;
Ok(map
.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
// cannot stall on a file the user cannot see.
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
.map(|(rel, mut e)| {
if conflicts.contains(&rel) {
e.staged = None;
e.renamed_from = None;
e.unstaged = Some(ChangeKind::Conflicted);
}
FileStatus {
path: r.abs_path(&rel).into(),
rel_path: rel,
staged: e.staged,
unstaged: e.unstaged,
renamed_from: e.renamed_from,
}
})
.collect())
}
type Entries = (BTreeMap<String, Entry>, HashSet<String>);
fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
let repo = r.gix()?;
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: HashSet<String> = HashSet::new();
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// Stamp before status can read the index: stamping after the load could capture
// an external `git switch`, `add` or commit's new index and allow the stale
@@ -152,60 +118,28 @@ fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
}
}
Ok((map, conflicts))
}
/// `git status --porcelain=v2`: what git lists, unreadable directories skipped.
fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
let out = crate::cli::run(r.root(), &["status", "--porcelain=v2", "-z", "--untracked-files=all"], None)?;
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
let kind = |c: u8| match c {
b'M' | b'T' => Some(ChangeKind::Modified),
b'A' | b'C' => Some(ChangeKind::Added),
b'D' => Some(ChangeKind::Deleted),
b'R' => Some(ChangeKind::Renamed),
_ => None,
};
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: HashSet<String> = HashSet::new();
let mut records = out.split('\0');
while let Some(record) = records.next() {
let (tag, rest) = record.split_once(' ').unwrap_or((record, ""));
match tag {
// `1 XY sub mH mI mW hH hI path`, `2 … Xscore path` + NUL + the original path.
"1" | "2" => {
let fields = if tag == "1" { 8 } else { 9 };
let mut parts = rest.splitn(fields, ' ');
let xy = parts.next().unwrap_or("..").as_bytes();
let Some(path) = parts.nth(fields - 2) else { continue };
let e = map.entry(path.to_owned()).or_default();
e.staged = xy.first().copied().and_then(kind);
// Intent to add shows as `.A`; worktree renames are off, as with gitoxide.
e.unstaged = xy.get(1).copied().and_then(kind).filter(|k| *k != ChangeKind::Renamed);
if tag == "2" {
let from = records.next().unwrap_or_default();
if e.staged == Some(ChangeKind::Renamed) {
e.renamed_from = Some(from.to_owned());
}
}
Ok(map
.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
// cannot stall on a file the user cannot see.
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
.map(|(rel, mut e)| {
if conflicts.contains(&rel) {
e.staged = None;
e.renamed_from = None;
e.unstaged = Some(ChangeKind::Conflicted);
}
// `u XY sub m1 m2 m3 mW h1 h2 h3 path`
"u" => {
if let Some(path) = rest.splitn(10, ' ').nth(9) {
conflicts.insert(path.to_owned());
map.entry(path.to_owned()).or_default().unstaged = Some(ChangeKind::Conflicted);
}
FileStatus {
path: r.abs_path(&rel).into(),
rel_path: rel,
staged: e.staged,
unstaged: e.unstaged,
renamed_from: e.renamed_from,
}
"?" if !rest.is_empty() && !ignored.is_ignored_path(rest) => {
map.entry(rest.to_owned()).or_default().unstaged = Some(ChangeKind::Untracked);
}
_ => {}
}
}
(map, conflicts)
})
.collect())
}
/// Lets a test land an external index write in the window between the status run
@@ -293,59 +227,4 @@ mod tests {
let staged = Command::new("git").current_dir(dir).args(["diff", "--cached", "--stat"]).output().unwrap();
assert!(staged.stdout.is_empty(), "{}", String::from_utf8_lossy(&staged.stdout));
}
/// git's porcelain answer (used when gitoxide can't read the tree) matches gitoxide's.
#[test]
fn git_and_gitoxide_report_the_same_status() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
git(dir, &["config", "user.name", "Test User"]);
git(dir, &["config", "user.email", "[email protected]"]);
git(dir, &["config", "commit.gpgsign", "false"]);
for f in ["modified.txt", "staged.txt", "deleted.txt", "staged_del.txt", "both.txt", "conflict.txt"] {
std::fs::write(dir.join(f), "a\n").unwrap();
}
std::fs::write(dir.join("rename_me.txt"), "some longer content\nthat is stable\nfor rename detection\n")
.unwrap();
commit_all(dir, "base");
git(dir, &["checkout", "-q", "-b", "side"]);
std::fs::write(dir.join("conflict.txt"), "side\n").unwrap();
commit_all(dir, "side");
git(dir, &["checkout", "-q", "main"]);
std::fs::write(dir.join("conflict.txt"), "main\n").unwrap();
commit_all(dir, "main");
let merge = Command::new("git").current_dir(dir).args(["merge", "-q", "side"]).output().unwrap();
assert!(!merge.status.success());
std::fs::write(dir.join("modified.txt"), "b\n").unwrap();
std::fs::write(dir.join("staged.txt"), "b\n").unwrap();
git(dir, &["add", "staged.txt"]);
std::fs::remove_file(dir.join("deleted.txt")).unwrap();
git(dir, &["rm", "-q", "staged_del.txt"]);
std::fs::write(dir.join("both.txt"), "b\n").unwrap();
git(dir, &["add", "both.txt"]);
std::fs::write(dir.join("both.txt"), "c\n").unwrap();
git(dir, &["mv", "rename_me.txt", "renamed with space.txt"]);
std::fs::create_dir_all(dir.join("new dir")).unwrap();
std::fs::write(dir.join("new dir/untracked.txt"), "u\n").unwrap();
std::fs::write(dir.join("intent.txt"), "i\n").unwrap();
git(dir, &["add", "-N", "intent.txt"]);
let repo = Repo::discover(dir).unwrap().expect("repository found");
let ignored = IgnoredNames::default();
let show = |(map, conflicts): Entries| {
let mut c: Vec<_> = conflicts.into_iter().collect();
c.sort();
let m: Vec<_> = map
.into_iter()
.map(|(k, e)| format!("{k}: {:?} {:?} {:?}", e.staged, e.unstaged, e.renamed_from))
.collect();
(m, c)
};
let from_git = show(cli_status(&repo, &ignored).unwrap());
assert_eq!(from_git, show(gix_status(&repo, &ignored).unwrap()));
assert!(from_git.0.iter().any(|l| l.starts_with("renamed with space.txt: Some(Renamed)")), "{from_git:?}");
assert_eq!(from_git.1, ["conflict.txt"]);
}
}
-21
Переглянути файл
@@ -2015,24 +2015,3 @@ fn reword_refuses_commits_outside_the_current_branch() {
fx.git(&["checkout", "-q", "main"]);
assert!(fx.repo().reword(&side, "other").is_err());
}
#[cfg(unix)]
#[test]
fn status_skips_a_directory_it_cannot_read() {
// `git status` warns "could not open directory: Permission denied" and lists the rest.
use std::os::unix::fs::PermissionsExt;
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("init");
fx.write("a.txt", "b\n");
fx.write("new.txt", "n\n");
let locked = fx.path("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("inside.txt"), "x").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let st = fx.repo().status();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
let st = st.unwrap();
assert_eq!(status_of(&st, "a.txt").unstaged, Some(ChangeKind::Modified));
assert_eq!(status_of(&st, "new.txt").unstaged, Some(ChangeKind::Untracked));
}
+1 -79
Переглянути файл
@@ -359,32 +359,6 @@ fn sync_error_is_tolerable(e: &std::io::Error) -> bool {
false
}
/// On Windows a file can't be replaced while another process has it open without
/// delete sharing: an antivirus or indexer scan, a sync client, a program reading
/// its config. Those let go within moments, so the replacing rename is retried briefly.
const RENAME_ATTEMPTS: u32 = 10;
const RENAME_PAUSE: std::time::Duration = std::time::Duration::from_millis(100);
/// Access denied (os error 5), sharing and lock violations (32, 33) on Windows.
fn held_by_another_process(e: &std::io::Error) -> bool {
cfg!(windows) && matches!(e.raw_os_error(), Some(5 | 32 | 33))
}
fn retrying(
attempts: u32,
pause: std::time::Duration,
transient: impl Fn(&std::io::Error) -> bool,
mut op: impl FnMut() -> std::io::Result<()>,
) -> std::io::Result<()> {
for _ in 1..attempts {
match op() {
Err(e) if transient(&e) => std::thread::sleep(pause),
r => return r,
}
}
op()
}
/// Writes `bytes` to `path` atomically: temp file in the same directory, fsync,
/// rename over the target. Preserves the permissions of an existing file and
/// writes through symlinks. Falls back to an in-place write when the directory
@@ -467,7 +441,7 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: b
fs::set_permissions(&tmp, permissions)?;
}
drop(file);
retrying(RENAME_ATTEMPTS, RENAME_PAUSE, held_by_another_process, || fs::rename(&tmp, &target))
fs::rename(&tmp, &target)
})();
if let Err(e) = result {
let _ = fs::remove_file(&tmp);
@@ -565,58 +539,6 @@ mod tests {
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640);
}
#[test]
fn replacing_is_retried_only_while_a_file_is_held() {
use std::io::{Error, ErrorKind};
let in_use = || Error::from_raw_os_error(32);
let transient = |e: &Error| e.raw_os_error() == Some(32);
let zero = std::time::Duration::ZERO;
for (fails, outcome) in [(0, true), (3, true), (RENAME_ATTEMPTS - 1, true), (RENAME_ATTEMPTS, false)] {
let mut calls = 0;
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
calls += 1;
if calls <= fails { Err(in_use()) } else { Ok(()) }
});
assert_eq!(r.is_ok(), outcome, "{fails} failures");
assert_eq!(calls, (fails + 1).min(RENAME_ATTEMPTS));
}
let mut calls = 0;
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
calls += 1;
Err(Error::from(ErrorKind::NotFound))
});
assert_eq!(r.unwrap_err().kind(), ErrorKind::NotFound);
assert_eq!(calls, 1, "other errors fail at once");
for code in [5, 32, 33] {
assert_eq!(held_by_another_process(&Error::from_raw_os_error(code)), cfg!(windows), "os error {code}");
}
assert!(!held_by_another_process(&Error::from(ErrorKind::NotFound)));
}
#[cfg(windows)]
#[test]
fn saving_waits_for_a_windows_file_lock_to_be_released() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
// No sharing at all (os error 32), and readers that don't allow deletion (os error 5).
for share in [0, FILE_SHARE_READ | FILE_SHARE_WRITE] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, b"old").unwrap();
let held = fs::OpenOptions::new().read(true).share_mode(share).open(&path).unwrap();
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
drop(held);
});
let result = write_atomic(&path, b"new");
release.join().unwrap();
result.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new", "share mode {share}");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1, "no temp file left behind");
}
}
#[cfg(unix)]
#[test]
fn strict_write_does_not_truncate_when_sibling_creation_is_denied() {
+6 -69
Переглянути файл
@@ -25,11 +25,6 @@ pub struct DocumentInfo {
pub line_ending: LineEnding,
pub read_only: bool,
pub dirty: bool,
/// Whether "Reload with Encoding" can re-decode this document's source
/// bytes: true for files and virtual documents the backend can re-read
/// (archive entries, JDT sources with an editable attachment encoding,
/// Git revisions); false for server-generated content (decompiled code).
pub can_reload_encoding: bool,
/// Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
/// structure and folding are disabled; the editor should drop heavy features.
#[specta(optional)]
@@ -148,9 +143,6 @@ struct DocState {
observed: bool,
binary: bool,
dirty: bool,
can_reload_encoding: bool,
/// The user picked `encoding`; reloads keep it instead of detecting again.
encoding_chosen: bool,
/// Disk state we last loaded or wrote; changes matching it are our own.
disk_mtime: f64,
disk_len: u64,
@@ -347,7 +339,6 @@ impl Documents {
line_ending: st.doc.line_ending(),
read_only: st.read_only,
dirty: st.dirty,
can_reload_encoding: st.can_reload_encoding,
large: st.large,
editorconfig: st.editorconfig.clone(),
uri: st.uri.clone(),
@@ -421,8 +412,6 @@ impl Documents {
observed,
binary: loaded.binary,
dirty: false,
can_reload_encoding: true,
encoding_chosen: false,
disk_mtime: loaded.mtime_ms,
disk_len: disk_len(path),
closed: false,
@@ -460,19 +449,8 @@ impl Documents {
/// Opens (or re-references) a read-only virtual document: contents a
/// language server provides for a non-`file:` URI. It is keyed by the URI,
/// never touches the disk and cannot be edited or saved. `encoding` /
/// `has_bom` describe how the content was decoded (not a guess);
/// `can_reload_encoding` tells whether [`Documents::replace_virtual`] can
/// re-decode it ("Reload with Encoding").
pub fn open_virtual(
&self,
uri: &str,
language: LanguageId,
text: &str,
encoding: &str,
has_bom: bool,
can_reload_encoding: bool,
) -> IdeResult<DocumentInfo> {
/// never touches the disk and cannot be edited or saved.
pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult<DocumentInfo> {
let _operation = self.operations.lock();
let key = PathBuf::from(uri);
if let Some(info) = self.reopen(&key) {
@@ -484,8 +462,8 @@ impl Documents {
doc: Document::new(text),
language,
ui_mode: None,
encoding: encoding.into(),
has_bom,
encoding: "utf-8".into(),
has_bom: false,
native_to_ascii: false,
read_only: true,
large,
@@ -493,8 +471,6 @@ impl Documents {
observed: true,
binary: false,
dirty: false,
can_reload_encoding,
encoding_chosen: false,
disk_mtime: 0.0,
disk_len: 0,
closed: false,
@@ -870,7 +846,6 @@ impl Documents {
let loaded = Self::load_as(&st.path, encoding, enabled)?;
st.native_to_ascii = enabled;
st.encoding = loaded.encoding.clone();
st.encoding_chosen = true;
st.has_bom = loaded.has_bom;
st.disk_mtime = loaded.mtime_ms;
st.disk_len = disk_len(&st.path);
@@ -889,45 +864,12 @@ impl Documents {
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
}
st.encoding = encoding.to_owned();
st.encoding_chosen = true;
st.has_bom = has_bom;
self.set_dirty(doc, st, true);
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
})
}
/// The URI of a virtual document, or `None` for a file-backed one.
pub fn virtual_uri(&self, doc: DocId) -> IdeResult<Option<String>> {
self.with_doc(doc, |st| Ok(st.uri.clone()))
}
/// Re-decoded content of a virtual document ("Reload with Encoding"):
/// replaces the text and the encoding label, keeping the document
/// read-only and clean. The caller re-reads the origin (archive entry,
/// language server, Git blob) with the chosen encoding first.
pub fn replace_virtual(
&self,
doc: DocId,
text: &str,
encoding: &str,
has_bom: bool,
) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
if st.uri.is_none() {
return Err(IdeError::invalid("not a virtual document"));
}
ide_vfs::encode("", encoding, false)?;
st.doc.set_line_ending(LineEnding::detect(text));
self.replace_locked(doc, st, text)?;
st.encoding = encoding.to_owned();
st.has_bom = has_bom;
st.large = text.len() as u64 > ide_vfs::LARGE_FILE_THRESHOLD;
// Reloading is not an edit: the buffer matches its source again.
self.set_dirty(doc, st, false);
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
})
}
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
pub fn set_bom(&self, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
self.with_doc(doc, |st| {
@@ -1026,13 +968,8 @@ impl Documents {
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
} else {
let defaults = self.file_defaults();
let native_to_ascii = defaults.native_to_ascii_for(&st.path);
let loaded = if st.encoding_chosen {
Self::load_as(&st.path, &st.encoding, native_to_ascii)?
} else {
Self::load_with_defaults(&st.path, &defaults)?
};
st.native_to_ascii = native_to_ascii;
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
loaded
};
st.disk_mtime = loaded.mtime_ms;
+1 -6
Переглянути файл
@@ -748,7 +748,6 @@ impl Documents {
let result = self.with_doc(*id, |state| {
self.replace_locked(*id, state, &before.doc.text())?;
state.encoding = before.encoding.clone();
state.encoding_chosen = before.encoding_chosen;
state.has_bom = before.has_bom;
state.doc.set_line_ending(before.doc.line_ending());
state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0);
@@ -790,11 +789,7 @@ impl Documents {
return Err(stale(path));
}
let defaults = self.file_defaults();
let loaded = if state.encoding_chosen {
Self::load_as(path, &state.encoding, state.native_to_ascii)?
} else {
ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?
};
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?;
self.replace_locked(id, state, &loaded.text)?;
state.encoding = loaded.encoding;
state.has_bom = loaded.has_bom;
+2 -106
Переглянути файл
@@ -505,48 +505,6 @@ fn reload_and_convert_encoding() {
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err());
}
#[test]
fn external_changes_keep_a_chosen_encoding() {
// Valid UTF-8 bytes, so detection alone would read them as UTF-8.
let (first, changed) = (b"caf\xC3\xA9".as_slice(), b"caf\xC3\xA9 \xC3\xA8".as_slice());
for (encoding, reload) in [("windows-1252", true), ("gbk", true), ("windows-1252", false)] {
let f = fixture();
let path = f.root.join("legacy.txt");
fs::write(&path, first).unwrap();
let info = f.docs.open(&path).unwrap();
assert_eq!(info.encoding, "utf-8");
if reload {
f.docs.set_encoding(info.id, encoding, true).unwrap();
} else {
// Convert, then save: the file is now in the chosen encoding.
f.docs.replace_all(info.id, "café").unwrap();
f.docs.set_encoding(info.id, encoding, false).unwrap();
f.docs.save(info.id).unwrap();
}
let expected =
|bytes: &[u8]| encoding_rs::Encoding::for_label(encoding.as_bytes()).unwrap().decode(bytes).0.into_owned();
let changed = if reload { changed.to_vec() } else { ide_vfs::encode("café è", encoding, false).unwrap() };
fs::write(&path, &changed).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!(snapshot.text, expected(&changed), "{encoding} reload={reload}");
assert_eq!(snapshot.encoding, encoding, "{encoding} reload={reload}");
assert!(!snapshot.dirty);
}
}
#[test]
fn external_changes_redetect_an_encoding_the_user_never_chose() {
let f = fixture();
let path = f.root.join("plain.txt");
fs::write(&path, "plain").unwrap();
let info = f.docs.open(&path).unwrap();
fs::write(&path, ide_vfs::encode("wide", "utf-16le", true).unwrap()).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snapshot = f.docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("wide", "utf-16le"));
}
#[test]
fn utf8_bom_is_added_and_removed_on_save() {
let f = fixture();
@@ -698,14 +656,14 @@ fn editorconfig_applies_on_open_and_save() {
fn virtual_documents_are_read_only_and_keyed_by_uri() {
let f = fixture();
let uri = "jdt://contents/a.jar/p/C.class?=x";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n", "utf-8", false, true).unwrap();
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n").unwrap();
assert_eq!(info.uri.as_deref(), Some(uri));
assert_eq!(info.path, PathBuf::from(uri));
assert!(info.read_only && !info.dirty);
assert_eq!(info.language.as_str(), "java");
// Re-opening shares the document; edits and saves never reach a file.
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored", "utf-8", false, true).unwrap();
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored").unwrap();
assert_eq!(again.id, info.id);
assert_eq!(again.text, "class C {}\n");
let err = f.docs.edit(info.id, info.version, change(0, 0, "x")).unwrap_err();
@@ -719,68 +677,6 @@ fn virtual_documents_are_read_only_and_keyed_by_uri() {
assert_eq!(*f.rec.log.lock(), vec!["open v0 java".to_owned(), "close".to_owned()]);
}
#[test]
fn virtual_documents_carry_their_encoding_and_reload_capability() {
let f = fixture();
// Archive sources decoded by the backend report the charset actually used.
let uri = "jar:///m2/lib-1.0-sources.jar!/p/C.java";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "中文", "gbk", false, true).unwrap();
assert_eq!((info.encoding.as_str(), info.has_bom, info.can_reload_encoding), ("gbk", false, true));
let bom =
f.docs.open_virtual("jar:///m2/b.jar!/p/B.java", LanguageId::new("java"), "x", "utf-8", true, true).unwrap();
assert!(bom.has_bom);
// Server-generated content (decompiled) cannot be re-decoded.
let decompiled = f
.docs
.open_virtual(
"csharp:/p/App.csproj/decompiled/Lib.cs",
LanguageId::new("csharp"),
"class Lib {}",
"utf-8",
false,
false,
)
.unwrap();
assert!(!decompiled.can_reload_encoding);
// File-backed documents can always be re-read from disk.
let path = f.root.join("f.txt");
fs::write(&path, "x").unwrap();
assert!(f.docs.open(&path).unwrap().can_reload_encoding);
}
#[test]
fn virtual_documents_reload_with_another_encoding_without_becoming_editable() {
let f = fixture();
let uri = "jdt://contents/a.jar/p/C.class?=x";
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "涓枃", "utf-8", false, true).unwrap();
let encoding = f.docs.replace_virtual(info.id, "中文", "gbk", false).unwrap();
assert_eq!((encoding.encoding.as_str(), encoding.has_bom), ("gbk", false));
let snap = f.docs.snapshot(info.id).unwrap();
assert_eq!(snap.text, "中文");
assert_eq!(snap.encoding, "gbk");
assert!(snap.read_only && !snap.dirty, "reloading is not an edit");
assert!(snap.version > info.version);
assert_eq!(f.docs.virtual_uri(info.id).unwrap().as_deref(), Some(uri));
// The replacement reached observers and the event stream (editors resync).
assert!(f.rec.log.lock().iter().any(|l| l.starts_with("change v") && l.contains("中文")));
assert!(f.events.lock().iter().any(|e| matches!(e, IdeEvent::DocPatched { doc, .. } if *doc == info.id)));
// Read-only protection is unchanged.
assert_eq!(f.docs.edit(info.id, snap.version, change(0, 0, "x")).unwrap_err().kind, ErrorKind::PermissionDenied);
assert!(f.docs.set_encoding(info.id, "utf-8", false).is_err());
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err(), "the op layer routes virtual reloads");
assert!(f.docs.set_bom(info.id, true).is_err());
f.docs.save(info.id).unwrap();
// Unknown encodings are refused; file-backed documents are not virtual.
assert!(f.docs.replace_virtual(info.id, "x", "not-an-encoding", false).is_err());
let path = f.root.join("f.txt");
fs::write(&path, "x").unwrap();
let file = f.docs.open(&path).unwrap();
assert!(f.docs.replace_virtual(file.id, "y", "utf-8", false).is_err());
assert_eq!(f.docs.virtual_uri(file.id).unwrap(), None);
}
#[test]
fn toggling_the_writable_bit_updates_the_open_document() {
let f = fixture();
-27
Переглянути файл
@@ -162,21 +162,6 @@ fn undo_id(events: &Mutex<Vec<IdeEvent>>) -> u32 {
.expect("successful transaction has one undo record")
}
#[test]
fn undo_keeps_the_encoding_chosen_for_a_file_opened_after_the_edit() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("legacy.txt");
std::fs::write(&path, b"abc\xC3\xA9").unwrap();
let (docs, events) = recorded_documents();
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap();
let info = docs.open(&path).unwrap();
docs.set_encoding(info.id, "windows-1252", true).unwrap();
assert_eq!(docs.snapshot(info.id).unwrap().text, "newé");
docs.undo_workspace_edit(undo_id(&events)).unwrap();
let snapshot = docs.snapshot(info.id).unwrap();
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("abcé", "windows-1252"));
}
#[test]
fn stale_document_version_rejects_the_whole_edit() {
let tmp = tempfile::tempdir().unwrap();
@@ -530,18 +515,6 @@ fn duplicate_edits_keep_versions_and_apply_one_replacement() {
assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long");
}
#[test]
fn repeated_inserts_at_one_position_all_apply_once_per_entry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("main.ts");
std::fs::write(&path, "f(").unwrap();
let docs = documents();
let close = TextEdit { range: Range::new(Pos::new(0, 2), Pos::new(0, 2)), new_text: ")".into() };
let entry = FileEdit::Edit { path: path.clone().into(), version: None, edits: vec![close.clone(), close] };
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![entry.clone(), entry], ..Default::default() }).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "f())");
}
#[test]
fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() {
let tmp = tempfile::tempdir().unwrap();
+3 -5
Переглянути файл
@@ -11,10 +11,9 @@ Each open file gets its language from the definitions, checked in this order:
1. A per-file **Override File Type** (in the context menu of a file in the project tree or of an editor tab).
2. An exact file name (`Dockerfile`, `go.mod`) or a file name pattern.
3. A framework's folder: an `.html` or `.txt` file in a `templates` folder with a `manage.py` above it is a Django template.
4. The extension, longest match first (`.html.erb` before `.erb`).
5. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
6. Otherwise the file is plain text.
3. The extension, longest match first (`.html.erb` before `.erb`).
4. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
5. Otherwise the file is plain text.
You can add or remove file name patterns per language in **Settings › Editor › File Types** (**Recognized File Types**).
@@ -107,7 +106,6 @@ The table lists every built-in language definition. Server and debugger names in
| D | `d` | `.d`, `.di` | Yes | — | serve-d | — |
| Dart | `dart` | `.dart` | Yes | Yes | dart | dart |
| Diff | `diff` | `.diff`, `.patch`, `.rej` | Yes | — | — | — |
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | Yes | Yes | **vscode-html-language-server** | — |
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | Yes | — | **dockerfile-language-server** | — |
| Elixir | `elixir` | `.ex`, `.exs` | Yes | Yes | **elixir-ls** | **elixir-ls-debugger** |
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | Yes | — | — | — |
-6
Переглянути файл
@@ -125,12 +125,6 @@ See [Debugging](/en/guide/debugging) for breakpoints, stepping and the debugger
Django is the only framework with dedicated support: the `manage.py runserver` and `manage.py test` configurations, the **Django tests** configuration type (target, settings module, options), running single `TestCase` tests from the gutter, and Django mode in the debugger. Other frameworks (Flask, FastAPI and so on) run as ordinary scripts or console scripts.
### Django templates
The HTML and text files in a `templates` folder of a Django project (one with a `manage.py` above it) open as **Django Template** files: the template tags, variables, filters and comments are highlighted on top of the HTML, and block tags (`{% block %}`, `{% if %}`, `{% for %}`, `{% with %}` and the others of Django and its contrib libraries) fold, indent their content and highlight their end tag. Typing `{%`, `{{` or `{#` closes the pair, finishing a block tag adds its end tag, and `{% end` completes the end tags of the open blocks. **Comment with Line Comment** puts each line in its own `{# … #}`, and **Comment with Block Comment** over several lines uses `{% comment %}`. The HTML language server and Emmet work in the markup between the tags.
An HTML file elsewhere with Django tags shows a banner that switches it to Django Template or keeps it as HTML; either choice is the file's **Override File Type**.
## Limitations
- Only one type checker runs at a time. Ruff runs only in projects with a Ruff configuration.
+1
Переглянути файл
@@ -52,6 +52,7 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
| Editor | General (Auto Import, Appearance, Breadcrumbs, Code Completion, Code Folding, Editor Tabs, Gutter Icons, Inline Completion, Postfix Completion, Rich-Text Copy, Smart Keys, Sticky Lines); Code Editing; Font; Color Scheme; Code Style; Inspections; Natural Languages (Spelling); File and Code Templates; File Encodings; Live Templates; File Types; Inlay Hints; TODO; Vim |
| Plugins | Plugins |
| Version Control | Commit; Confirmation; Directory Mappings; Issue Navigation; Log; Shelf; Git; GitHub |
| Project Structure | Project Structure |
| Build, Execution, Deployment | CMake; Gradle; Maven; Console; Debugger (Data Views, Stepping); Python Debugger; Run/Debug; Run Targets; Deployment (Options) |
| Languages & Frameworks | Language Servers; Go (Build Tags, Go Modules); Java; Markdown; Node.js; PHP; Python Interpreter; Prettier; oxfmt; Ruff; Ruby; Rust; TypeScript |
| Tools | Agents (Chat); Terminal; Actions on Save; External Tools; SSH Configurations; MCP Server |
+3 -5
Переглянути файл
@@ -11,10 +11,9 @@ OpenIDEA 用一个小的定义文件 `languages/<id>.toml` 描述每种语言:
1. 单个文件的 **覆盖文件类型**(在项目树中文件的右键菜单或编辑器标签页的右键菜单里)。
2. 精确文件名(`Dockerfile`、`go.mod`)或文件名模式。
3. 框架目录:上层目录有 `manage.py` 的 `templates` 目录中的 `.html` 或 `.txt` 文件是 Django 模板。
4. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
5. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
6. 都不匹配时按纯文本处理。
3. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
4. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
5. 都不匹配时按纯文本处理。
在 **设置 › 编辑器 › 文件类型**(**已识别的文件类型**)中可以为某种语言添加或移除文件名模式。
@@ -102,7 +101,6 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
| D | `d` | `.d`, `.di` | 是 | — | serve-d | — |
| Dart | `dart` | `.dart` | 是 | 是 | dart | dart |
| Diff | `diff` | `.diff`, `.patch`, `.rej` | 是 | — | — | — |
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | 是 | 是 | **vscode-html-language-server** | — |
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | 是 | — | **dockerfile-language-server** | — |
| Elixir | `elixir` | `.ex`, `.exs` | 是 | 是 | **elixir-ls** | **elixir-ls-debugger** |
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | 是 | — | — | — |
-6
Переглянути файл
@@ -125,12 +125,6 @@ Python 调试使用 debugpy。开始调试时如果缺少 debugpy,OpenIDEA 会
目前只有 Django 有专门支持:`manage.py runserver` 和 `manage.py test` 运行配置、**Django 测试** 配置类型(目标、settings 模块、选项)、在左侧栏运行单个 `TestCase` 测试,以及调试器的 Django 模式。Flask、FastAPI 等其他框架按普通脚本或命令行入口运行。
### Django 模板
Django 项目(上层目录有 `manage.py`)中 `templates` 目录里的 HTML 和文本文件以 **Django Template** 打开:模板标签、变量、过滤器和注释在 HTML 之上高亮,块标签(`{% block %}`、`{% if %}`、`{% for %}`、`{% with %}` 以及 Django 和其 contrib 库的其他块标签)可以折叠、缩进内容并高亮对应的结束标签。输入 `{%`、`{{` 或 `{#` 会自动补全成对符号,写完块标签会自动添加结束标签,输入 `{% end` 会补全当前打开块的结束标签。**使用行注释** 会把每一行放进各自的 `{# … #}`,多行的 **使用块注释** 则用 `{% comment %}`。HTML 语言服务器和 Emmet 在标签之间的标记中照常工作。
其他位置含有 Django 标签的 HTML 文件会显示一个横幅,可以切换为 Django Template 或保留为 HTML;两种选择都会成为该文件的 **覆盖文件类型**。
## 限制
- 同一时间只运行一个类型检查器。Ruff 只在有 Ruff 配置的项目中运行。
+1
Переглянути файл
@@ -52,6 +52,7 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
| 编辑器 | 常规(自动导入、外观、痕迹导航、代码补全、代码折叠、编辑器标签页、装订区域图标、内联补全、后缀补全、富文本复制、智能键、粘滞行);代码编辑;字体;配色方案;代码样式;检查;自然语言(拼写);文件和代码模板;文件编码;实时模板;文件类型;嵌入提示;TODO;Vim |
| 插件 | 插件 |
| 版本控制 | 提交;确认;目录映射;问题导航;日志;搁置;Git;GitHub |
| 项目结构 | 项目结构 |
| 构建、执行、部署 | CMake;Gradle;Maven;控制台;调试器(数据视图、单步执行);Python 调试器;运行/调试;运行目标;部署(选项) |
| 语言和框架 | 语言服务器;Go(构建标签、Go 模块);Java;Markdown;Node.js;PHP;Python 解释器;Prettier;oxfmt;Ruff;Ruby;Rust;TypeScript |
| 工具 | 智能体(聊天);终端;保存时操作;外部工具;SSH 配置;MCP 服务器 |
+8 -5
Переглянути файл
@@ -85,13 +85,16 @@ for (const id of ["editor.fileTemplates", "editor.liveTemplates"]) {
}
test("Project Structure keeps every details pane usable in a compact window", async ({ workbench: page }) => {
await page.keyboard.press("Meta+;");
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
await expect(dialog).toBeVisible();
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage("projectStructure");
});
await page.setViewportSize({ width: 520, height: 760 });
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
await dialog.getByRole("button", { name: section, exact: true }).click();
const details = dialog.locator("[class*='splitLayout'] > [class*='detailPane']");
await page.getByRole("tab", { name: section, exact: true }).click();
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
await expect(details).toBeAttached();
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
}
-30
Переглянути файл
@@ -93,36 +93,6 @@ test("terminal highlights visible occurrences of selected text in a single overl
activeSession()!.term.clearSelection();
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(0);
// Full-scrollback trim shifts a visible match into the cached margin row above viewportY; scrolling up must highlight it.
await page.evaluate(async () => {
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
const rows = s.term.rows;
s.term.options.scrollback = 4;
s.term.reset();
const total = rows + 4;
let payload = "";
for (let i = 0; i < total - 1; i++) {
payload += `${i === 4 ? "needle hit" : `row-${i}`}\r\n`;
}
payload += "needle sel";
await new Promise<void>((resolve) => s.term.write(payload, resolve));
s.term.select(0, total - 1, 6);
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 100));
const path = "/src/features/terminal/runtime.ts";
const { activeSession } = await import(path);
const s = activeSession()!;
await new Promise<void>((resolve) => s.term.write("\r\nnext", resolve));
s.term.scrollLines(-1);
});
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCSS("top", "0px");
});
test("terminal ⌘F highlights active/inactive search matches and seeds with a new selection", async ({
-1
Переглянути файл
@@ -15,7 +15,6 @@ something to show.
| `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` |
| `c-app/` | gcc / g++ 12+, make, GDB 14+ | `make all` | — | — | — |
| `php-app/` | PHP 8.5, Composer | `composer quote` | `vendor/bin/pest` | `OPENIDEA_FAIL=1 vendor/bin/pest` | unused variable in `ThresholdCustoms.php` |
| `django-app/` | Python 3.10+, Django 5.2+ | `python manage.py runserver` | `python manage.py test` | `OPENIDEA_FAIL=1 python manage.py test` | unused import in `shop/views.py` |
| `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` |
The fixtures are standalone: `rust-app` declares its own `[workspace]` and
Переглянути файл
Whitespace-only changes.
-27
Переглянути файл
@@ -1,27 +0,0 @@
"""Settings of the shop fixture: one app, no database tables of its own."""
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "fixture-only-not-a-secret"
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
INSTALLED_APPS = ["django.contrib.staticfiles", "shop"]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {"context_processors": ["django.template.context_processors.request"]},
}
]
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
STATIC_URL = "static/"
USE_TZ = True
-3
Переглянути файл
@@ -1,3 +0,0 @@
from django.urls import include, path
urlpatterns = [path("", include("shop.urls"))]
-16
Переглянути файл
@@ -1,16 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for the shop fixture."""
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
-4
Переглянути файл
@@ -1,4 +0,0 @@
<!-- Not in a templates folder: the editor offers Django Template from its tags. -->
{% load static %}
<p>Two cappuccini for <strong>{{ price|default:"€3" }}</strong></p>
<img src="{% static 'promo.png' %}" alt="">
-1
Переглянути файл
@@ -1 +0,0 @@
Django>=5.2
Переглянути файл
Whitespace-only changes.
-15
Переглянути файл
@@ -1,15 +0,0 @@
{% load static %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Shop{% endblock %}</title>
<link rel="stylesheet" href="{% static 'shop/site.css' %}">
</head>
<body>
{# The navigation is the same on every page. #}
<nav><a href="{% url 'shop:products' %}">Menu</a></nav>
<main>
{% block content %}{% endblock content %}
</main>
</body>
</html>
-3
Переглянути файл
@@ -1,3 +0,0 @@
{% autoescape off %}Thank you for your order, {{ name|default:"customer" }}!
{% for line in lines %}- {{ line.quantity }} × {{ line.product.name }}
{% endfor %}{% endautoescape %}
-25
Переглянути файл
@@ -1,25 +0,0 @@
{% extends "shop/base.html" %}
{% block title %}{% if single %}{{ products.0.name }}{% else %}Menu{% endif %} · {{ block.super }}{% endblock %}
{% block content %}
<ul class="products">
{% for product in products %}
<li class="{% cycle 'odd' 'even' %}">
<a href="{% url 'shop:product' slug=product.slug %}">{{ product.name|title }}</a>
<span class="price">€{{ product.price|floatformat:2 }}</span>
{% with count=product.tags|length %}
{% if count > 1 %}
<small>{{ product.tags|join:", " }}</small>
{% endif %}
{% endwith %}
</li>
{% empty %}
<li>Nothing on the menu today.</li>
{% endfor %}
</ul>
{% comment "Ideas" %}
<p>{{ seasonal_specials }}</p>
{% endcomment %}
{% verbatim %}<script type="text/x-template">{{ clientSide }}</script>{% endverbatim %}
{% endblock content %}
-22
Переглянути файл
@@ -1,22 +0,0 @@
import os
import unittest
from django.test import SimpleTestCase
from django.urls import reverse
class ProductPagesTests(SimpleTestCase):
def test_lists_every_product(self):
response = self.client.get(reverse("shop:products"))
self.assertContains(response, "Espresso")
self.assertContains(response, "Cornetto")
self.assertContains(response, "€1.60")
def test_unknown_product_is_not_found(self):
response = self.client.get(reverse("shop:product", args=["tea"]))
self.assertEqual(response.status_code, 404)
@unittest.skipUnless(os.environ.get("OPENIDEA_FAIL"), "opt-in failing test")
def test_intentionally_failing(self):
# FIXME: the opt-in failing test, for the test runner's failure views.
self.assertContains(self.client.get(reverse("shop:products")), "Tea")
-10
Переглянути файл
@@ -1,10 +0,0 @@
from django.urls import path
from . import views
app_name = "shop"
urlpatterns = [
path("", views.product_list, name="products"),
path("products/<slug:slug>/", views.product_detail, name="product"),
]
-35
Переглянути файл
@@ -1,35 +0,0 @@
"""Views of the shop: a product list and a product page."""
import json # Intentional warning: unused import.
from dataclasses import dataclass
from decimal import Decimal
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import render
@dataclass(frozen=True)
class Product:
slug: str
name: str
price: Decimal
tags: tuple[str, ...] = ()
PRODUCTS = [
Product("espresso", "Espresso", Decimal("1.20"), ("coffee", "hot")),
Product("cappuccino", "Cappuccino", Decimal("1.60"), ("coffee", "milk")),
Product("cornetto", "Cornetto", Decimal("1.30")),
]
def product_list(request: HttpRequest) -> HttpResponse:
# TODO: paginate once the menu grows.
return render(request, "shop/product_list.html", {"products": PRODUCTS})
def product_detail(request: HttpRequest, slug: str) -> HttpResponse:
product = next((p for p in PRODUCTS if p.slug == slug), None) # Set a breakpoint here
if product is None:
raise Http404(slug)
return render(request, "shop/product_list.html", {"products": [product], "single": True})
+1 -1
Переглянути файл
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.13.0
Bundle-Version: 1.12.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+1 -1
Переглянути файл
@@ -88,7 +88,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.13.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.12.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
Бінарний файл не відображається.
-21
Переглянути файл
@@ -1,21 +0,0 @@
package ai.openidea;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.ls.core.internal.JobHelpers;
import org.eclipse.jdt.ls.core.internal.handlers.BaseDocumentLifeCycleHandler;
/** What a command that reads an editor's position needs before it resolves the position. */
public final class Documents {
private Documents() {}
/**
* Waits for the queued document changes to be reconciled, as JDT LS's own position requests
* do: `workspace/executeCommand` does not, so a position sent right after an edit would
* be resolved against the element structure from before it.
*/
public static void awaitChanges(IProgressMonitor monitor) {
JobHelpers.waitForJobs(BaseDocumentLifeCycleHandler.DOCUMENT_LIFE_CYCLE_JOBS, monitor);
if (monitor.isCanceled()) throw new OperationCanceledException();
}
}
-2
Переглянути файл
@@ -1,6 +1,5 @@
package ai.openidea.configuration;
import ai.openidea.Documents;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -97,7 +96,6 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args);
Documents.awaitChanges(monitor);
String uri = (String) args.get(0);
String key = args.get(1) instanceof String s ? s : null;
int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue();
-2
Переглянути файл
@@ -1,6 +1,5 @@
package ai.openidea.hierarchy;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -45,7 +44,6 @@ public final class HierarchyCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
-2
Переглянути файл
@@ -1,6 +1,5 @@
package ai.openidea.implementations;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -28,7 +27,6 @@ public final class ImplementationsCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
List<Location> locations = new ArrayList<>();
boolean complete = find(arguments, locations, monitor);
return Map.of("locations", locations, "complete", complete);
-2
Переглянути файл
@@ -1,6 +1,5 @@
package ai.openidea.usages;
import ai.openidea.Documents;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -30,7 +29,6 @@ import org.eclipse.lsp4j.*;
public final class UsagesCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
Documents.awaitChanges(monitor);
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
-23
Переглянути файл
@@ -1,23 +0,0 @@
id = "django-html"
name = "Django Template"
extensions = ["djhtml"]
block-comment = ["{#", "#}"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "Django Template"
tree-sitter = "html"
# Django projects: the HTML and text files in a `templates` folder.
[[framework-files]]
folder = "templates"
extensions = ["html", "txt"]
root-markers = ["manage.py"]
# The markup between the template tags: completion and hover of tags and
# attributes. The template tags are text to it. No formatter: HTML formatters
# reflow template tags.
[[language-servers]]
name = "vscode-html-language-server"
command = "vscode-html-language-server"
args = ["--stdio"]
initialization-options = { embeddedLanguages = { css = true, javascript = true } }
install-hint = "npm i -g vscode-langservers-extracted"
+1 -1
Переглянути файл
@@ -17,7 +17,7 @@ func main() {
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
initialization-options = { semanticTokens = true }
root-markers = ["go.work", "go.mod"]
install-hint = "go install golang.org/x/tools/gopls@latest"
+1 -1
Переглянути файл
@@ -8,6 +8,6 @@ ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
initialization-options = { semanticTokens = true }
root-markers = ["go.work", "go.mod"]
install-hint = "go install golang.org/x/tools/gopls@latest"
+1 -1
Переглянути файл
@@ -8,6 +8,6 @@ ui-mode = "GoMod"
[[language-servers]]
name = "gopls"
command = "gopls"
initialization-options = { semanticTokens = true, vulncheck = "Imports" }
initialization-options = { semanticTokens = true }
root-markers = ["go.work"]
install-hint = "go install golang.org/x/tools/gopls@latest"
-1
Переглянути файл
@@ -22,7 +22,6 @@
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
"gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts",
"gen:django-grammar": "lezer-generator src/editor/core/cm/django/django.grammar --typeScript -o src/editor/core/cm/django/parser.ts",
"gen:astro-grammar": "lezer-generator src/editor/core/cm/astro/astro.grammar --typeScript -o src/editor/core/cm/astro/parser.ts",
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
"theme:import-intellij": "node scripts/theme/import-intellij-theme.ts",
-1
Переглянути файл
@@ -108,7 +108,6 @@ windows = { version = "0.62", features = [
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
-13
Переглянути файл
@@ -128,19 +128,6 @@ pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection,
ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_download_into(
window: Window,
state: State<'_, AppState>,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
ide_host::ops::deploy::remote_download_into(window.label(), &state.host, connection, paths, target, request).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_rename(
+4 -6
Переглянути файл
@@ -163,14 +163,12 @@ pub async fn doc_selection_ranges(
#[tauri::command]
#[specta::specta]
pub async fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::doc::history_list(&host, &label, path)).await
pub fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
ide_host::ops::doc::history_list(&state.host, window.label(), path)
}
#[tauri::command]
#[specta::specta]
pub async fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::doc::history_content(&host, &label, id)).await
pub fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
ide_host::ops::doc::history_content(&state.host, window.label(), id)
}
-189
Переглянути файл
@@ -1,189 +0,0 @@
//! Detects shortcuts the OS already gave to another app (Windows only).
//!
//! Some vendors register system-wide hotkeys (e.g. NVIDIA's overlay grabs
//! `Alt+R` / `Alt+Shift+R`), so those keystrokes never reach the IDE. Probing
//! asks Windows to register each combo for an instant: success means it is
//! free (released right away), `ERROR_HOTKEY_ALREADY_REGISTERED` means another
//! app grabbed it. Nothing is left registered.
use ide_core::IdeResult;
/// Reports which of `combos` (single-stroke `Alt+Shift+R`-style bindings) are
/// already registered to another app. Empty everywhere but Windows.
#[tauri::command]
#[specta::specta]
pub fn system_probe_hotkeys(combos: Vec<String>) -> IdeResult<Vec<String>> {
Ok(combos.into_iter().filter(|combo| imp::grabbed(combo)).collect())
}
/// A parsed binding: `RegisterHotKey` modifiers plus a virtual-key code.
#[cfg(windows)]
struct Hotkey {
modifiers: u32,
vk: u32,
}
/// Splits a canonical single-stroke binding into modifiers and key; `None`
/// for chords, double-modifier gestures, mouse buttons and unknown keys.
#[cfg(windows)]
fn parse_combo(combo: &str) -> Option<Hotkey> {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MOD_ALT, MOD_CONTROL, MOD_SHIFT, MOD_WIN};
if combo.contains(' ') {
return None;
}
let mut parts = combo.split('+');
let key = parts.next_back()?;
let mut modifiers = 0;
for part in parts {
modifiers |= match part.to_ascii_uppercase().as_str() {
"CTRL" => MOD_CONTROL,
"ALT" => MOD_ALT,
"SHIFT" => MOD_SHIFT,
"META" => MOD_WIN,
_ => return None,
};
}
if modifiers == 0 {
return None;
}
Some(Hotkey { modifiers, vk: vk_of(key)? })
}
/// The virtual-key code of a binding key, or `None` when it has no single
/// `RegisterHotKey` code (mouse buttons, gestures, layout-dependent keys).
#[cfg(windows)]
fn vk_of(key: &str) -> Option<u32> {
if key.len() == 1 {
let c = key.as_bytes()[0].to_ascii_uppercase();
if c.is_ascii_alphanumeric() {
return Some(u32::from(c));
}
return match c {
b'-' => Some(0xBD),
b'=' => Some(0xBB),
b'[' => Some(0xDB),
b']' => Some(0xDD),
b'\\' => Some(0xDC),
b';' => Some(0xBA),
b'\'' => Some(0xDE),
b',' => Some(0xBC),
b'.' => Some(0xBE),
b'/' => Some(0xBF),
b'`' => Some(0xC0),
_ => None,
};
}
// "F13" → 0x7C … "F24" → 0x87; anything else falls through to the names below.
let fkey =
key.strip_prefix('F').and_then(|n| n.parse::<u32>().ok()).filter(|n| (1..=24).contains(n)).map(|n| 0x6F + n);
if fkey.is_some() {
return fkey;
}
match key.to_ascii_uppercase().as_str() {
"SPACE" => Some(0x20),
"ENTER" | "RETURN" => Some(0x0D),
"ESCAPE" | "ESC" => Some(0x1B),
"TAB" => Some(0x09),
"BACKSPACE" | "BACK" => Some(0x08),
"DELETE" | "DEL" => Some(0x2E),
"INSERT" | "INS" => Some(0x2D),
"HOME" => Some(0x24),
"END" => Some(0x23),
"PAGEUP" | "PRIOR" => Some(0x21),
"PAGEDOWN" | "NEXT" => Some(0x22),
"UP" => Some(0x26),
"DOWN" => Some(0x28),
"LEFT" => Some(0x25),
"RIGHT" => Some(0x27),
_ => None,
}
}
#[cfg(windows)]
mod imp {
use super::{Hotkey, parse_combo};
use std::sync::atomic::{AtomicI32, Ordering};
use windows_sys::Win32::Foundation::{ERROR_HOTKEY_ALREADY_REGISTERED, GetLastError};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{RegisterHotKey, UnregisterHotKey};
/// Probe ids (never shown, never left registered).
static NEXT_ID: AtomicI32 = AtomicI32::new(0x1DEA00);
/// Registers `combo` for an instant: grabbed iff Windows refuses with
/// `ERROR_HOTKEY_ALREADY_REGISTERED`. A granted registration is released
/// before returning, so probing never steals a shortcut.
pub(super) fn grabbed(combo: &str) -> bool {
let Some(Hotkey { modifiers, vk }) = parse_combo(combo) else {
return false;
};
// SAFETY: registering a hotkey to a null window only tests availability;
// the id is unique per probe and unregistered on success.
unsafe {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
if RegisterHotKey(std::ptr::null_mut(), id, modifiers, vk) != 0 {
UnregisterHotKey(std::ptr::null_mut(), id);
false
} else {
GetLastError() == ERROR_HOTKEY_ALREADY_REGISTERED
}
}
}
}
#[cfg(not(windows))]
mod imp {
/// No other platform lets background apps swallow our keystrokes this way.
pub(super) fn grabbed(_combo: &str) -> bool {
false
}
}
#[cfg(all(test, windows))]
mod tests {
use super::imp::grabbed;
use super::parse_combo;
use windows_sys::Win32::Foundation::{ERROR_HOTKEY_ALREADY_REGISTERED, GetLastError};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MOD_ALT, MOD_SHIFT, RegisterHotKey, UnregisterHotKey};
#[test]
fn parses_modifier_order_and_key() {
let hotkey = parse_combo("Alt+Shift+R").expect("Alt+Shift+R parses");
assert_eq!(hotkey.modifiers, MOD_ALT | MOD_SHIFT);
assert_eq!(hotkey.vk, u32::from(b'R'));
let f7 = parse_combo("Alt+F7").expect("Alt+F7 parses");
assert_eq!(f7.modifiers, MOD_ALT);
assert_eq!(f7.vk, 0x76);
}
#[test]
fn rejects_what_cannot_be_probed() {
for combo in ["R", "Alt+R Alt+S", "Shift Shift", "Alt+Button4", "Ctrl+Foo", ""] {
assert!(!grabbed(combo), "{combo} must not probe");
}
}
/// Holds a real hotkey, then the probe must report it as grabbed — whether
/// our hold succeeded or another app already held it (both read 1409).
#[test]
fn sees_a_hotkey_held_by_another_registration() {
const COMBO: &str = "Alt+Shift+F24";
const VK_F24: u32 = 0x87;
// SAFETY: a test-only registration, released below.
let held = unsafe { RegisterHotKey(std::ptr::null_mut(), 0x1DEAFE, MOD_ALT | MOD_SHIFT, VK_F24) != 0 };
if !held {
assert_eq!(
unsafe { GetLastError() },
ERROR_HOTKEY_ALREADY_REGISTERED,
"the test hotkey must be holdable or already held"
);
}
assert!(grabbed(COMBO), "a held hotkey reads as grabbed");
if held {
// SAFETY: releases the registration above.
unsafe {
UnregisterHotKey(std::ptr::null_mut(), 0x1DEAFE);
}
}
}
}
-3
Переглянути файл
@@ -28,7 +28,6 @@ pub mod fs;
pub mod github;
pub mod heap;
pub mod history;
pub mod hotkeys;
pub mod http;
pub mod java;
pub mod lsp;
@@ -219,7 +218,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
history::history_list_dir,
history::history_put_label,
history::history_restore,
hotkeys::system_probe_hotkeys,
refactor::refactor_file_move_edits,
refactor::refactor_file_move_text_edits,
refactor::refactor_move_files,
@@ -687,7 +685,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
deploy::remote_of,
deploy::remote_delete,
deploy::remote_mkdir,
deploy::remote_download_into,
deploy::remote_rename,
crate::remote_dev::remote_bootstrap_list,
crate::remote_dev::remote_connect,
+12 -24
Переглянути файл
@@ -1,6 +1,5 @@
//! IPC commands of `ide_host::ops::run` (the shared implementation).
use super::doc::blocking;
use crate::state::AppState;
use ide_core::{IdeResult, RunId};
#[allow(unused_imports)]
@@ -56,15 +55,13 @@ pub async fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResul
#[tauri::command]
#[specta::specta]
pub async fn run_save_configs(
pub fn run_save_configs(
window: Window,
state: State<'_, AppState>,
configs: Vec<RunConfig>,
removed_detected_ids: Option<Vec<String>>,
) -> IdeResult<()> {
let (host, label) = (state.host.clone(), window.label().to_owned());
let removed = removed_detected_ids.unwrap_or_default();
blocking(move || ide_host::ops::run::save_configs(&host, &label, &configs, &removed)).await
ide_host::ops::run::save_configs(&state.host, window.label(), &configs, &removed_detected_ids.unwrap_or_default())
}
/// Temporary configurations as they are kept between sessions: without what settings derived.
@@ -143,44 +140,36 @@ pub fn run_close_stdin(state: State<'_, AppState>, id: RunId) -> IdeResult<()> {
/// Configuration templates: per-type defaults applied to new configurations.
#[tauri::command]
#[specta::specta]
pub async fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<RunConfig>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_templates(&host, &label)).await
pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<RunConfig>> {
ide_host::ops::run::run_templates(&state.host, window.label())
}
#[tauri::command]
#[specta::specta]
pub async fn run_save_templates(
window: Window,
state: State<'_, AppState>,
templates: Vec<RunConfig>,
) -> IdeResult<()> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_save_templates(&host, &label, templates)).await
pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec<RunConfig>) -> IdeResult<()> {
ide_host::ops::run::run_save_templates(&state.host, window.label(), templates)
}
/// Project files run configurations can be imported from (`.vscode/launch.json`).
#[tauri::command]
#[specta::specta]
pub async fn run_import_sources(
pub fn run_import_sources(
window: Window,
state: State<'_, AppState>,
) -> IdeResult<Vec<ide_run::import::RunImportSource>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_import_sources(&host, &label)).await
ide_host::ops::run::run_import_sources(&state.host, window.label())
}
/// Configurations read from `source`, not saved; names already in `existing` are skipped.
#[tauri::command]
#[specta::specta]
pub async fn run_import_configs(
pub fn run_import_configs(
window: Window,
state: State<'_, AppState>,
source: ide_run::import::RunImportSource,
existing: Vec<RunConfig>,
) -> IdeResult<ide_run::import::RunImport> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_import_configs(&host, &label, source, existing)).await
ide_host::ops::run::run_import_configs(&state.host, window.label(), source, existing)
}
/// The environment runs inherit when "Include system environment variables" is on.
@@ -193,13 +182,12 @@ pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap<
/// Variables of the `.env` files a debug launch names, for the debugger to start with.
#[tauri::command]
#[specta::specta]
pub async fn run_env_files(
pub fn run_env_files(
window: Window,
state: State<'_, AppState>,
files: Vec<std::path::PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::run::run_env_files(&host, &label, files)).await
ide_host::ops::run::run_env_files(&state.host, window.label(), files)
}
/// Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path.
+13 -30
Переглянути файл
@@ -1,6 +1,5 @@
//! IPC commands of `ide_host::ops::vcs` (the shared implementation).
use super::doc::blocking;
use crate::state::AppState;
use ide_core::{IdeResult, RequestId};
#[allow(unused_imports)]
@@ -8,7 +7,7 @@ pub use ide_host::ops::vcs::*;
use ide_vcs::*;
use std::path::PathBuf;
use tauri::ipc::Channel;
use tauri::{AppHandle, Manager, State, Window};
use tauri::{State, Window};
/// Changes of every repository of the project.
#[tauri::command]
@@ -474,12 +473,8 @@ pub async fn vcs_diff_text(
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
#[tauri::command]
#[specta::specta]
pub async fn vcs_diff_hunks(app: AppHandle, old_text: String, new_text: String) -> Vec<LineHunk> {
// Off the main thread: diffing two large texts would freeze the window.
let host = app.state::<AppState>().host.clone();
tauri::async_runtime::spawn_blocking(move || ide_host::ops::vcs::vcs_diff_hunks(&host, "", old_text, new_text))
.await
.expect("line diff panicked")
pub fn vcs_diff_hunks(state: State<'_, AppState>, old_text: String, new_text: String) -> Vec<LineHunk> {
ide_host::ops::vcs::vcs_diff_hunks(&state.host, "", old_text, new_text)
}
/// Stages only the selected hunks of a file's working-tree changes.
@@ -788,9 +783,8 @@ pub async fn vcs_shelve(
#[tauri::command]
#[specta::specta]
pub async fn vcs_shelves(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ShelvedChangeList>> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelves(&host, &label)).await
pub fn vcs_shelves(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ShelvedChangeList>> {
ide_host::ops::vcs::vcs_shelves(&state.host, window.label())
}
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
@@ -809,44 +803,33 @@ pub async fn vcs_unshelve(
#[tauri::command]
#[specta::specta]
pub async fn vcs_shelf_delete(
window: Window,
state: State<'_, AppState>,
id: String,
path: Option<String>,
) -> IdeResult<()> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelf_delete(&host, &label, id, path)).await
pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String, path: Option<String>) -> IdeResult<()> {
ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id, path)
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, name: String) -> IdeResult<()> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelf_rename(&host, &label, id, name)).await
pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, name: String) -> IdeResult<()> {
ide_host::ops::vcs::vcs_shelf_rename(&state.host, window.label(), id, name)
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_shelf_patch(
pub fn vcs_shelf_patch(
window: Window,
state: State<'_, AppState>,
id: String,
path: Option<String>,
) -> IdeResult<String> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_shelf_patch(&host, &label, id, path)).await
ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id, path)
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_changelists_load(window: Window, state: State<'_, AppState>) -> IdeResult<ChangelistState> {
let (host, label) = (state.host.clone(), window.label().to_owned());
blocking(move || ide_host::ops::vcs::vcs_changelists_load(&host, &label)).await
pub fn vcs_changelists_load(window: Window, state: State<'_, AppState>) -> IdeResult<ChangelistState> {
ide_host::ops::vcs::vcs_changelists_load(&state.host, window.label())
}
// Synchronous on purpose: the UI saves without waiting (debounced), and on the main thread
// the saves land in order, so an older state can't overwrite a newer one.
#[tauri::command]
#[specta::specta]
pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changelists: ChangelistState) -> IdeResult<()> {
-46
Переглянути файл
@@ -1,46 +0,0 @@
//! Tauri runs a command declared without `async` on the main thread, so disk I/O or a
//! large diff there freezes the window (input, painting, every other IPC call).
/// Commands that read or write project files, Local History or `.env` files, or diff two
/// arbitrary texts: they must be `async` and do that work on the blocking pool.
const OFF_MAIN_THREAD: &[(&str, &[&str])] = &[
("doc.rs", &["history_list", "history_content"]),
(
"vcs.rs",
&[
"vcs_diff_hunks",
"vcs_shelves",
"vcs_shelf_delete",
"vcs_shelf_rename",
"vcs_shelf_patch",
"vcs_changelists_load",
],
),
(
"run.rs",
&[
"run_save_configs",
"run_templates",
"run_save_templates",
"run_import_sources",
"run_import_configs",
"run_env_files",
],
),
];
#[test]
fn disk_and_diff_commands_stay_off_the_main_thread() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands");
let mut on_main = Vec::new();
for (file, commands) in OFF_MAIN_THREAD {
let source = std::fs::read_to_string(dir.join(file)).unwrap();
for command in *commands {
assert!(source.contains(&format!("fn {command}(")), "{file} has no command {command}");
if !source.contains(&format!("pub async fn {command}(")) {
on_main.push(*command);
}
}
}
assert!(on_main.is_empty(), "synchronous commands run on the main thread: {on_main:?}");
}
-25
Переглянути файл
@@ -381,11 +381,6 @@ export const commands = {
* file and its folders); an open editor updates in place.
*/
historyRestore: (path: string, id: string) => typedError<null, IdeError>(__TAURI_INVOKE("history_restore", { path, id })),
/**
* Reports which of `combos` (single-stroke `Alt+Shift+R`-style bindings) are
* already registered to another app. Empty everywhere but Windows.
*/
systemProbeHotkeys: (combos: string[]) => typedError<string[], IdeError>(__TAURI_INVOKE("system_probe_hotkeys", { combos })),
/** `workspace/willRenameFiles` on every interested server (before the move). Cancellable. */
refactorFileMoveEdits: (request: RequestId, moves: FileMove[]) => typedError<FileRenameEdits, IdeError>(__TAURI_INVOKE("refactor_file_move_edits", { request, moves })),
/**
@@ -1576,7 +1571,6 @@ export const commands = {
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }),
remoteDelete: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { connection, path })),
remoteMkdir: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { connection, path })),
remoteDownloadInto: (connection: FsConnection, paths: string[], target: IpcPath, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("remote_download_into", { connection, paths, target, request })),
remoteRename: (connection: FsConnection, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_rename", { connection, from, to })),
/** Browse before connecting, using the desktop's SSH configuration even in a remote window. */
remoteBootstrapList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })),
@@ -4183,13 +4177,6 @@ export type DocumentInfo = {
lineEnding: LineEnding,
readOnly: boolean,
dirty: boolean,
/**
* Whether "Reload with Encoding" can re-decode this document's source
* bytes: true for files and virtual documents the backend can re-read
* (archive entries, JDT sources with an editable attachment encoding,
* Git revisions); false for server-generated content (decompiled code).
*/
canReloadEncoding: boolean,
/**
* Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
* structure and folding are disabled; the editor should drop heavy features.
@@ -9983,18 +9970,6 @@ export type VirtualDocument = {
description: string | null,
/** Generated Java from a decompiler, rather than real source or a disassembler stub. */
decompiled: boolean,
/**
* Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
* `utf-8` for text the server decoded itself.
*/
encoding: string,
hasBom: boolean,
/**
* "Reload with Encoding" can re-decode the origin's bytes (archive
* entries, JDT sources with an editable attachment encoding); false for
* server-generated content such as decompiled code.
*/
canReloadEncoding: boolean,
};
/** Declared (or conventional) visibility of an outline symbol. */
-1
Переглянути файл
@@ -396,7 +396,6 @@ export function createMemoryBackend(files: Record<string, string> = {}): MemoryB
lineEnding: d.lineEnding,
readOnly: d.readOnly,
dirty: d.text !== d.saved || d.hasBom !== d.savedBom,
canReloadEncoding: !d.uri,
...(d.uri ? { uri: d.uri } : {}),
...(d.uiMode ? { uiMode: d.uiMode } : {}),
});
+4 -88
Переглянути файл
@@ -81,10 +81,7 @@ export const commentLine: Command = (view) => {
lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)),
);
// Languages without line comments (CSS, HTML…): block comments.
if (tokens.some((t) => !t)) {
const first = doc.line(blocks[0]!.startLine);
return lineBound(state, first.from + leadingWs(first.text)) ? commentEachLine(view) : blockCommentLines(view);
}
if (tokens.some((t) => !t)) return blockCommentLines(view);
let allCommented = true;
let commentedNonEmpty = false;
@@ -351,92 +348,11 @@ function uncommentBlock(view: EditorView, ranges: { from: number; to: number }[]
/** ⌘⇧/: block comments the selection, or takes the block comment back out. */
export const commentBlock: Command = (view) => {
const { state } = view;
if (state.readOnly) return false;
const ranges = state.selection.ranges.map((r) => ({ from: r.from, to: r.to }));
if (uncommentBlock(view, ranges)) return true;
const multiline = state.selection.ranges.some(
(r) => state.doc.lineAt(r.from).number != state.doc.lineAt(r.to).number,
);
const bound = multiline ? lineBound(state, state.selection.main.from) : null;
if (!bound) return toggleBlockComment(view);
const { open, close } = bound.multiline;
view.dispatch(
state.changeByRange((r) => ({
changes: [
{ from: r.from, insert: open },
{ from: r.to, insert: close },
],
range: EditorSelection.range(r.from + open.length, r.to + open.length),
})),
{ scrollIntoView: true, userEvent: "input.comment" },
);
return true;
if (view.state.readOnly) return false;
const ranges = view.state.selection.ranges.map((r) => ({ from: r.from, to: r.to }));
return uncommentBlock(view, ranges) || toggleBlockComment(view);
};
/**
* Language data `lineBoundComments` of a language whose block comment can't span lines
* (Django's `{# #}`): `multiline` is the comment to use across lines.
*/
interface LineBoundComments {
multiline: CommentPair;
}
function lineBound(state: EditorState, pos: number): LineBoundComments | null {
return state.languageDataAt<LineBoundComments>("lineBoundComments", pos)[0] ?? null;
}
/**
* ⌘/ where a block comment can't span lines: each non-blank line gets its own, or loses it
* when every one has one. A caret alone moves down, as with line comments.
*/
function commentEachLine(view: EditorView): boolean {
const { state } = view;
const doc = state.doc;
const lines = new Set<number>();
for (const r of state.selection.ranges) {
const first = doc.lineAt(r.from).number;
let last = doc.lineAt(r.to).number;
if (last > first && doc.line(last).from === r.to) last--;
for (let n = first; n <= last; n++) lines.add(n);
}
const targets = [...lines]
.sort((a, b) => a - b)
.map((n) => doc.line(n))
.filter((line) => line.text.trim());
const pairs = targets.map((line) => commentPairs(state, line.from + leadingWs(line.text))[0]);
if (!targets.length || pairs.some((p) => !p)) return blockCommentLines(view);
const commented = targets.every((line, i) => {
const text = line.text.trim();
return text.startsWith(pairs[i]!.open) && text.endsWith(pairs[i]!.close);
});
const changes: ChangeSpec[] = targets.map((line, i) => {
const { open, close } = pairs[i]!;
const start = line.from + leadingWs(line.text);
const end = line.from + line.text.trimEnd().length;
if (!commented)
return [
{ from: start, insert: `${open} ` },
{ from: end, insert: ` ${close}` },
];
const inner = state.sliceDoc(start + open.length, end - close.length);
return { from: start, to: end, insert: inner.replace(/^ /, "").replace(/ $/, "") };
});
const caret = state.selection.ranges.length === 1 && state.selection.main.empty;
const changeSet = state.changes(changes);
const next = caret ? doc.lineAt(state.selection.main.head).number + 1 : 0;
view.dispatch({
changes: changeSet,
selection:
caret && next <= doc.lines
? EditorSelection.cursor(changeSet.mapPos(doc.line(next).from + leadingWs(doc.line(next).text)))
: state.selection.map(changeSet),
scrollIntoView: true,
userEvent: commented ? "delete.uncomment" : "input.comment",
});
return true;
}
/** ⌘/ in a language without line comments (CSS, HTML…): the block comment of the lines. */
function blockCommentLines(view: EditorView): boolean {
const ranges = selectedLines(view.state);
-112
Переглянути файл
@@ -1,112 +0,0 @@
// Django templates: the template layer. Tags (`{% … %}`), variables (`{{ … }}`)
// and comments are parsed here; the text between them is HTML, mounted by
// `./index.ts`. Block tags of Django and its contrib libraries nest
// (`{% if %}…{% elif %}…{% endif %}`); other tags, custom ones included, stand
// alone. Regenerate with `pnpm gen:django-grammar`.
@context blocks from "./tokens"
@top Template { node* }
node {
Text |
Comment |
Interpolation |
Tag |
Element |
CommentElement |
VerbatimElement
}
// A block tag, its middle tags (`elif`, `else`, `empty`, `plural`) and its end tag.
Element { OpenTag node* (MidTag node*)* EndTag }
// `{% comment %}…{% endcomment %}`: the content is not parsed.
CommentElement { CommentTag CommentBody? EndTag }
// `{% verbatim %}…{% endverbatim %}`: the content is HTML whose tags and
// variables Django leaves alone.
VerbatimElement { VerbatimTag VerbatimBody? EndTag }
@skip { space } {
OpenTag { OpenTagStart TagName argument* TagEnd }
CommentTag { CommentTagStart TagName argument* TagEnd }
VerbatimTag { VerbatimTagStart TagName argument* TagEnd }
MidTag { MidTagStart TagName argument* TagEnd }
EndTag { EndTagStart TagName argument* TagEnd }
Tag { TagStart TagName argument* TagEnd }
Interpolation { "{{" expression? "}}" }
argument {
expression |
CompareOp |
AssignOp { "=" } |
kw<"and"> | kw<"or"> | kw<"not"> | kw<"in"> | kw<"is"> |
kw<"as"> | kw<"with"> | kw<"only"> | kw<"from">
}
expression[@isGroup=Expression] {
VariableName |
MemberExpression |
String |
Number |
@specialize[@name=BooleanLiteral]<identifier, "True" | "False"> |
@specialize[@name=None]<identifier, "None"> |
FilterExpression
}
MemberExpression { (VariableName | MemberExpression) "." (PropertyName | Number) }
FilterExpression { expression !filter FilterOp FilterName (":" filterArgument)? }
filterArgument { VariableName | MemberExpression | String | Number }
}
@precedence { filter @left }
VariableName { identifier }
PropertyName { identifier }
FilterName { identifier }
// `with` is a keyword in arguments and a block tag's name.
TagName { identifier | kw<"with"> }
FilterOp { "|" }
TagEnd { "%}" }
kw<term> { @specialize[@name={term}]<identifier, term> }
// Tried in this order, before the tokens below: a raw block's content before
// the tags in it.
@external tokens comment from "./tokens" { Comment }
@external tokens commentBody from "./tokens" { CommentBody }
@external tokens verbatimBody from "./tokens" { VerbatimBody }
@external tokens text from "./tokens" { Text }
@external tokens tagStart from "./tokens" {
OpenTagStart, CommentTagStart, VerbatimTagStart, MidTagStart, EndTagStart, TagStart
}
@tokens {
space { $[ \t\n\r]+ }
identifier { $[a-zA-Z_\u{a1}-\u{10ffff}] $[a-zA-Z0-9_\u{a1}-\u{10ffff}]* }
Number { "-"? @digit+ ("." @digit+)? }
String {
'"' (!["\\] | "\\" _)* '"'? |
"'" (!['\\] | "\\" _)* "'"?
}
CompareOp { "==" | "!=" | "<" | ">" | "<=" | ">=" }
@precedence { CompareOp, "=" }
"{{" "}}" "." ":" "="
}
@detectDelim
-251
Переглянути файл
@@ -1,251 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { CompletionContext, type CompletionResult, type CompletionSource } from "@codemirror/autocomplete";
import { foldable, getIndentation, indentUnit, matchBrackets } from "@codemirror/language";
import { buildParserFile } from "@lezer/generator";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { completedSyntaxState, completedSyntaxTree } from "@/test/syntax";
import { commentBlock, commentLine } from "../comments";
import { findLanguageDescription, loadSupport } from "../languages";
import { django, djangoLanguage } from "./index";
const highlighter = tagHighlighter([
{ tag: t.special(t.brace), class: "delim" },
{ tag: t.blockComment, class: "comment" },
{ tag: t.operatorKeyword, class: "opkw" },
{ tag: t.keyword, class: "keyword" },
{ tag: t.bool, class: "bool" },
{ tag: t.null, class: "null" },
{ tag: t.function(t.variableName), class: "filter" },
{ tag: t.variableName, class: "var" },
{ tag: t.propertyName, class: "prop" },
{ tag: t.attributeName, class: "attr" },
{ tag: t.string, class: "str" },
{ tag: t.number, class: "num" },
{ tag: t.tagName, class: "tag" },
]);
function state(doc: string, selection?: EditorSelection | number): EditorState {
return completedSyntaxState(
EditorState.create({
doc,
selection: typeof selection == "number" ? EditorSelection.cursor(selection) : selection,
extensions: [django(), indentUnit.of(" ")],
}),
);
}
/** `text:class` of the styled tokens of `doc`, HTML included. */
function tokens(doc: string): string[] {
const out: string[] = [];
highlightTree(completedSyntaxTree(state(doc)), highlighter, (from, to, cls) =>
out.push(`${doc.slice(from, to)}:${cls}`),
);
return out;
}
/** The error nodes of `doc`'s template layer, with the text they start. */
function errors(doc: string): string[] {
const out: string[] = [];
djangoLanguage.parser.parse(doc).iterate({
enter: (node) => void (node.type.isError && out.push(doc.slice(node.from, node.from + 12))),
});
return out;
}
/** The top-level node names of `doc`'s template layer. */
function outline(doc: string): string {
return djangoLanguage.parser.parse(doc).toString();
}
/** A view on `doc` with `|` marking the caret. */
function view(marked: string): EditorView {
const at = marked.indexOf("|");
const doc = marked.slice(0, at) + marked.slice(at + 1);
return new EditorView({ state: state(doc, at) });
}
/** Types `text` at the caret through the input handlers, like a keystroke. */
function type(v: EditorView, text: string): string {
const { from, to } = v.state.selection.main;
const handled = v.state.facet(EditorView.inputHandler).some((h) => h(v, from, to, text, () => v.state.update({})));
if (!handled)
v.dispatch({ changes: { from, to, insert: text }, selection: EditorSelection.cursor(from + text.length) });
const head = v.state.selection.main.head;
return v.state.sliceDoc(0, head) + "|" + v.state.sliceDoc(head);
}
describe("Django template grammar", () => {
it("parses Django's tags, filters with arguments and its operators without errors", () => {
const doc = [
`{% extends "base.html" %}{% load static i18n %}`,
`{% block content %}{{ block.super }}`,
`<a href="{% url 'app:detail' pk=obj.pk as link %}">{{ value|date:"Y-m-d"|default:'-' }}</a>`,
`<img src="{% static 'img/a.png' %}">{% csrf_token %}`,
`{% for x in items|dictsort:"name" %}{{ forloop.counter0 }} {{ x.0 }}{% empty %}none{% endfor %}`,
`{% if a and not b or c in d %}x{% elif e is not None and n >= -1 %}y{% else %}z{% endif %}`,
`{% blocktranslate with n=count trimmed %}{{ n }} item{% plural %}{{ n }} items{% endblocktranslate %}`,
`{% with total=business.employees.count %}{{ total }}{% endwith %}`,
`{% include "row.html" with item=item only %}{% cycle 'odd' 'even' %}{% now "Y" %}`,
`{% render_field form.email class="input" %}{% endblock content %}`,
].join("\n");
expect(errors(doc)).toEqual([]);
});
it("nests block tags and leaves other tags alone", () => {
expect(outline("{% if a %}{% for x in b %}{{ x }}{% endfor %}{% else %}-{% endif %}")).toBe(
"Template(Element(OpenTag(OpenTagStart,TagName,VariableName,TagEnd)," +
"Element(OpenTag(OpenTagStart,TagName,VariableName,in,VariableName,TagEnd)," +
'Interpolation("{{",VariableName,"}}"),EndTag(EndTagStart,TagName,TagEnd)),' +
"MidTag(MidTagStart,TagName,TagEnd),Text,EndTag(EndTagStart,TagName,TagEnd)))",
);
expect(outline("{% csrf_token %}{% else %}")).toBe(
"Template(Tag(TagStart,TagName,TagEnd),Tag(TagStart,TagName,TagEnd))",
);
});
it("closes a block only with its own end tag", () => {
// `endfor` closes nothing here: the `if` stays open to the end.
const tree = outline("{% if a %}{% endfor %}");
expect(tree).toContain("Element(OpenTag(");
expect(tree).toContain("Tag(TagStart,TagName,TagEnd)");
});
it("doesn't parse the content of comment and verbatim", () => {
expect(errors("{% comment 'why' %}{% if %} {{ {% endcomment %}")).toEqual([]);
expect(outline("{% verbatim %}{{ raw }}{% if %}{% endverbatim %}")).toBe(
"Template(VerbatimElement(VerbatimTag(VerbatimTagStart,TagName,TagEnd),VerbatimBody,EndTag(EndTagStart,TagName,TagEnd)))",
);
});
it("highlights the template layer and the markup around it", () => {
expect(tokens(`<p class="x">{{ user.name|lower }}{# hi #}</p>`)).toEqual([
"p:tag",
"class:attr",
'"x":str',
"{{:delim",
"user:var",
"name:prop",
"lower:filter",
"}}:delim",
"{# hi #}:comment",
"p:tag",
]);
expect(tokens(`{% if a is None and b %}{% endif %}`)).toEqual([
"{%:delim",
"if:keyword",
"a:var",
"is:opkw",
"None:null",
"and:opkw",
"b:var",
"%}:delim",
"{%:delim",
"endif:keyword",
"%}:delim",
]);
});
it("parses the templates of the Django fixture without errors", () => {
const fixture = resolve(import.meta.dirname, "../../../../../fixtures/django-app");
for (const file of [
"shop/templates/shop/base.html",
"shop/templates/shop/product_list.html",
"shop/templates/shop/email/order.txt",
"pages/promo.html",
]) {
expect([file, errors(readFileSync(resolve(fixture, file), "utf8"))]).toEqual([file, []]);
}
});
it("is generated from django.grammar", () => {
const dir = import.meta.dirname;
const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "django.grammar"), "utf8"), {
fileName: "src/editor/core/cm/django/django.grammar",
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
});
describe("Django template editing", () => {
it("folds blocks, comments and verbatim content", () => {
const doc = "{% block content %}\n <p>x</p>\n{% endblock %}\n{% comment %}\nold\n{% endcomment %}\n";
const s = state(doc);
expect(foldable(s, 0, doc.indexOf("\n"))).toEqual({ from: doc.indexOf("%}") + 2, to: doc.indexOf("{% endblock") });
const comment = doc.indexOf("{% comment");
expect(foldable(s, comment, doc.indexOf("\n", comment))).toEqual({
from: doc.indexOf("%}", comment) + 2,
to: doc.indexOf("{% endcomment"),
});
});
it("indents a block's content and puts its middle and end tags back", () => {
const doc = "{% if a %}\nx\n{% else %}\ny\n{% endif %}";
const s = state(doc);
const lineStart = (n: number) => s.doc.line(n).from;
expect(getIndentation(s, lineStart(2))).toBe(2);
expect(getIndentation(s, lineStart(3))).toBe(0);
expect(getIndentation(s, lineStart(5))).toBe(0);
});
it("matches a block tag with its end tag by name", () => {
const doc = "{% for x in xs %}{{ x }}{% endfor %}";
const match = matchBrackets(state(doc), 4, 1);
expect(match?.matched).toBe(true);
expect(doc.slice(match!.end!.from, match!.end!.to)).toBe("endfor");
});
it("closes {% %}, {{ }} and {# #} after a brace", () => {
expect(type(view("<p>{|</p>"), "%")).toBe("<p>{% | %}</p>");
expect(type(view("<p>{|}</p>"), "{")).toBe("<p>{{ | }}</p>");
expect(type(view("{|"), "#")).toBe("{# | #}");
// Not in a script, where `{` is JavaScript's.
expect(type(view("<script>let a = {|</script>"), "{")).toBe("<script>let a = {{|</script>");
});
it("adds the end tag of a block tag when its %} is typed", () => {
expect(type(view("{% block content %|"), "}")).toBe("{% block content %}|{% endblock %}");
expect(type(view("{% if a %|}"), "}")).toBe("{% if a %}|{% endif %}");
// It has one already.
expect(type(view("{% if a %|\n{% endif %}"), "}")).toBe("{% if a %}|\n{% endif %}");
expect(type(view("{% if a %|{% if b %}{% endif %}"), "}")).toBe("{% if a %}|{% endif %}{% if b %}{% endif %}");
// Not a block tag.
expect(type(view("{% csrf_token %|"), "}")).toBe("{% csrf_token %}|");
});
it("completes the end tags of the open blocks, innermost first", () => {
const doc = "{% block a %}{% for x in y %}{% end";
const s = state(doc);
const sources = s.languageDataAt<CompletionSource>("autocomplete", doc.length);
const result = sources.map((src) => src(new CompletionContext(s, doc.length, false))).find(Boolean) as
| CompletionResult
| undefined;
expect(result?.options.map((o) => o.label)).toEqual(["endfor", "endblock"]);
});
it("comments each line with {# #}, and several lines with a comment tag", () => {
const v = view("|<p>a</p>\n <p>b</p>");
v.dispatch({ selection: EditorSelection.range(0, v.state.doc.length) });
commentLine(v);
expect(v.state.doc.toString()).toBe("{# <p>a</p> #}\n {# <p>b</p> #}");
v.dispatch({ selection: EditorSelection.range(0, v.state.doc.length) });
commentLine(v);
expect(v.state.doc.toString()).toBe("<p>a</p>\n <p>b</p>");
v.dispatch({ selection: EditorSelection.range(0, v.state.doc.length) });
commentBlock(v);
expect(v.state.doc.toString()).toBe("{% comment %}<p>a</p>\n <p>b</p>{% endcomment %}");
commentBlock(v);
expect(v.state.doc.toString()).toBe("<p>a</p>\n <p>b</p>");
});
it("is the Django Template mode", async () => {
const desc = findLanguageDescription("Django Template", "/p/templates/index.html");
expect((await loadSupport(desc!))?.language).toBe(djangoLanguage);
});
});
-223
Переглянути файл
@@ -1,223 +0,0 @@
/**
* Django templates: `django.grammar` parses the template layer (tags,
* variables, comments), and the text between its elements is one HTML
* document, an overlay as in JSP, so markup split by a tag still nests and
* `href="{% url 'home' %}"` is an attribute with a hole. Block tags fold,
* indent their content and match their end tags; typing `{%`, `{{` or `{#`
* closes the pair, and finishing a block tag adds its end tag.
*/
import {
LRLanguage,
LanguageSupport,
bracketMatchingHandle,
foldNodeProp,
indentNodeProp,
syntaxTree,
type TreeIndentContext,
} from "@codemirror/language";
import { html } from "@codemirror/lang-html";
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { EditorSelection, Prec, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { styleTags, tags as t } from "@lezer/highlight";
import { NodeProp, parseMixed, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common";
import { parser } from "./parser";
import { BLOCK_TAGS } from "./tokens";
export { BLOCK_TAGS };
const baseHTML = html({ selfClosingTags: true });
/** `{# #}` can't span lines: a multi-line block comment is a `comment` tag. */
const DJANGO_COMMENT = { block: { open: "{#", close: "#}" } };
const COMMENT_TAG = { open: "{% comment %}", close: "{% endcomment %}" };
const OPENERS = ["OpenTag", "CommentTag", "VerbatimTag"];
const startTagOf = (element: SyntaxNode) => element.firstChild;
const endTagOf = (element: SyntaxNode) => (element.lastChild?.name == "EndTag" ? element.lastChild : null);
/** The content of a block: from the end of its start tag to its end tag (or the end of the block). */
function foldBlock(element: SyntaxNode, state: EditorState) {
const start = startTagOf(element);
const end = endTagOf(element);
if (!start) return null;
// An end tag the parser inserted to recover is empty.
const to = end && end.to > end.from ? end.from : element.to;
return start.to < to && state.doc.lineAt(start.to).number < state.doc.lineAt(to).number
? { from: start.to, to }
: null;
}
const CLOSING_LINE = /^\s*\{%-?\s*(end\w*|elif|else|empty|plural)\b/;
/** A block's content one unit in from its start tag's line; its middle and end tags at that line's indent. */
function indentBlock(cx: TreeIndentContext) {
const start = startTagOf(cx.node);
if (!start) return null;
const base = cx.lineIndent(start.from);
return CLOSING_LINE.test(cx.textAfter) ? base : base + cx.unit;
}
const tagName = (node: SyntaxNode) => node.getChild("TagName");
const templateParser = parser.configure({
props: [
styleTags({
'TagStart OpenTagStart CommentTagStart VerbatimTagStart MidTagStart EndTagStart TagEnd "{{" "}}"': t.special(
t.brace,
),
TagName: t.keyword,
"Comment CommentBody": t.blockComment,
VariableName: t.variableName,
PropertyName: t.propertyName,
FilterName: t.function(t.variableName),
FilterOp: t.operator,
String: t.string,
Number: t.number,
BooleanLiteral: t.bool,
None: t.null,
CompareOp: t.compareOperator,
AssignOp: t.definitionOperator,
"and or not in is": t.operatorKeyword,
"as with only from": t.keyword,
'"."': t.derefOperator,
'":"': t.punctuation,
}),
foldNodeProp.add({
"Element CommentElement VerbatimElement": foldBlock,
}),
indentNodeProp.add({
Element: indentBlock,
// The raw content keeps its own indentation.
"CommentElement VerbatimElement": () => null,
}),
NodeProp.closedBy.add({ "OpenTag CommentTag VerbatimTag": ["EndTag"] }),
NodeProp.openedBy.add({ EndTag: OPENERS }),
bracketMatchingHandle.add({ "OpenTag CommentTag VerbatimTag EndTag": tagName }),
],
});
// HTML's document gives no indentation past its last text; there the block around it does.
const htmlBase = (baseHTML.language as LRLanguage).parser;
const htmlDocumentIndent = htmlBase.nodeSet.types.find((type) => type.name == "Document")?.prop(indentNodeProp);
const htmlParser = htmlBase.configure({
props: [indentNodeProp.add({ Document: (cx) => htmlDocumentIndent?.(cx) ?? cx.continue() })],
});
// The template text, and the content of `verbatim`, are one HTML document.
const htmlMixed = {
parser: htmlParser,
overlay: (node: SyntaxNodeRef) => node.name == "Text" || node.name == "VerbatimBody",
};
export const djangoLanguage = LRLanguage.define({
name: "django",
parser: templateParser.configure({ wrap: parseMixed((node) => (node.type.isTop ? htmlMixed : null)) }),
});
/** Where the template layer reaches: Django's own nodes, or the HTML outside `<script>` and `<style>`. */
function inTemplate(state: EditorState, pos: number): boolean {
return state.languageDataAt<boolean>("djangoTemplate", pos).includes(true);
}
const PAIRS: Record<string, string> = { "%": "%", "{": "}", "#": "#" };
/**
* After a `{`, typing `%`, `{` or `#` makes the pair: `{% | %}`, `{{ | }}`, `{# | #}`. The
* `}` that closing brackets put after the first brace is reused.
*/
const closePair = EditorView.inputHandler.of((view, from, to, text) => {
const close = PAIRS[text];
const { state } = view;
if (!close || from != to || state.selection.ranges.length > 1 || state.readOnly) return false;
if (state.sliceDoc(from - 1, from) != "{" || state.sliceDoc(from - 2, from - 1) == "{") return false;
if (!inTemplate(state, from)) return false;
const reuse = state.sliceDoc(from, from + 1) == "}";
const insert = `${text} ${close}${reuse ? "" : "}"}`;
view.dispatch({
changes: { from, insert },
selection: EditorSelection.cursor(from + text.length + 1),
userEvent: "input.type",
});
return true;
});
const TAG_BEFORE = /\{%-?\s*([A-Za-z_]\w*)[^%]*%$/;
/** Whether the block `name` opened before `pos` has no end tag after it, nested blocks counted. */
function unclosed(state: EditorState, name: string, pos: number): boolean {
const rest = state.sliceDoc(pos);
const re = new RegExp(`\\{%-?\\s*(end)?${name}\\b`, "g");
let depth = 0;
for (let m; (m = re.exec(rest));) {
if (!m[1]) depth++;
else if (depth-- == 0) return false;
}
return true;
}
/** Typing the `}` of a block tag's `%}` adds its end tag after the caret when it has none. */
const closeBlock = EditorView.inputHandler.of((view, from, to, text) => {
const { state } = view;
if (text != "}" || from != to || state.selection.ranges.length > 1 || state.readOnly) return false;
const line = state.doc.lineAt(from);
const m = TAG_BEFORE.exec(state.sliceDoc(line.from, from));
const name = m?.[1];
if (!name || !BLOCK_TAGS.has(name) || !inTemplate(state, from - m[0].length)) return false;
// A `}` closing brackets put there is typed over.
const after = state.sliceDoc(from, from + 1) == "}" ? 1 : 0;
if (!unclosed(state, name, from + after)) return false;
view.dispatch({
changes: { from, to: from + after, insert: `}{% end${name} %}` },
selection: EditorSelection.cursor(from + 1),
userEvent: "input.type",
});
return true;
});
/** The blocks open at `pos`, innermost first. */
function openBlocks(state: EditorState, pos: number): string[] {
const names: string[] = [];
for (let node: SyntaxNode | null = syntaxTree(state).resolve(pos, -1); node; node = node.parent) {
if (!/^(Element|CommentElement|VerbatimElement)$/.test(node.name)) continue;
const start = startTagOf(node);
const name = start && tagName(start);
if (start && name && start.to <= pos) names.push(state.sliceDoc(name.from, name.to));
}
return names;
}
/** `{% end…`: the end tags of the blocks open at the caret, innermost first. */
function endTagCompletion(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\{%-?\s*\w*$/);
if (!word) return null;
const name = /\w*$/.exec(word.text)![0];
if (!name && !context.explicit) return null;
const from = word.to - name.length;
// Template syntax, not UI text.
const ends = openBlocks(context.state, from).map((block) => `end${block}`);
const options = ends.map((end, i) => ({ label: end, type: "keyword", boost: 99 - i }));
return options.length ? { from, options, validFor: /^\w*$/ } : null;
}
export function django(): LanguageSupport {
return new LanguageSupport(djangoLanguage, [
baseHTML.support,
// Markup is commented out with a template comment, which Django drops from the page.
baseHTML.language.data.of({ commentTokens: DJANGO_COMMENT, lineBoundComments: { multiline: COMMENT_TAG } }),
baseHTML.language.data.of({ djangoTemplate: true, autocomplete: endTagCompletion }),
djangoLanguage.data.of({
djangoTemplate: true,
autocomplete: endTagCompletion,
commentTokens: DJANGO_COMMENT,
lineBoundComments: { multiline: COMMENT_TAG },
}),
// A multi-line block comment, after `{# #}`, so that taking one out finds it.
Prec.lowest([
djangoLanguage.data.of({ commentTokens: { block: COMMENT_TAG } }),
baseHTML.language.data.of({ commentTokens: { block: COMMENT_TAG } }),
]),
Prec.high([closePair, closeBlock]),
]);
}
-45
Переглянути файл
@@ -1,45 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Comment = 1,
CommentBody = 2,
VerbatimBody = 3,
Text = 4,
OpenTagStart = 5,
CommentTagStart = 6,
VerbatimTagStart = 7,
MidTagStart = 8,
EndTagStart = 9,
TagStart = 10,
Template = 11,
Interpolation = 14,
VariableName = 15,
MemberExpression = 16,
PropertyName = 18,
Number = 19,
String = 20,
BooleanLiteral = 21,
None = 22,
FilterExpression = 23,
FilterOp = 24,
FilterName = 25,
Tag = 27,
TagName = 28,
_with = 29,
CompareOp = 30,
and = 33,
or = 34,
not = 35,
_in = 36,
is = 37,
as = 38,
only = 39,
from = 40,
TagEnd = 41,
Element = 42,
OpenTag = 43,
MidTag = 44,
EndTag = 45,
CommentElement = 46,
CommentTag = 47,
VerbatimElement = 48,
VerbatimTag = 49
-25
Переглянути файл
@@ -1,25 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {comment, commentBody, verbatimBody, text, tagStart, blocks} from "./tokens"
const spec_identifier = {__proto__:null,True:42, False:42, None:44, with:58, and:66, or:68, not:70, in:72, is:74, as:76, only:78, from:80}
export const parser = LRParser.deserialize({
version: 14,
states: "+WQVO!ZOOO!PQpO'#CjO!WQpO'#CwO!WQpO'#DXO!`O!ZO'#DWO!WQpO'#D]O!jObO'#D[O!WQpO'#D_O!rOdO'#D^OOOi'#Df'#DfOOOi'#D`'#D`QVO!ZOOOOQO'#Ck'#CkO!zQpO'#DgO$[QpO,59UOOQO'#Dg'#DgOOOi,59U,59UOOQO'#Cx'#CxO$dQpO,59cO$dQpO,59sO!WQpO'#DYO%^O!ZO'#DbO%hO`O,59rO!WQpO'#DZOOOi,59r,59rO!`O!ZO,59rO$dQpO,59wOOOi,59v,59vO!mO`O,59vO$dQpO,59yOOOi,59x,59xO!mO`O,59xOOOi-E7^-E7^O%pQpO,59WOOQO'#Ct'#CtO%xQpO,59_OOOi1G.p1G.pO%}QpO'#DlOOQO'#C{'#C{OOQO'#Dl'#DlOOQO'#Da'#DaO$dQpO1G.}OOOo'#DV'#DVOOOi1G.}1G.}O$dQpO1G/_OOOi1G/_1G/_O$dQpO,59tO'XO!ZO,59|OOO`-E7`-E7`OOOi1G/^1G/^O$dQpO,59uO%hO`O1G/^O$dQpO1G/cOOOb1G/c1G/cOOOi1G/b1G/bO$dQpO1G/eOOOd1G/e1G/eOOOi1G/d1G/dOOQO'#Cn'#CnOOQO1G.r1G.rOOQO'#Cu'#CuO'cQpO1G.yOOQO-E7_-E7_OOOi7+$i7+$iOOOi7+$y7+$yO$dQpO1G/`OOOi1G/`1G/`O$dQpO1G/aOOOi1G/a1G/aOOOi7+$x7+$xOOOb7+$}7+$}OOOd7+%P7+%PO(sQpO7+$eOOOi7+$z7+$zOOOi7+${7+${O)OQpO'#DjOOQO'#Dj'#DjOOQO<<HP<<HP",
stateData: "*c~O!XOS~OPXOSXOTROUTOVVOYQO]PO~Oc_Od_Oe_Of_O![[O~O[`O~PnOmaO![aO~OWdOXgO~PVOQlOXgO~ORoOXgO~OaqO[!ZX!]!ZXc!ZXd!ZXe!ZXf!ZXm!ZXn!ZXp!ZXq!ZXr!ZXs!ZXt!ZXu!ZXv!ZXw!ZXx!ZX![!ZX!_!ZX~O[tO!]rO~OmwOnwOpvOqwOrwOswOtwOuwOvwOwwOxwO!_zO~PnOW!UXX!UX~PVOWdOXgO~Oc!]O![![O~O![!^O~O!]rOc!`Xd!`Xe!`Xf!`Xm!`Xn!`Xp!`Xq!`Xr!`Xs!`Xt!`Xu!`Xv!`Xw!`Xx!`X![!`X!_!`X~OW!UaX!Ua~PVOj!jO[gi!]gicgidgiegifgimgingipgiqgirgisgitgiugivgiwgixgi![gi!_gi~Oc!nOd!nO![[O~OaqO[!^X!]!^Xc!^Xd!^Xe!^Xf!^Xm!^Xn!^Xp!^Xq!^Xr!^Xs!^Xt!^Xu!^Xv!^Xw!^Xx!^X![!^X!_!^X~Onp~",
goto: "(T!aPPPPPPPPPPPPPP!b!j!jP!|PPPP#P#`#dP!b#gPP#yPPPPPPPPP$X!b$}%V%]!b%s!b%{&T&c&}PPP'X'aPP'rP'u]XOSZei!Pj]Pbcjmy|!O!S!U!X!c!eR!m!jR!]qk_Pbcjmy|!O!S!U!X!c!eTs^uR!_sQbQQcRQjTQmVQ!OdR!Sgiwbcjmy|!O!S!U!X!c!eQ{bQ}cQ!VjQ!YmQ!ayQ!b|Q!d!OQ!f!SQ!h!UQ!i!XQ!k!cR!l!e]SOSZei!PXeSfi!TQhSQkUQnWS!RfiQ!WlQ!ZoR!g!T]UOSZei!P]WOSZei!PQZOQiSUpZi!PR!PeQybQ|cQ!UjQ!Xm[!`y|!U!X!c!eQ!c!OR!e!SQfSS!Qf!TR!Ti]YOSZei!PQ^Piubcjmy|!O!S!U!X!c!eR!o!jixbcjmy|!O!S!U!X!c!e",
nodeNames: "⚠ Comment CommentBody VerbatimBody Text OpenTagStart CommentTagStart VerbatimTagStart MidTagStart EndTagStart TagStart Template }} {{ Interpolation VariableName MemberExpression . PropertyName Number String BooleanLiteral None FilterExpression FilterOp FilterName : Tag TagName with CompareOp AssignOp = and or not in is as only from TagEnd Element OpenTag MidTag EndTag CommentElement CommentTag VerbatimElement VerbatimTag",
maxTerm: 62,
context: blocks,
nodeProps: [
["openedBy", 12,"{{"],
["closedBy", 13,"}}"],
["group", -7,15,16,19,20,21,22,23,"Expression"]
],
skippedNodes: [0],
repeatNodeCount: 3,
tokenData: ")]~RhXY!mYZ!m]^!mpq!mqr#Ors#Zuv${wx%W}!O&s!O!P'd!Q![&y![!]'i!^!_'n!_!`'v!`!a'n!c!}(O#R#S(O#T#o(O#o#p(p#p#q({#q#r)Q$g;'S(O;'S;=`(j<%lO(O~!rS!X~XY!mYZ!m]^!mpq!m~#RP!_!`#U~#ZOn~~#`Vd~Or#Zrs#us#O#Z#O#P#z#P;'S#Z;'S;=`$u<%lO#Z~#zOd~~#}RO;'S#Z;'S;=`$W;=`O#Z~$]Wd~Or#Zrs#us#O#Z#O#P#z#P;'S#Z;'S;=`$u;=`<%l#Z<%lO#Z~$xP;=`<%l#Z~%OP#q#r%R~%WO!_~~%]Vd~Ow%Wwx#ux#O%W#O#P%r#P;'S%W;'S;=`&m<%lO%W~%uRO;'S%W;'S;=`&O;=`O%W~&TWd~Ow%Wwx#ux#O%W#O#P%r#P;'S%W;'S;=`&m;=`<%l%W<%lO%W~&pP;=`<%l%W~&vP!Q![&y~'OQc~!O!P'U!Q![&y~'XP!Q!['[~'aPc~!Q!['[~'iOa~~'nOj~~'sPn~!_!`#U~'{Pp~!_!`#U~(TV![~!Q![(O!c!}(O#R#S(O#T#o(O$g;'S(O;'S;=`(j<%lO(O~(mP;=`<%l(O~(sP#o#p(v~({O]~~)QO!]~~)TP#q#r)W~)]O[~",
tokenizers: [comment, commentBody, verbatimBody, text, tagStart, 0],
topRules: {"Template":[0,11]},
specialized: [{term: 58, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
tokenPrec: 430
})
-152
Переглянути файл
@@ -1,152 +0,0 @@
/**
* External tokens of `django.grammar`: the text between template elements, the
* comments, the raw content of `comment` and `verbatim`, and the start of each
* tag, classified by its name against the blocks open around it.
*/
import { ContextTracker, ExternalTokenizer, type InputStream, type Stack } from "@lezer/lr";
import {
Comment,
CommentBody,
CommentElement,
CommentTagStart,
Element,
EndTagStart,
MidTagStart,
OpenTagStart,
TagStart,
Text,
VerbatimBody,
VerbatimElement,
VerbatimTagStart,
} from "./parser.terms";
const BRACE_L = 123,
BRACE_R = 125,
HASH = 35,
PERCENT = 37;
/**
* Block tags of Django and its contrib libraries (`i18n`, `l10n`, `tz`, `cache`), with the
* middle tags each one allows.
*/
export const BLOCK_TAGS: ReadonlyMap<string, readonly string[]> = new Map([
["autoescape", []],
["block", []],
["blocktrans", ["plural"]],
["blocktranslate", ["plural"]],
["cache", []],
["comment", []],
["filter", []],
["for", ["empty"]],
["if", ["elif", "else"]],
["ifchanged", ["else"]],
["language", []],
["localize", []],
["localtime", []],
["partialdef", []],
["spaceless", []],
["timezone", []],
["verbatim", []],
["with", []],
]);
/** The block tags open around a position, innermost first. */
class Block {
readonly hash: number;
constructor(
readonly name: string,
readonly parent: Block | null,
) {
let hash = parent ? parent.hash : 0;
for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
this.hash = hash;
}
}
const isNameStart = (ch: number) => (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == 95;
const isName = (ch: number) => isNameStart(ch) || (ch >= 48 && ch <= 57);
const isSpace = (ch: number) => ch == 32 || ch == 9 || ch == 10 || ch == 13;
/** The name of a tag whose `{%` is `offset` code units ahead. */
function tagName(input: InputStream, offset = 0): string {
let i = offset + 2;
while (isSpace(input.peek(i))) i++;
let name = "";
for (let ch; isName((ch = input.peek(i))); i++) {
if (!name && !isNameStart(ch)) break;
name += String.fromCharCode(ch);
}
return name;
}
const tagAt = (input: InputStream, offset = 0) => input.peek(offset) == BRACE_L && input.peek(offset + 1) == PERCENT;
/** Whether `{{`, `{%` or `{#` starts `offset` code units ahead. */
function elementStart(input: InputStream, offset = 0): boolean {
if (input.peek(offset) != BRACE_L) return false;
const next = input.peek(offset + 1);
return next == BRACE_L || next == PERCENT || next == HASH;
}
export const blocks = new ContextTracker<Block | null>({
start: null,
shift(context, term, _stack, input) {
return term == OpenTagStart || term == CommentTagStart || term == VerbatimTagStart
? new Block(tagName(input), context)
: context;
},
reduce(context, term) {
return (term == Element || term == CommentElement || term == VerbatimElement) && context ? context.parent : context;
},
hash: (context) => (context ? context.hash : 0),
strict: false,
});
/** `{# … #}`, to the end of the line when unterminated (it can't span lines). */
export const comment = new ExternalTokenizer((input) => {
if (input.next != BRACE_L || input.peek(1) != HASH) return;
input.advance(2);
for (let ch; (ch = input.peek(0)) >= 0 && ch != 10;) {
if (ch == HASH && input.peek(1) == BRACE_R) {
input.advance(2);
break;
}
input.advance();
}
input.acceptToken(Comment);
});
/** Template text: up to a tag, a variable or a comment. */
export const text = new ExternalTokenizer((input) => {
const start = input.pos;
while (input.next >= 0 && !elementStart(input)) input.advance();
if (input.pos > start) input.acceptToken(Text);
});
/** The `{%` of a tag, a token telling what the tag is in the blocks open around it. */
export const tagStart = new ExternalTokenizer((input, stack: Stack) => {
if (!tagAt(input)) return;
const name = tagName(input);
const open: Block | null = stack.context;
let term = TagStart;
if (name == "comment") term = CommentTagStart;
else if (name == "verbatim") term = VerbatimTagStart;
else if (BLOCK_TAGS.has(name)) term = OpenTagStart;
else if (open && name == `end${open.name}`) term = EndTagStart;
else if (open && BLOCK_TAGS.get(open.name)?.includes(name)) term = MidTagStart;
if (!stack.canShift(term)) term = TagStart;
if (stack.canShift(term)) input.acceptToken(term, 2);
});
/** The content of a raw block: up to the `{% end<name>` closing it. */
function rawBody(name: string, token: number) {
const end = `end${name}`;
return new ExternalTokenizer((input) => {
const start = input.pos;
while (input.next >= 0 && !(tagAt(input) && tagName(input) == end)) input.advance();
if (input.pos > start) input.acceptToken(token);
});
}
export const commentBody = rawBody("comment", CommentBody);
export const verbatimBody = rawBody("verbatim", VerbatimBody);
-15
Переглянути файл
@@ -33,21 +33,6 @@ describe("goModParser", () => {
]);
});
it("highlights tool, godebug and ignore directives", () => {
const state = initialGoModState();
expect(tokenize("tool golang.org/x/tools/cmd/stringer", state)).toEqual([
{ text: "tool", token: "keyword" },
{ text: "golang.org/x/tools/cmd/stringer", token: "string" },
]);
expect(tokenize("tool (", state)[0]).toEqual({ text: "tool", token: "keyword" });
expect(tokenize("\tgithub.com/air-verse/air", state)).toEqual([
{ text: "github.com/air-verse/air", token: "string" },
]);
tokenize(")", state);
expect(tokenize("godebug default=go1.21", state)[0]).toEqual({ text: "godebug", token: "keyword" });
expect(tokenize("ignore ./node_modules", state)[0]).toEqual({ text: "ignore", token: "keyword" });
});
it("highlights require blocks with module paths and versions", () => {
const state = initialGoModState();
+1 -14
Переглянути файл
@@ -1,19 +1,6 @@
import { StreamLanguage, type StreamParser } from "@codemirror/language";
/** go.mod and go.work directives (`tool` since Go 1.24, `ignore` since Go 1.25). */
const KEYWORDS = new Set([
"module",
"go",
"toolchain",
"godebug",
"require",
"replace",
"exclude",
"retract",
"tool",
"ignore",
"use",
]);
const KEYWORDS = new Set(["module", "go", "toolchain", "require", "replace", "exclude", "retract", "use"]);
export interface GoModState {
currentKeyword: string | null;
-6
Переглянути файл
@@ -131,12 +131,6 @@ const OWN_MODES = [
load: () => import("./erb").then((m) => m.erb()),
}),
LanguageDescription.of({ name: "JSP", load: () => import("./jsp").then((m) => m.jsp()) }),
LanguageDescription.of({
name: "Django Template",
alias: ["django-html", "djhtml"],
extensions: ["djhtml"],
load: () => import("./django").then((m) => m.django()),
}),
LanguageDescription.of({
name: "Astro",
extensions: ["astro"],
-3
Переглянути файл
@@ -148,9 +148,6 @@ describe("custom editor document lifetime", () => {
title: "Class",
description: null,
decompiled: true,
encoding: "utf-8",
hasBom: false,
canReloadEncoding: false,
});
await rejected;
expect(findTab(useTabs.getState(), "virtual:jdt://old/Class")).toBeNull();
-3
Переглянути файл
@@ -86,8 +86,6 @@ export interface DocMeta {
lineEnding: LineEnding;
readOnly: boolean;
dirty: boolean;
/** "Reload with Encoding" can re-decode this document's source bytes. */
canReloadEncoding: boolean;
banner: Banner;
indent: { tabSize: number; insertSpaces: boolean };
}
@@ -256,7 +254,6 @@ export class DocModel {
// Library sources (External Libraries) open read-only, like IDEA's.
readOnly: info.readOnly || (!this.virtual && isLibraryPath(info.path)),
dirty: info.dirty,
canReloadEncoding: info.canReloadEncoding,
banner: bannerFor(info),
indent: { tabSize: 4, insertSpaces: true },
};
-76
Переглянути файл
@@ -28,8 +28,6 @@ class FakeServer {
edits: Array<{ base: number; changes: TextChange[] }> = [];
private queue: Array<() => void> = [];
snapshots = 0;
/** Snapshots still to reject (a transient IPC or remote-host failure). */
failSnapshots = 0;
failNext: unknown = null;
constructor(public text: string) {}
@@ -54,10 +52,6 @@ class FakeServer {
}),
snapshot: async () => {
this.snapshots++;
if (this.failSnapshots > 0) {
this.failSnapshots--;
throw { kind: "unavailable", message: "snapshot failed" };
}
return { text: this.text, version: this.version };
},
};
@@ -224,76 +218,6 @@ describe("DocSync", () => {
}
});
it("keeps syncing when the snapshot of a rebase fails once", async () => {
vi.useFakeTimers();
try {
const { server, view, sync, type, frame, host } = setup("one\ntwo\n");
type(8, 8, "three\n");
frame();
server.patch([{ from: 0, to: 3, insert: "ONE" }]); // event lost
server.failSnapshots = 1;
let idle = false;
void sync.whenIdle().then(() => (idle = true));
for (let i = 0; i < 4; i++) {
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
}
expect(server.snapshots).toBe(2);
expect(host.onError).toHaveBeenCalledWith(expect.objectContaining({ kind: "unavailable" }));
expect(server.text).toBe("ONE\ntwo\nthree\n");
expect(view.text.toString()).toBe(server.text);
expect(sync.idle).toBe(true);
expect(idle).toBe(true);
expect(host.onDiscarded).not.toHaveBeenCalled();
// Later typing still reaches the backend.
type(0, 0, "#");
frame();
await server.processAll();
expect(server.text).toBe("#ONE\ntwo\nthree\n");
expect(sync.idle).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("stops retrying when every snapshot fails, and keeps the local text to send later", async () => {
vi.useFakeTimers();
try {
const { server, view, sync, type, frame, host } = setup("one\ntwo\n");
type(8, 8, "three\n");
frame();
server.patch([{ from: 0, to: 3, insert: "ONE" }]); // event lost
server.failSnapshots = Infinity;
let idle = false;
void sync.whenIdle().then(() => (idle = true));
for (let i = 0; i < 20 && !idle; i++) {
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
}
// A save waiting on the document is released instead of hanging, and nothing is in flight.
expect(idle).toBe(true);
expect(sync.inflight).toBeNull();
expect(server.pending()).toBe(0);
expect(host.onError).toHaveBeenCalled();
expect(view.text.toString()).toBe("one\ntwo\nthree\n");
// Once the backend answers again, the local text and new typing land where they belong.
server.failSnapshots = 0;
type(0, 0, "#");
frame();
for (let i = 0; i < 4; i++) {
await server.processAll();
await vi.advanceTimersByTimeAsync(100);
}
expect(server.text).toBe("#ONE\ntwo\nthree\n");
expect(view.text.toString()).toBe(server.text);
expect(sync.idle).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("hard-resyncs on unexpected errors and reports discarded changes", async () => {
const { server, view, sync, type, frame, host } = setup("abc");
server.failNext = { kind: "permissionDenied", message: "read-only" };
+3 -10
Переглянути файл
@@ -251,11 +251,9 @@ export class DocSync {
try {
snap = await this.transport.snapshot();
} catch (e) {
if (gen !== this.generation || this.disposed) return;
this.host.onError?.(e);
// Keep the edit going: the backend acks it once it caught up, or rejects it again,
// which retries the snapshot (a hard resync after MAX_REBASES).
this.resend();
this.resyncing = false;
this.resolveIdle(true);
return;
}
if (gen !== this.generation || this.disposed) return;
@@ -315,13 +313,8 @@ export class DocSync {
} catch (e) {
if (gen !== this.generation) return;
this.host.onError?.(e);
// Nothing to adopt: keep the local text as pending changes against `confirmed`, so the
// next flush sends them in the coordinates the backend knows.
const pending = minimalChanges(this.confirmed.toString(), this.localText.toString());
this.inflight = null;
this.buffer = pending.length ? ChangeSet.of(pending, this.confirmed.length) : null;
this.inflightStale = false;
this.rebases = 0;
this.buffer = null;
this.resyncing = false;
this.resolveIdle(true);
return;
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more