дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-11 20:25:41 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
cbcd1818a7 |
No files matched your search
@@ -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",
|
||||
|
||||
Створено
@@ -4618,7 +4618,6 @@ name = "ide-lsp"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>)>;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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()
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = ¶ms["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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 | — | — | — |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`, … | 是 | — | — | — |
|
||||
|
||||
@@ -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 配置的项目中运行。
|
||||
|
||||
@@ -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 服务器 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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.
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [path("", include("shop.urls"))]
|
||||
@@ -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()
|
||||
@@ -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 +0,0 @@
|
||||
Django>=5.2
|
||||
Whitespace-only changes.
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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")
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Бінарний файл не відображається.
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]),
|
||||
]);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
@@ -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);
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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
Reference in new issue
Заблокувати користувача