mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 16:36:58 +00:00
Project & files - Project view modes (Project Files / Open Files / All Changed Files / Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go, Maven/Gradle jars; read-only jar/zip browsing - ⌘N New popup with type search and more file kinds; New Project, Rename Project, Reload All from Disk, Export/Import Settings, Power Save Mode - File type associations, extra content roots and exclude patterns (indexed and watched without reopening the project) Editor & navigation - Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline, history; Find in Files: Module scope, custom pattern scopes, multiline - Search Everywhere filters, Assign Shortcut, settings results; named bookmark lists, file/folder bookmarks; Structure filters and Show Inherited; Recent Locations timestamps and breadcrumbs; Code Vision author - Completion options, editor tab placement/layout, inline completion (LSP 3.18) - Spell checking (Typo inspection, ide-spell) and Inspections settings with custom profiles; Code Style schemes, formatter markers, Do not format Run, VCS, services, database - Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker, Compound), Modify options, Before launch, env dialog, Current File, Emulate terminal (PTY) - Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees - Generic Services host (Docker connections/registries, run sessions); TODO scope/changes tabs with changelists; Coverage report import - Data sources: wire-compatible vendors, URL import, SSL certs, Options, Schemas and Advanced tabs, color and comment Workbench - Run Anything, Customize Main Toolbar, named tool window layouts, menu completeness, Code Cleanup, macros, "Don't Show Again", editor banners - Chinese translations for the new UI Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
646 lines
25 KiB
Rust
646 lines
25 KiB
Rust
use ide_core::CancellationToken;
|
|
use ide_core::error::ErrorKind;
|
|
use ide_search::{FileMatches, LineMatch, SearchQuery, SearchScope, SearchSummary, replace_in_text, search};
|
|
use serde::Serialize;
|
|
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use tempfile::TempDir;
|
|
|
|
fn project(files: &[(&str, &[u8])]) -> TempDir {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
for (path, content) in files {
|
|
let path = dir.path().join(path);
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(path, content).unwrap();
|
|
}
|
|
dir
|
|
}
|
|
|
|
fn query(pattern: &str) -> SearchQuery {
|
|
SearchQuery {
|
|
pattern: pattern.into(),
|
|
is_regex: false,
|
|
case_sensitive: false,
|
|
whole_word: false,
|
|
include: vec![],
|
|
exclude: vec![],
|
|
scope: SearchScope::Project,
|
|
max_matches: 0,
|
|
context: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn regex(pattern: &str) -> SearchQuery {
|
|
SearchQuery { is_regex: true, ..query(pattern) }
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct View {
|
|
rel_path: String,
|
|
matches: Vec<LineMatch>,
|
|
}
|
|
|
|
fn run_with(
|
|
root: &Path,
|
|
q: &SearchQuery,
|
|
overlay: &(dyn Fn(&Path) -> Option<String> + Sync),
|
|
cancel: &CancellationToken,
|
|
) -> (Vec<FileMatches>, SearchSummary) {
|
|
let out = Mutex::new(Vec::new());
|
|
let summary = search(root, q, overlay, cancel, &|f| out.lock().unwrap().push(f)).unwrap();
|
|
let mut files = out.into_inner().unwrap();
|
|
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
|
(files, summary)
|
|
}
|
|
|
|
fn run(root: &Path, q: &SearchQuery) -> (Vec<View>, SearchSummary) {
|
|
let (files, summary) = run_with(root, q, &|_| None, &CancellationToken::new());
|
|
for f in &files {
|
|
assert!(f.path.starts_with(root) || !f.path.is_absolute(), "{:?}", f.path);
|
|
}
|
|
let views = files.into_iter().map(|f| View { rel_path: f.rel_path, matches: f.matches }).collect();
|
|
(views, summary)
|
|
}
|
|
|
|
fn paths(views: &[View]) -> Vec<&str> {
|
|
views.iter().map(|v| v.rel_path.as_str()).collect()
|
|
}
|
|
|
|
fn total(views: &[View]) -> usize {
|
|
views.iter().flat_map(|v| &v.matches).map(|m| m.ranges.len()).sum()
|
|
}
|
|
|
|
#[test]
|
|
fn literal_search_shape() {
|
|
let p = project(&[
|
|
("src/main.rs", b"fn main() {\n let foo = Foo::new();\n foo.run(foo);\n}\n"),
|
|
("src/lib.rs", b"pub mod foo;\n"),
|
|
("README.md", b"nothing here\n"),
|
|
]);
|
|
let (views, summary) = run(p.path(), &query("foo"));
|
|
assert_eq!(summary.files, 2);
|
|
assert_eq!(summary.matches, 5);
|
|
assert!(!summary.limit_hit && !summary.cancelled);
|
|
insta::assert_yaml_snapshot!(views);
|
|
}
|
|
|
|
#[test]
|
|
fn literal_escapes_regex_metacharacters() {
|
|
let p = project(&[("a.txt", b"a.b\naxb\n(a.b)\n")]);
|
|
let (views, _) = run(p.path(), &query("a.b"));
|
|
assert_eq!(views[0].matches.iter().map(|m| m.line).collect::<Vec<_>>(), vec![0, 2]);
|
|
let (views, _) = run(p.path(), &query("(a.b)"));
|
|
assert_eq!(views[0].matches[0].ranges, vec![(0, 5)]);
|
|
}
|
|
|
|
#[test]
|
|
fn case_sensitivity() {
|
|
let p = project(&[("a.txt", b"Foo foo FOO\n")]);
|
|
let (views, _) = run(p.path(), &query("foo"));
|
|
assert_eq!(views[0].matches[0].ranges, vec![(0, 3), (4, 7), (8, 11)]);
|
|
let (views, _) = run(p.path(), &SearchQuery { case_sensitive: true, ..query("foo") });
|
|
assert_eq!(views[0].matches[0].ranges, vec![(4, 7)]);
|
|
// No smart case: an uppercase letter does not imply case sensitivity.
|
|
let (views, _) = run(p.path(), &query("Foo"));
|
|
assert_eq!(views[0].matches[0].ranges.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn regex_search() {
|
|
let p = project(&[("a.txt", b"let x1 = 10;\nlet yy = 2;\nconst z = 3;\n")]);
|
|
let (views, summary) = run(p.path(), ®ex(r"let (\w+) = \d+"));
|
|
assert_eq!(summary.matches, 2);
|
|
assert_eq!(views[0].matches[0].ranges, vec![(0, 11)]);
|
|
assert_eq!(views[0].matches[1].ranges, vec![(0, 10)]);
|
|
let (views, _) = run(p.path(), ®ex(r"^\w+"));
|
|
assert_eq!(total(&views), 3);
|
|
let (views, _) = run(p.path(), ®ex(r"\d+;$"));
|
|
assert_eq!(views[0].matches.iter().map(|m| m.ranges[0]).collect::<Vec<_>>(), vec![(9, 12), (9, 11), (10, 12)]);
|
|
}
|
|
|
|
#[test]
|
|
fn whole_word() {
|
|
let p = project(&[("a.txt", b"foo foobar barfoo foo_bar (foo) foo\n")]);
|
|
let (views, _) = run(p.path(), &SearchQuery { whole_word: true, ..query("foo") });
|
|
assert_eq!(views[0].matches[0].ranges, vec![(0, 3), (27, 30), (32, 35)]);
|
|
let (views, _) = run(p.path(), &SearchQuery { whole_word: true, ..regex("fo+") });
|
|
assert_eq!(views[0].matches[0].ranges, vec![(0, 3), (27, 30), (32, 35)]);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_regex_is_reported() {
|
|
let p = project(&[("a.txt", b"x\n")]);
|
|
let err = search(p.path(), ®ex("a(b"), &|_| None, &CancellationToken::new(), &|_| {}).unwrap_err();
|
|
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
|
assert!(err.message.contains("Invalid regular expression"), "{}", err.message);
|
|
let err = search(p.path(), ®ex("[z-a]"), &|_| None, &CancellationToken::new(), &|_| {}).unwrap_err();
|
|
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_mask_is_reported() {
|
|
let p = project(&[("a.txt", b"x\n")]);
|
|
let q = SearchQuery { include: vec!["*.{rs".into()], ..query("x") };
|
|
let err = search(p.path(), &q, &|_| None, &CancellationToken::new(), &|_| {}).unwrap_err();
|
|
assert_eq!(err.kind, ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_pattern_finds_nothing() {
|
|
let p = project(&[("a.txt", b"x\n")]);
|
|
let (views, summary) = run(p.path(), &query(""));
|
|
assert!(views.is_empty());
|
|
assert_eq!(summary.matches, 0);
|
|
}
|
|
|
|
fn mask_project() -> TempDir {
|
|
project(&[
|
|
("src/Main.kt", b"hit\n"),
|
|
("src/MainTest.kt", b"hit\n"),
|
|
("src/Util.java", b"hit\n"),
|
|
("src/web/app.ts", b"hit\n"),
|
|
("lib/other.ts", b"hit\n"),
|
|
("notes.md", b"hit\n"),
|
|
])
|
|
}
|
|
|
|
#[test]
|
|
fn file_masks() {
|
|
let p = mask_project();
|
|
let with = |include: &[&str], exclude: &[&str]| {
|
|
let q = SearchQuery {
|
|
include: include.iter().map(|s| s.to_string()).collect(),
|
|
exclude: exclude.iter().map(|s| s.to_string()).collect(),
|
|
..query("hit")
|
|
};
|
|
let (views, _) = run(p.path(), &q);
|
|
paths(&views).into_iter().map(String::from).collect::<Vec<_>>()
|
|
};
|
|
assert_eq!(with(&["*.kt"], &[]), ["src/Main.kt", "src/MainTest.kt"]);
|
|
assert_eq!(with(&["*.kt, *.java, !*Test.kt"], &[]), ["src/Main.kt", "src/Util.java"]);
|
|
assert_eq!(with(&["*.kt", "*.java"], &["*Test.kt"]), ["src/Main.kt", "src/Util.java"]);
|
|
assert_eq!(with(&["src/**/*.ts"], &[]), ["src/web/app.ts"]);
|
|
assert_eq!(with(&["*.ts"], &["src/**"]), ["lib/other.ts"]);
|
|
assert_eq!(with(&["!*.md"], &[]).len(), 5);
|
|
assert_eq!(with(&[], &["*.kt,*.ts"]), ["notes.md", "src/Util.java"]);
|
|
}
|
|
|
|
#[test]
|
|
fn honours_ignore_files_hidden_and_git() {
|
|
let p = project(&[
|
|
(".gitignore", b"target/\n*.log\n"),
|
|
(".ignore", b"generated.rs\n"),
|
|
("src/a.rs", b"needle\n"),
|
|
("src/generated.rs", b"needle\n"),
|
|
("target/debug/out.rs", b"needle\n"),
|
|
("build.log", b"needle\n"),
|
|
(".hidden/secret.rs", b"needle\n"),
|
|
(".env", b"needle\n"),
|
|
(".git/config", b"needle\n"),
|
|
("sub/.gitignore", b"local.txt\n"),
|
|
("sub/local.txt", b"needle\n"),
|
|
("sub/kept.txt", b"needle\n"),
|
|
]);
|
|
let (views, _) = run(p.path(), &query("needle"));
|
|
assert_eq!(paths(&views), ["src/a.rs", "sub/kept.txt"]);
|
|
}
|
|
|
|
#[test]
|
|
fn skips_binary_files() {
|
|
let mut late_nul = b"needle\n".to_vec();
|
|
late_nul.extend(std::iter::repeat_n(b'a', 200_000));
|
|
late_nul.extend(b"\0\nneedle\n");
|
|
let p = project(&[("text.txt", b"needle\n"), ("image.bin", b"\x89PNG\0\0needle\0"), ("late.bin", &late_nul)]);
|
|
let (views, summary) = run(p.path(), &query("needle"));
|
|
assert_eq!(paths(&views), ["text.txt"]);
|
|
assert_eq!(summary.matches, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_takes_precedence() {
|
|
let p = project(&[("a.txt", b"old needle\n"), ("b.txt", b"nothing\n")]);
|
|
let b = p.path().join("b.txt");
|
|
let a = p.path().join("a.txt");
|
|
let overlay = move |path: &Path| -> Option<String> {
|
|
if path == b {
|
|
Some("first\nunsaved needle here\n".into())
|
|
} else if path == a {
|
|
Some("edited away\n".into())
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
let (files, _) = run_with(p.path(), &query("needle"), &overlay, &CancellationToken::new());
|
|
assert_eq!(files.len(), 1);
|
|
assert_eq!(files[0].rel_path, "b.txt");
|
|
assert_eq!(files[0].path, p.path().join("b.txt"));
|
|
assert_eq!(files[0].matches[0].line, 1);
|
|
assert_eq!(files[0].matches[0].ranges, vec![(8, 14)]);
|
|
}
|
|
|
|
#[test]
|
|
fn match_limit() {
|
|
let line = b"x x x x x\n";
|
|
let p = project(&[("a.txt", line), ("b.txt", line), ("c.txt", line)]);
|
|
let (views, summary) = run(p.path(), &SearchQuery { max_matches: 7, ..query("x") });
|
|
assert!(summary.limit_hit);
|
|
assert_eq!(summary.matches, 7);
|
|
assert_eq!(total(&views), 7);
|
|
assert_eq!(summary.files as usize, views.len());
|
|
|
|
let (views, summary) = run(p.path(), &SearchQuery { max_matches: 15, ..query("x") });
|
|
assert!(!summary.limit_hit);
|
|
assert_eq!(total(&views), 15);
|
|
|
|
let (_, summary) = run(p.path(), &SearchQuery { max_matches: 14, ..query("x") });
|
|
assert!(summary.limit_hit);
|
|
assert_eq!(summary.matches, 14);
|
|
}
|
|
|
|
#[test]
|
|
fn default_limit_is_ten_thousand() {
|
|
let content = "a\n".repeat(10_050);
|
|
let p = project(&[("a.txt", content.as_bytes())]);
|
|
let (views, summary) = run(p.path(), &query("a"));
|
|
assert!(summary.limit_hit);
|
|
assert_eq!(summary.matches, 10_000);
|
|
assert_eq!(views[0].matches.len(), 10_000);
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_before_start() {
|
|
let p = project(&[("a.txt", b"x\n")]);
|
|
let cancel = CancellationToken::new();
|
|
cancel.cancel();
|
|
let (files, summary) = run_with(p.path(), &query("x"), &|_| None, &cancel);
|
|
assert!(summary.cancelled);
|
|
assert!(files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_mid_search() {
|
|
let files: Vec<(String, Vec<u8>)> =
|
|
(0..400).map(|i| (format!("d{}/f{i}.txt", i % 10), b"hit\n".to_vec())).collect();
|
|
let refs: Vec<(&str, &[u8])> = files.iter().map(|(p, c)| (p.as_str(), c.as_slice())).collect();
|
|
let p = project(&refs);
|
|
let cancel = CancellationToken::new();
|
|
let seen = AtomicUsize::new(0);
|
|
let summary = search(p.path(), &query("hit"), &|_| None, &cancel, &|_| {
|
|
seen.fetch_add(1, Ordering::SeqCst);
|
|
cancel.cancel();
|
|
})
|
|
.unwrap();
|
|
assert!(summary.cancelled);
|
|
let seen = seen.load(Ordering::SeqCst);
|
|
assert!(seen < 400, "search kept going after cancel: {seen}");
|
|
assert_eq!(summary.files as usize, seen);
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_interrupts_large_file() {
|
|
// 64 MiB without matches: the reader must notice cancellation mid-file.
|
|
let p = project(&[]);
|
|
let big = p.path().join("big.txt");
|
|
let line = "abcdefghijklmnopqrstuvwxyz0123456789\n".repeat(1024);
|
|
{
|
|
use std::io::Write;
|
|
let mut f = std::io::BufWriter::new(std::fs::File::create(&big).unwrap());
|
|
for _ in 0..(64 * 1024 * 1024 / line.len()) {
|
|
f.write_all(line.as_bytes()).unwrap();
|
|
}
|
|
f.write_all(b"needle\n").unwrap();
|
|
}
|
|
let cancel = CancellationToken::new();
|
|
let canceller = cancel.clone();
|
|
let t = std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
canceller.cancel();
|
|
});
|
|
let (files, summary) = run_with(p.path(), ®ex("ne+dle"), &|_| None, &cancel);
|
|
t.join().unwrap();
|
|
assert!(summary.cancelled);
|
|
assert!(files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unicode_columns_are_utf16() {
|
|
let p = project(&[("i18n.txt", "中文字符 needle 😀 needle\n🎉🎉needle\nnaïve Needle ünïcödé\n".as_bytes())]);
|
|
let (views, _) = run(p.path(), &query("needle"));
|
|
assert_eq!(views[0].matches[0].ranges, vec![(5, 11), (15, 21)]);
|
|
assert_eq!(views[0].matches[1].ranges, vec![(4, 10)]);
|
|
assert_eq!(views[0].matches[2].ranges, vec![(6, 12)]);
|
|
let (cjk, _) = run(p.path(), &query("字符"));
|
|
assert_eq!(cjk[0].matches[0].ranges, vec![(2, 4)]);
|
|
let (emoji, _) = run(p.path(), &query("😀"));
|
|
assert_eq!(emoji[0].matches[0].ranges, vec![(12, 14)]);
|
|
// Unicode-aware case folding.
|
|
let (folded, _) = run(p.path(), &query("ÜNÏCÖDÉ"));
|
|
assert_eq!(folded[0].matches[0].ranges, vec![(13, 20)]);
|
|
insta::assert_yaml_snapshot!(views);
|
|
}
|
|
|
|
#[test]
|
|
fn long_lines_are_truncated() {
|
|
let long = format!("{}needle{}needle{}\nshort needle\n", "a".repeat(2000), "b".repeat(100), "c".repeat(2000));
|
|
let p = project(&[("min.js", long.as_bytes())]);
|
|
let (views, _) = run(p.path(), &query("needle"));
|
|
let first = &views[0].matches[0];
|
|
assert_eq!(first.text_offset, 1900);
|
|
assert_eq!(first.ranges, vec![(100, 106), (206, 212)]);
|
|
assert_eq!(first.text.encode_utf16().count(), 500);
|
|
assert_eq!(&first.text[100..106], "needle");
|
|
let second = &views[0].matches[1];
|
|
assert_eq!(second.text_offset, 0);
|
|
assert_eq!(second.text, "short needle");
|
|
}
|
|
|
|
#[test]
|
|
fn multi_line_matches() {
|
|
let p = project(&[("a.rs", b"fn foo() {\n bar();\n}\nfoo\nbar\n")]);
|
|
let (views, summary) = run(p.path(), ®ex(r"\{\n\s*bar"));
|
|
assert_eq!(summary.matches, 1);
|
|
insta::assert_yaml_snapshot!("multi_line_regex", views);
|
|
|
|
let (views, _) = run(p.path(), &query("foo\nbar"));
|
|
assert_eq!(views[0].matches.len(), 1);
|
|
assert_eq!(views[0].matches[0].line, 3);
|
|
assert_eq!(views[0].matches[0].ranges, vec![(0, 3)]);
|
|
}
|
|
|
|
#[test]
|
|
fn adjacent_multi_line_matches() {
|
|
let p = project(&[("a.txt", b"a\nb\na\nb\n")]);
|
|
let (views, summary) = run(p.path(), ®ex(r"a\nb"));
|
|
assert_eq!(summary.matches, 2);
|
|
assert_eq!(
|
|
views[0].matches.iter().map(|m| (m.line, m.ranges.clone())).collect::<Vec<_>>(),
|
|
[(0, vec![(0, 1)]), (2, vec![(0, 1)])]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_matches_respect_char_boundaries() {
|
|
let p = project(&[("e.txt", "😀中x\n".as_bytes())]);
|
|
let (views, _) = run(p.path(), ®ex("x*"));
|
|
assert_eq!(views[0].matches[0].ranges, vec![(0, 0), (2, 2), (3, 4)]);
|
|
}
|
|
|
|
#[test]
|
|
fn crlf_lines() {
|
|
let p = project(&[("win.txt", b"alpha beta\r\ngamma beta\r\n")]);
|
|
let (views, _) = run(p.path(), ®ex("beta$"));
|
|
assert_eq!(views[0].matches.len(), 2);
|
|
assert_eq!(views[0].matches[0].text, "alpha beta");
|
|
assert_eq!(views[0].matches[1].ranges, vec![(6, 10)]);
|
|
}
|
|
|
|
#[test]
|
|
fn directory_scope() {
|
|
let p = mask_project();
|
|
let q = SearchQuery { scope: SearchScope::Directory { path: "src/web".into() }, ..query("hit") };
|
|
let (views, _) = run(p.path(), &q);
|
|
assert_eq!(paths(&views), ["src/web/app.ts"]);
|
|
let q = SearchQuery { scope: SearchScope::Directory { path: p.path().join("lib").into() }, ..query("hit") };
|
|
let (views, _) = run(p.path(), &q);
|
|
assert_eq!(paths(&views), ["lib/other.ts"]);
|
|
let q = SearchQuery { scope: SearchScope::Directory { path: "missing".into() }, ..query("hit") };
|
|
let err = search(p.path(), &q, &|_| None, &CancellationToken::new(), &|_| {}).unwrap_err();
|
|
assert_eq!(err.kind, ErrorKind::NotFound);
|
|
}
|
|
|
|
#[test]
|
|
fn pattern_scope_intersects_the_file_mask() {
|
|
let p = mask_project();
|
|
let scope = SearchScope::Pattern { pattern: "file:src//*&&!file:*Test*".into() };
|
|
let q = SearchQuery { scope: scope.clone(), ..query("hit") };
|
|
let (views, _) = run(p.path(), &q);
|
|
assert_eq!(paths(&views), ["src/Main.kt", "src/Util.java", "src/web/app.ts"]);
|
|
let q = SearchQuery { scope, include: vec!["*.ts".into()], ..query("hit") };
|
|
let (views, _) = run(p.path(), &q);
|
|
assert_eq!(paths(&views), ["src/web/app.ts"]);
|
|
let bad = SearchQuery { scope: SearchScope::Pattern { pattern: "a &&".into() }, ..query("hit") };
|
|
assert_eq!(ide_search::validate_query(&bad).unwrap_err().kind, ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[test]
|
|
fn files_scope_searches_ignored_and_unsaved_files() {
|
|
let p = project(&[(".gitignore", b"*.log\n"), ("a.log", b"hit\n"), ("b.txt", b"hit\n"), ("c.txt", b"hit\n")]);
|
|
let unsaved = p.path().join("new.txt");
|
|
let unsaved2 = unsaved.clone();
|
|
let overlay = move |path: &Path| (path == unsaved2).then(|| "hit\n".to_string());
|
|
let q = SearchQuery {
|
|
scope: SearchScope::Files {
|
|
paths: vec!["a.log".into(), p.path().join("b.txt").into(), "a.log".into(), unsaved.clone().into()],
|
|
},
|
|
..query("hit")
|
|
};
|
|
let (files, summary) = run_with(p.path(), &q, &overlay, &CancellationToken::new());
|
|
let names: Vec<_> = files.iter().map(|f| f.rel_path.as_str()).collect();
|
|
assert_eq!(names, ["a.log", "b.txt", "new.txt"]);
|
|
assert_eq!(summary.files, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn utf16_encoded_files_are_transcoded() {
|
|
let mut bytes = vec![0xFF, 0xFE];
|
|
for unit in "hello needle\n".encode_utf16() {
|
|
bytes.extend(unit.to_le_bytes());
|
|
}
|
|
let p = project(&[("utf16.txt", &bytes)]);
|
|
let (views, _) = run(p.path(), &query("needle"));
|
|
assert_eq!(views[0].matches[0].text, "hello needle");
|
|
assert_eq!(views[0].matches[0].ranges, vec![(6, 12)]);
|
|
}
|
|
|
|
// ---- replace ----
|
|
|
|
#[test]
|
|
fn replace_literal() {
|
|
let (out, n) = replace_in_text("a.b axb A.B", &query("a.b"), "$1-x").unwrap();
|
|
assert_eq!((out.as_str(), n), ("$1-x axb $1-x", 2));
|
|
let (out, n) = replace_in_text("a.b A.B", &SearchQuery { case_sensitive: true, ..query("a.b") }, "z").unwrap();
|
|
assert_eq!((out.as_str(), n), ("z A.B", 1));
|
|
}
|
|
|
|
#[test]
|
|
fn replace_regex_groups() {
|
|
let q = regex(r"(\w+)\s*=\s*(?P<value>\d+)");
|
|
let (out, n) = replace_in_text("x = 1; yy=22;", &q, "${value} => $1 ($$)").unwrap();
|
|
assert_eq!((out.as_str(), n), ("1 => x ($); 22 => yy ($);", 2));
|
|
let (out, _) = replace_in_text("ab", ®ex("(a)(b)"), "$2$1${1}x").unwrap();
|
|
assert_eq!(out, "baax");
|
|
}
|
|
|
|
#[test]
|
|
fn replace_escapes() {
|
|
let (out, n) = replace_in_text("a,b,c", ®ex(","), r"\n").unwrap();
|
|
assert_eq!((out.as_str(), n), ("a\nb\nc", 2));
|
|
let (out, _) = replace_in_text("a,b", ®ex(","), r"\t\$\\").unwrap();
|
|
assert_eq!(out, "a\t$\\b");
|
|
// Literal queries never interpret escapes.
|
|
let (out, _) = replace_in_text("a,b", &query(","), r"\n").unwrap();
|
|
assert_eq!(out, r"a\nb");
|
|
}
|
|
|
|
#[test]
|
|
fn replace_whole_word_and_case() {
|
|
let q = SearchQuery { whole_word: true, ..query("foo") };
|
|
let (out, n) = replace_in_text("foo foobar Foo (foo)", &q, "bar").unwrap();
|
|
assert_eq!((out.as_str(), n), ("bar foobar bar (bar)", 3));
|
|
}
|
|
|
|
#[test]
|
|
fn replace_is_line_oriented_unless_multi_line() {
|
|
// `\s` must not cross lines, exactly as in search.
|
|
let (out, n) = replace_in_text("a \nb", ®ex(r"a\s+"), "X").unwrap();
|
|
assert_eq!((out.as_str(), n), ("X\nb", 1));
|
|
let (out, n) = replace_in_text("foo\n bar\nfoo\nbaz", ®ex(r"foo\n\s*bar"), "joined").unwrap();
|
|
assert_eq!((out.as_str(), n), ("joined\nfoo\nbaz", 1));
|
|
let (out, n) = replace_in_text("l1\nl2\r\nl3", ®ex("^"), "> ").unwrap();
|
|
assert_eq!((out.as_str(), n), ("> l1\n> l2\r\n> l3", 3));
|
|
let (out, n) = replace_in_text("l1\r\nl2", ®ex("$"), ";").unwrap();
|
|
assert_eq!((out.as_str(), n), ("l1;\r\nl2;", 2));
|
|
}
|
|
|
|
#[test]
|
|
fn replace_unicode_and_empty() {
|
|
let (out, n) = replace_in_text("中文 😀 中文", &query("中文"), "zh").unwrap();
|
|
assert_eq!((out.as_str(), n), ("zh 😀 zh", 2));
|
|
let (out, n) = replace_in_text("😀😀", ®ex("x*"), "-").unwrap();
|
|
assert_eq!((out.as_str(), n), ("-😀-😀-", 3));
|
|
let (out, n) = replace_in_text("abc", &query(""), "-").unwrap();
|
|
assert_eq!((out.as_str(), n), ("abc", 0));
|
|
assert_eq!(replace_in_text("abc", ®ex("("), "").unwrap_err().kind, ErrorKind::InvalidInput);
|
|
}
|
|
|
|
#[test]
|
|
fn replace_matches_search_count() {
|
|
let text = "foo Foo fooFoo foo_bar\nbar foo\n";
|
|
for q in [query("foo"), SearchQuery { whole_word: true, ..query("foo") }, regex("f[o]+")] {
|
|
let p = project(&[("a.txt", text.as_bytes())]);
|
|
let (_, summary) = run(p.path(), &q);
|
|
let (_, n) = replace_in_text(text, &q, "x").unwrap();
|
|
assert_eq!(summary.matches, n, "{q:?}");
|
|
}
|
|
}
|
|
|
|
/// `OPENIDEA_SEARCH_BENCH=/path/to/repo PATTERN cargo test --release -- --ignored bench`
|
|
#[test]
|
|
#[ignore]
|
|
fn bench_large_tree() {
|
|
let Ok(root) = std::env::var("OPENIDEA_SEARCH_BENCH") else { return };
|
|
let pattern = std::env::var("OPENIDEA_SEARCH_PATTERN").unwrap_or_else(|_| "fn main".into());
|
|
let q = SearchQuery { max_matches: u32::MAX, is_regex: true, case_sensitive: true, ..query(&pattern) };
|
|
if let Ok(dump) = std::env::var("OPENIDEA_SEARCH_DUMP") {
|
|
let (files, _) = run_with(Path::new(&root), &q, &|_| None, &CancellationToken::new());
|
|
let list: Vec<_> = files.iter().map(|f| f.path.display().to_string()).collect();
|
|
std::fs::write(dump, list.join("\n") + "\n").unwrap();
|
|
}
|
|
for _ in 0..3 {
|
|
let (_, s) = run_with(Path::new(&root), &q, &|_| None, &CancellationToken::new());
|
|
eprintln!("{} files, {} matches in {} ms", s.files, s.matches, s.elapsed_ms);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn file_set_scope_applies_the_path_filter() {
|
|
let p = project(&[
|
|
("src/main/java/App.java", b"needle\n"),
|
|
("src/test/java/AppTest.java", b"needle\n"),
|
|
("README.md", b"needle\n"),
|
|
]);
|
|
let q = SearchQuery { scope: SearchScope::FileSet { set: ide_search::ProjectFileSet::Test }, ..query("needle") };
|
|
let out = Mutex::new(Vec::new());
|
|
let keep = |rel: &str| rel.starts_with("src/test/");
|
|
ide_search::search_filtered(p.path(), &q, Some(&keep), &|_| None, &CancellationToken::new(), &|f| {
|
|
out.lock().unwrap().push(f.rel_path)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(out.into_inner().unwrap(), vec!["src/test/java/AppTest.java"]);
|
|
// Without a filter the file set walks the whole project.
|
|
let (all, _) = run(p.path(), &q);
|
|
assert_eq!(paths(&all), vec!["README.md", "src/main/java/App.java", "src/test/java/AppTest.java"]);
|
|
}
|
|
|
|
/// Test classifier: `//` to end of line is a comment, `"…"` a string (only in `.rs` files).
|
|
fn toy_literals(path: &Path, text: &str) -> Option<Vec<ide_search::LiteralRange>> {
|
|
use ide_search::{LiteralKind, LiteralRange};
|
|
if path.extension().is_none_or(|e| e != "rs") {
|
|
return None;
|
|
}
|
|
let mut out = Vec::new();
|
|
let bytes = text.as_bytes();
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i..].starts_with(b"//") {
|
|
let end = memchr_newline(bytes, i);
|
|
out.push(LiteralRange { start: i, end, kind: LiteralKind::Comment });
|
|
i = end;
|
|
} else if bytes[i] == b'"' {
|
|
let end = bytes[i + 1..].iter().position(|&b| b == b'"').map_or(bytes.len(), |p| i + p + 2);
|
|
out.push(LiteralRange { start: i, end, kind: LiteralKind::String });
|
|
i = end;
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
fn memchr_newline(bytes: &[u8], from: usize) -> usize {
|
|
bytes[from..].iter().position(|&b| b == b'\n').map_or(bytes.len(), |p| from + p)
|
|
}
|
|
|
|
/// `(rel_path, line, ranges)` of every matched line.
|
|
type Row = (String, u32, Vec<(u32, u32)>);
|
|
|
|
fn run_context(root: &Path, context: ide_search::SearchContext) -> Vec<Row> {
|
|
let out = Mutex::new(Vec::new());
|
|
let q = SearchQuery { context, ..query("foo") };
|
|
ide_search::search_in_context(root, &q, None, Some(&toy_literals), &|_| None, &CancellationToken::new(), &|f| {
|
|
out.lock().unwrap().push(f)
|
|
})
|
|
.unwrap();
|
|
let mut rows: Vec<_> = out
|
|
.into_inner()
|
|
.unwrap()
|
|
.into_iter()
|
|
.flat_map(|f| f.matches.into_iter().map(move |m| (f.rel_path.clone(), m.line, m.ranges)))
|
|
.collect();
|
|
rows.sort();
|
|
rows
|
|
}
|
|
|
|
#[test]
|
|
fn search_context_filters_comments_and_strings() {
|
|
use ide_search::SearchContext as C;
|
|
let p = project(&[("a.rs", b"foo(\"foo\"); // foo\nfoo\n"), ("b.txt", b"foo // foo\n")]);
|
|
let rows = |c| run_context(p.path(), c);
|
|
let s = |r: &str, l: u32, ranges: &[(u32, u32)]| (r.to_owned(), l, ranges.to_vec());
|
|
assert_eq!(
|
|
rows(C::Any),
|
|
vec![s("a.rs", 0, &[(0, 3), (5, 8), (15, 18)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])]
|
|
);
|
|
assert_eq!(rows(C::InComments), vec![s("a.rs", 0, &[(15, 18)])]);
|
|
assert_eq!(rows(C::InStringLiterals), vec![s("a.rs", 0, &[(5, 8)])]);
|
|
// Files without a syntax tree have no comments or strings.
|
|
assert_eq!(
|
|
rows(C::ExceptComments),
|
|
vec![s("a.rs", 0, &[(0, 3), (5, 8)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])]
|
|
);
|
|
assert_eq!(
|
|
rows(C::ExceptCommentsAndStringLiterals),
|
|
vec![s("a.rs", 0, &[(0, 3)]), s("a.rs", 1, &[(0, 3)]), s("b.txt", 0, &[(0, 3), (7, 10)])]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archives_are_skipped() {
|
|
// A stored (uncompressed) zip entry would otherwise match before the first NUL byte.
|
|
let p = project(&[("lib/needle.jar", b"needle in a jar"), ("docs.zip", b"needle"), ("src/a.txt", b"needle\n")]);
|
|
let (views, _) = run(p.path(), &query("needle"));
|
|
assert_eq!(paths(&views), ["src/a.txt"]);
|
|
}
|