mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
feat: Third pass aligning core experience with IntelliJ IDEA
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
This commit is contained in:
1 parent
294198a727
commit
1e00ea5ee2
426 files changed
+85241
-2886
No files matched your search
Generated
+29
@@ -4061,6 +4061,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"which",
|
||||
]
|
||||
@@ -4248,6 +4249,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4266,6 +4268,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"ide-run",
|
||||
"ide-vfs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4285,6 +4288,7 @@ dependencies = [
|
||||
"grep-regex",
|
||||
"grep-searcher",
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"insta",
|
||||
"memchr",
|
||||
@@ -4294,6 +4298,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"specta",
|
||||
"spellbook",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.13"
|
||||
@@ -4389,6 +4405,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"trash",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4426,6 +4443,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5854,6 +5872,7 @@ dependencies = [
|
||||
"ide-run",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
"ide-syntax",
|
||||
"ide-terminal",
|
||||
"ide-text",
|
||||
@@ -7719,6 +7738,16 @@ dependencies = [
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spellbook"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
|
||||
@@ -33,6 +33,7 @@ ide-profiler = { path = "crates/ide-profiler" }
|
||||
ide-http = { path = "crates/ide-http" }
|
||||
ide-db = { path = "crates/ide-db" }
|
||||
ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -79,6 +79,15 @@ user's app-data directory only when the user asks; none of them ships with OpenI
|
||||
unmodified, and its source is available from the repository above.
|
||||
- **Eclipse JDT Language Server** (jdtls): EPL-2.0, from download.eclipse.org.
|
||||
|
||||
## en_US Hunspell dictionary
|
||||
|
||||
`crates/ide-spell/src/dict/en_US.{aff,dic}` (the spell checker's bundled dictionary) is the
|
||||
SCOWL-derived en_US Hunspell dictionary (version 2020.12.07, from the `dictionary-en` npm
|
||||
package 4.0.0), copied unmodified. Copyright Kevin Atkinson and the SCOWL / Hunspell
|
||||
dictionary contributors; MIT- and BSD-style licences, full text in
|
||||
`crates/ide-spell/src/dict/LICENSE-en_US`. It is checked with the
|
||||
[spellbook](https://github.com/helix-editor/spellbook) crate (MPL-2.0).
|
||||
|
||||
## nvim-treesitter queries
|
||||
|
||||
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
|
||||
|
||||
@@ -584,6 +584,7 @@ pub fn diagnostics(messages: &[BuildMessage]) -> BTreeMap<PathBuf, Vec<Diagnosti
|
||||
code: m.code.clone(),
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: Some(DIAGNOSTICS_SOURCE.to_owned()),
|
||||
});
|
||||
}
|
||||
out
|
||||
|
||||
@@ -285,6 +285,74 @@ pub fn script_java_version(text: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Configurations whose dependencies are libraries (`testFixturesImplementation` ends in one).
|
||||
const DEPENDENCY_CONFIGURATIONS: &[&str] = &[
|
||||
"implementation",
|
||||
"api",
|
||||
"compileOnly",
|
||||
"runtimeOnly",
|
||||
"compileOnlyApi",
|
||||
"annotationProcessor",
|
||||
"kapt",
|
||||
"ksp",
|
||||
"compile",
|
||||
"runtime",
|
||||
"testCompile",
|
||||
"testRuntime",
|
||||
"developmentOnly",
|
||||
];
|
||||
|
||||
fn is_dependency_configuration(name: &str) -> bool {
|
||||
DEPENDENCY_CONFIGURATIONS.iter().any(|c| {
|
||||
name == *c || {
|
||||
let mut cap = c.chars();
|
||||
let first = cap.next().map(|f| f.to_ascii_uppercase()).into_iter();
|
||||
let capitalised: String = first.chain(cap).collect();
|
||||
name.len() > c.len() && name.ends_with(&capitalised)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `(group, artifact, version)` of the external dependencies a build script declares:
|
||||
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
|
||||
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
|
||||
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
|
||||
let line = line.trim_start();
|
||||
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if name.is_empty() || !is_dependency_configuration(&name) {
|
||||
continue;
|
||||
}
|
||||
let rest = &line[name.len()..];
|
||||
let lits = literals(rest);
|
||||
let map_value = |key: &str| -> Option<String> {
|
||||
let i = rest.find(key)?;
|
||||
let after = rest[i + key.len()..].trim_start().strip_prefix([':', '='])?;
|
||||
literals(after).into_iter().next()
|
||||
};
|
||||
let coordinate = match (map_value("group"), map_value("name"), map_value("version")) {
|
||||
(Some(g), Some(a), Some(v)) => Some((g, a, v)),
|
||||
_ => lits.first().and_then(|l| {
|
||||
let l = l.split('@').next().unwrap_or(l);
|
||||
let mut parts = l.split(':');
|
||||
Some((parts.next()?.to_owned(), parts.next()?.to_owned(), parts.next()?.to_owned()))
|
||||
}),
|
||||
};
|
||||
if let Some((g, a, v)) = coordinate {
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
|
||||
};
|
||||
if plain(&g) && plain(&a) && plain(&v) && !v.contains('+') {
|
||||
out.push((g, a, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every settings and build script of the model (for change detection).
|
||||
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
@@ -333,6 +401,38 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn script_dependencies_read_string_and_map_notations() {
|
||||
let script = r#"
|
||||
plugins { id 'java' }
|
||||
dependencies {
|
||||
implementation 'com.google.guava:guava:33.0.0-jre'
|
||||
api("org.slf4j:slf4j-api:2.0.9")
|
||||
testImplementation "junit:junit:4.13.2@jar"
|
||||
// implementation 'commented:out:1.0'
|
||||
compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30'
|
||||
runtimeOnly(group = "com.h2database", name = "h2", version = "2.2.224")
|
||||
implementation "org.x:dyn:${ver}"
|
||||
implementation 'org.x:range:1.+'
|
||||
implementation libs.okhttp
|
||||
implementation project(':core')
|
||||
classpath 'not:a:library'
|
||||
}
|
||||
"#;
|
||||
let deps = script_dependencies(script);
|
||||
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"com.google.guava:guava:33.0.0-jre",
|
||||
"org.slf4j:slf4j-api:2.0.9",
|
||||
"junit:junit:4.13.2",
|
||||
"org.projectlombok:lombok:1.18.30",
|
||||
"com.h2database:h2:2.2.224",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -50,6 +50,56 @@ pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> Build
|
||||
BuildModel { maven, gradle }
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DependencyCoordinate {
|
||||
/// Gradle builds resolve into the Gradle cache, Maven ones into the local repository.
|
||||
pub gradle: bool,
|
||||
pub group_id: String,
|
||||
pub artifact_id: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
|
||||
/// (with the transitive ones the last sync read), except the build's own modules,
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
|
||||
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
let modules: std::collections::HashSet<String> = m.projects.iter().map(MavenProject::key).collect();
|
||||
let mut stack: Vec<&MavenDependency> = m.projects.iter().flat_map(|p| &p.dependencies).collect();
|
||||
while let Some(d) = stack.pop() {
|
||||
stack.extend(&d.children);
|
||||
let Some(version) = d.version.as_deref().filter(|v| !v.is_empty() && !v.contains("${")) else {
|
||||
continue;
|
||||
};
|
||||
if d.kind == "pom" || matches!(d.scope.as_str(), "import" | "system") {
|
||||
continue;
|
||||
}
|
||||
if modules.contains(&format!("{}:{}", d.group_id, d.artifact_id)) {
|
||||
continue;
|
||||
}
|
||||
out.push(DependencyCoordinate {
|
||||
gradle: false,
|
||||
group_id: d.group_id.clone(),
|
||||
artifact_id: d.artifact_id.clone(),
|
||||
version: version.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(g) = &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
|
||||
}));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a created / changed / removed project-relative path can change the build model.
|
||||
pub fn affects_build(rel: &Path) -> bool {
|
||||
let Some(name) = rel.file_name().and_then(|n| n.to_str()) else { return false };
|
||||
@@ -78,4 +128,43 @@ mod tests {
|
||||
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
|
||||
assert!(!affects_build(Path::new("src/Main.java")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_coordinates_flatten_maven_trees_and_read_gradle_scripts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "dependencies { implementation 'org.a:x:1.0' }\n").unwrap();
|
||||
let dep = |g: &str, a: &str, v: Option<&str>, children: Vec<MavenDependency>| MavenDependency {
|
||||
group_id: g.into(),
|
||||
artifact_id: a.into(),
|
||||
version: v.map(Into::into),
|
||||
kind: "jar".into(),
|
||||
scope: "compile".into(),
|
||||
children,
|
||||
..Default::default()
|
||||
};
|
||||
let maven = MavenModel {
|
||||
projects: vec![
|
||||
MavenProject {
|
||||
group_id: "me".into(),
|
||||
artifact_id: "app".into(),
|
||||
dependencies: vec![
|
||||
dep("me", "core", Some("1"), vec![]),
|
||||
dep("org.b", "y", Some("2"), vec![dep("org.c", "z", Some("3"), vec![])]),
|
||||
dep("org.d", "unresolved", Some("${v}"), vec![]),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
MavenProject { group_id: "me".into(), artifact_id: "core".into(), ..Default::default() },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let gradle = gradle::load(root);
|
||||
let model = BuildModel { maven: Some(maven), gradle };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
.map(|c| format!("{}{}:{}:{}", if c.gradle { "G " } else { "M " }, c.group_id, c.artifact_id, c.version))
|
||||
.collect();
|
||||
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,12 @@ pub struct CompletionSettings {
|
||||
pub auto_popup: bool,
|
||||
/// Insert the selected suggestion by pressing space, dot, or other context-dependent keys.
|
||||
pub insert_on_special_keys: bool,
|
||||
/// Insert a sole suggestion of explicit (⌃Space) completion right away.
|
||||
pub auto_insert_single: bool,
|
||||
/// Sort suggestions by name instead of relevance.
|
||||
pub sort_alphabetically: bool,
|
||||
/// Add `()` after function and method suggestions.
|
||||
pub insert_parentheses: bool,
|
||||
pub show_documentation: bool,
|
||||
pub documentation_delay: u32,
|
||||
pub parameter_info: bool,
|
||||
@@ -130,6 +136,9 @@ impl Default for CompletionSettings {
|
||||
case_sensitivity: "first".into(),
|
||||
auto_popup: true,
|
||||
insert_on_special_keys: false,
|
||||
auto_insert_single: true,
|
||||
sort_alphabetically: false,
|
||||
insert_parentheses: true,
|
||||
show_documentation: false,
|
||||
documentation_delay: 500,
|
||||
parameter_info: true,
|
||||
@@ -166,13 +175,22 @@ impl Default for FoldingSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EditorTabsSettings {
|
||||
/// `top` | `none`.
|
||||
/// `top` | `bottom` | `none`.
|
||||
pub placement: String,
|
||||
pub multiple_rows: bool,
|
||||
/// One row that doesn't fit: `scroll` the tabs panel | `squeeze` the tabs.
|
||||
pub one_row_overflow: String,
|
||||
pub pinned_separate_row: bool,
|
||||
pub show_file_icon: bool,
|
||||
pub show_extension: bool,
|
||||
pub show_directory_for_non_unique: bool,
|
||||
/// Mark modified tabs with an asterisk.
|
||||
pub mark_modified: bool,
|
||||
pub show_full_path_on_hover: bool,
|
||||
pub show_close_button: bool,
|
||||
/// `right` | `left` (with `show_close_button`).
|
||||
pub close_button_position: String,
|
||||
pub sort_alphabetically: bool,
|
||||
pub preview_tab: bool,
|
||||
pub open_at_end: bool,
|
||||
pub tab_limit: u32,
|
||||
@@ -187,9 +205,16 @@ impl Default for EditorTabsSettings {
|
||||
Self {
|
||||
placement: "top".into(),
|
||||
multiple_rows: false,
|
||||
one_row_overflow: "scroll".into(),
|
||||
pinned_separate_row: false,
|
||||
show_file_icon: true,
|
||||
show_extension: true,
|
||||
show_directory_for_non_unique: true,
|
||||
mark_modified: false,
|
||||
show_full_path_on_hover: true,
|
||||
show_close_button: true,
|
||||
close_button_position: "right".into(),
|
||||
sort_alphabetically: false,
|
||||
preview_tab: true,
|
||||
open_at_end: false,
|
||||
tab_limit: 30,
|
||||
@@ -275,11 +300,38 @@ pub struct InlayHintsSettings {
|
||||
pub disabled_languages: Vec<String>,
|
||||
/// Code vision: usages above declarations.
|
||||
pub code_vision_usages: bool,
|
||||
/// Code vision: last author of a declaration (VCS blame) beside its usages.
|
||||
pub code_vision_author: bool,
|
||||
}
|
||||
|
||||
impl Default for InlayHintsSettings {
|
||||
fn default() -> Self {
|
||||
Self { types: true, parameters: true, other: true, disabled_languages: Vec::new(), code_vision_usages: true }
|
||||
Self {
|
||||
types: true,
|
||||
parameters: true,
|
||||
other: true,
|
||||
disabled_languages: Vec::new(),
|
||||
code_vision_usages: true,
|
||||
code_vision_author: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor › General › Inline Completion: ghost text from language servers that
|
||||
/// support `textDocument/inlineCompletion` (LSP 3.18).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct InlineCompletionSettings {
|
||||
pub enabled: bool,
|
||||
/// Ask only once typing has paused for `pause_ms`, instead of right after each change.
|
||||
pub only_on_pause: bool,
|
||||
pub pause_ms: u32,
|
||||
pub disabled_languages: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for InlineCompletionSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, only_on_pause: false, pause_ms: 500, disabled_languages: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +387,14 @@ pub struct CodeStyleSettings {
|
||||
/// Enable EditorConfig support.
|
||||
pub editorconfig: bool,
|
||||
pub languages: BTreeMap<String, LanguageCodeStyle>,
|
||||
/// Formatter › Do not format: globs (`*.min.js`, `generated/**`) Reformat Code skips.
|
||||
pub do_not_format: Vec<String>,
|
||||
/// Formatter › Turn formatter on/off with markers in code comments.
|
||||
pub formatter_tags_enabled: bool,
|
||||
pub formatter_off_tag: String,
|
||||
pub formatter_on_tag: String,
|
||||
/// The markers are regular expressions.
|
||||
pub formatter_tags_regexp: bool,
|
||||
}
|
||||
|
||||
impl Default for CodeStyleSettings {
|
||||
@@ -346,6 +406,11 @@ impl Default for CodeStyleSettings {
|
||||
detect_indents: true,
|
||||
editorconfig: true,
|
||||
languages: BTreeMap::new(),
|
||||
do_not_format: Vec::new(),
|
||||
formatter_tags_enabled: false,
|
||||
formatter_off_tag: "@formatter:off".into(),
|
||||
formatter_on_tag: "@formatter:on".into(),
|
||||
formatter_tags_regexp: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ pub enum IdeEvent {
|
||||
doc: DocId,
|
||||
dirty: bool,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File Type).
|
||||
DocLanguage {
|
||||
doc: DocId,
|
||||
language: crate::LanguageId,
|
||||
},
|
||||
/// Full replacement of diagnostics for one file from one producer.
|
||||
Diagnostics {
|
||||
path: crate::IpcPath,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Settings › Editor › Inspections: inspection profiles. `Default` and the
|
||||
//! user's IDE-level copies live in the IDE (global) settings, `Project Default`
|
||||
//! and project-level copies in the project's; the project picks which one is in
|
||||
//! effect. The UI applies the profile to diagnostics. Also Editor › Natural
|
||||
//! Languages › Spelling (the Typo inspection's dictionaries).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct InspectionSettings {
|
||||
/// Profile in effect for the project: `default`, `project`, or a copy
|
||||
/// as `ide:<key>` / `project:<key>`.
|
||||
pub profile: String,
|
||||
/// The `Default` profile (IDE level): inspection id → override.
|
||||
pub default: BTreeMap<String, InspectionOverride>,
|
||||
/// The `Project Default` profile (stored with the project).
|
||||
pub project: BTreeMap<String, InspectionOverride>,
|
||||
/// Profiles copied at IDE level (global settings), by key.
|
||||
pub ide_profiles: BTreeMap<String, CustomInspectionProfile>,
|
||||
/// Profiles copied at project level (project settings), by key.
|
||||
pub project_profiles: BTreeMap<String, CustomInspectionProfile>,
|
||||
}
|
||||
|
||||
impl Default for InspectionSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profile: "default".into(),
|
||||
default: BTreeMap::new(),
|
||||
project: BTreeMap::new(),
|
||||
ide_profiles: BTreeMap::new(),
|
||||
project_profiles: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CustomInspectionProfile {
|
||||
pub name: String,
|
||||
pub overrides: BTreeMap<String, InspectionOverride>,
|
||||
}
|
||||
|
||||
/// Editor › Natural Languages › Spelling.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SpellingSettings {
|
||||
/// Application-level dictionary: accepted words (IDE settings).
|
||||
pub ide_words: Vec<String>,
|
||||
/// Project-level dictionary: accepted words (project settings).
|
||||
pub project_words: Vec<String>,
|
||||
/// Custom dictionaries: plain word lists or Hunspell `.dic` files.
|
||||
pub custom_dictionaries: Vec<String>,
|
||||
}
|
||||
|
||||
/// Changes to one inspection; absent fields keep the inspection's defaults.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct InspectionOverride {
|
||||
#[specta(optional)]
|
||||
pub enabled: Option<bool>,
|
||||
#[specta(optional)]
|
||||
pub severity: Option<InspectionLevel>,
|
||||
}
|
||||
|
||||
/// IDEA's highlighting levels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InspectionLevel {
|
||||
Error,
|
||||
Warning,
|
||||
WeakWarning,
|
||||
ServerProblem,
|
||||
Typo,
|
||||
/// Reported (Problems, Inspect Code) but not highlighted in the editor.
|
||||
None,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overrides_parse_partially() {
|
||||
let s: InspectionSettings = serde_json::from_str(
|
||||
r#"{"default":{"lsp:ruff":{"enabled":false},"lsp:ruff:F401":{"severity":"weakWarning"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.profile, "default");
|
||||
assert_eq!(s.default["lsp:ruff"].enabled, Some(false));
|
||||
assert_eq!(s.default["lsp:ruff:F401"].severity, Some(InspectionLevel::WeakWarning));
|
||||
assert!(s.project.is_empty());
|
||||
assert!(s.ide_profiles.is_empty());
|
||||
let s: InspectionSettings = serde_json::from_str(
|
||||
r#"{"profile":"ide:strict","ideProfiles":{"strict":{"name":"Strict","overrides":{"lsp:ruff":{"severity":"error"}}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.ide_profiles["strict"].name, "Strict");
|
||||
assert_eq!(s.ide_profiles["strict"].overrides["lsp:ruff"].severity, Some(InspectionLevel::Error));
|
||||
assert!(serde_json::from_str::<InspectionSettings>(r#"{"default":{"x":{"severity":"loud"}}}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod error;
|
||||
pub mod event;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
|
||||
@@ -6,6 +6,7 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use crate::editor_settings::*;
|
||||
pub use crate::inspection_settings::*;
|
||||
pub use crate::workflow_settings::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -40,6 +41,10 @@ pub struct Settings {
|
||||
/// Language servers / debug adapters at a user-chosen location, by the
|
||||
/// command the language definition launches (`elixir-ls`, `codelldb`).
|
||||
pub tools: BTreeMap<String, ToolSettings>,
|
||||
/// Editor › Inspections profiles.
|
||||
pub inspections: InspectionSettings,
|
||||
/// Editor › Natural Languages › Spelling.
|
||||
pub spelling: SpellingSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -77,6 +82,8 @@ impl Default for Settings {
|
||||
actions_on_save: ActionsOnSaveSettings::default(),
|
||||
external_tools: Vec::new(),
|
||||
tools: BTreeMap::new(),
|
||||
inspections: InspectionSettings::default(),
|
||||
spelling: SpellingSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +116,11 @@ pub struct GeneralSettings {
|
||||
pub save_on_idle: bool,
|
||||
pub idle_seconds: u32,
|
||||
pub date_format: DateFormatSettings,
|
||||
/// File › Power Save Mode: no automatic popups (completion, parameter info,
|
||||
/// quick documentation) or background highlighting (usages, code vision).
|
||||
pub power_save_mode: bool,
|
||||
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
|
||||
pub project_name: String,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -121,6 +133,8 @@ impl Default for GeneralSettings {
|
||||
save_on_idle: false,
|
||||
idle_seconds: 15,
|
||||
date_format: DateFormatSettings::default(),
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +224,20 @@ pub struct AppearanceSettings {
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
/// Customize Main Toolbar; `None` = the default toolbar.
|
||||
pub main_toolbar: Option<MainToolbarSettings>,
|
||||
}
|
||||
|
||||
/// Main toolbar entries per side, in order: title widget ids (`project`,
|
||||
/// `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
|
||||
/// (registered later) keep their default place.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MainToolbarSettings {
|
||||
pub left: Vec<String>,
|
||||
pub right: Vec<String>,
|
||||
/// Widgets the user removed.
|
||||
pub hidden: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AppearanceSettings {
|
||||
@@ -239,6 +267,7 @@ impl Default for AppearanceSettings {
|
||||
show_tool_window_names: true,
|
||||
widescreen_tool_windows: false,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,6 +302,7 @@ pub struct EditorSettings {
|
||||
pub smart_keys: SmartKeysSettings,
|
||||
pub code_editing: CodeEditingSettings,
|
||||
pub inlay: InlayHintsSettings,
|
||||
pub inline_completion: InlineCompletionSettings,
|
||||
/// Gutter icon groups turned off (`run`, `breakpoints`, `bookmarks`, `vcs`, `blame`).
|
||||
pub gutter_icons_disabled: Vec<String>,
|
||||
pub show_gutter_icons: bool,
|
||||
@@ -307,6 +337,7 @@ impl Default for EditorSettings {
|
||||
smart_keys: SmartKeysSettings::default(),
|
||||
code_editing: CodeEditingSettings::default(),
|
||||
inlay: InlayHintsSettings::default(),
|
||||
inline_completion: InlineCompletionSettings::default(),
|
||||
gutter_icons_disabled: Vec::new(),
|
||||
show_gutter_icons: true,
|
||||
code_style: CodeStyleSettings::default(),
|
||||
@@ -329,6 +360,22 @@ pub struct FileSettings {
|
||||
pub local_history_days: u32,
|
||||
/// Upper bound for a project's Local History in MB; the oldest revisions go first.
|
||||
pub local_history_max_mb: u32,
|
||||
/// File Types › Recognized File Types: the user's changes, by language id.
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One language's file name and hashbang patterns on top of its definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FileTypeAssociation {
|
||||
/// Added file name patterns: `*.foo`, `Jenkinsfile`, `Dockerfile.*` (`*` and `?` wildcards).
|
||||
pub patterns: Vec<String>,
|
||||
/// Patterns of the definition (`*.ext`, file names) the user removed.
|
||||
pub removed_patterns: Vec<String>,
|
||||
/// Interpreters run by a `#!` first line, e.g. `node` for `#!/usr/bin/env node`.
|
||||
pub hashbangs: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for FileSettings {
|
||||
@@ -341,6 +388,8 @@ impl Default for FileSettings {
|
||||
ensure_final_newline: true,
|
||||
local_history_days: 5,
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,6 +520,17 @@ pub struct ProjectStructureSettings {
|
||||
pub excluded: Vec<String>,
|
||||
/// Auto-detected roots (from `pom.xml`, `build.gradle`, `Cargo.toml`, …) the user unmarked.
|
||||
pub unmarked: Vec<String>,
|
||||
/// Content roots besides the project folder (absolute paths), shown and searched with it.
|
||||
pub content_roots: Vec<String>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
}
|
||||
|
||||
impl ProjectStructureSettings {
|
||||
/// The "Exclude files" name patterns.
|
||||
pub fn exclude_file_patterns(&self) -> Vec<String> {
|
||||
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -486,10 +546,12 @@ impl Settings {
|
||||
out
|
||||
}
|
||||
|
||||
/// `files.exclude` plus `files.ignoredNames` (a bare name matches at any depth).
|
||||
/// `files.exclude`, `files.ignoredNames` and the Project Structure "Exclude files"
|
||||
/// patterns (a bare name matches at any depth).
|
||||
pub fn file_excludes(&self) -> Vec<String> {
|
||||
let mut out = self.files.exclude.clone();
|
||||
out.extend(self.files.ignored_names.iter().filter(|n| !n.trim().is_empty()).cloned());
|
||||
out.extend(self.project_structure.exclude_file_patterns().into_iter().filter(|p| !p.contains('/')));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -524,4 +586,15 @@ mod tests {
|
||||
assert!(k.overrides["file.save"].is_empty());
|
||||
assert_eq!(serde_json::to_value(Settings::default()).unwrap()["customKeymaps"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_files_patterns_join_the_file_excludes() {
|
||||
let s: Settings = serde_json::from_str(
|
||||
r#"{"files":{"exclude":[],"ignoredNames":[]},"projectStructure":{"excludeFiles":" *.log ; tmp*;;"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.project_structure.exclude_file_patterns(), vec!["*.log", "tmp*"]);
|
||||
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
|
||||
assert!(Settings::default().files.file_types.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,11 @@ pub struct Diagnostic {
|
||||
pub unnecessary: bool,
|
||||
#[serde(default)]
|
||||
pub deprecated: bool,
|
||||
/// Language server (or tool) that reported it, e.g. `basedpyright`; `source`
|
||||
/// is what the producer calls itself. Inspection profiles key on this.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
/// Symbol kinds, a stable subset of LSP `SymbolKind`.
|
||||
@@ -168,6 +173,17 @@ impl SymbolKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Declared (or conventional) visibility of an outline symbol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Visibility {
|
||||
Public,
|
||||
Protected,
|
||||
/// Package / module / assembly scope (Java default, `internal`, `pub(crate)`, Go unexported).
|
||||
Package,
|
||||
Private,
|
||||
}
|
||||
|
||||
/// A node of a document outline (Structure tool window, breadcrumbs).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct OutlineItem {
|
||||
@@ -179,6 +195,10 @@ pub struct OutlineItem {
|
||||
/// The identifier itself; where navigation lands.
|
||||
pub selection_range: Range,
|
||||
pub children: Vec<OutlineItem>,
|
||||
/// Unknown (`None`) for language-server symbols and languages without the notion.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub visibility: Option<Visibility>,
|
||||
}
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! "Import a report collected in CI from disk": the format is sniffed from the
|
||||
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
|
||||
//! machine are mapped into the project by their longest existing suffix.
|
||||
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Format {
|
||||
Lcov,
|
||||
GoProfile,
|
||||
Jacoco,
|
||||
}
|
||||
|
||||
pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
let head = text.trim_start_matches('\u{feff}').trim_start();
|
||||
if head.starts_with('<') {
|
||||
return head.contains("<report").then_some(Format::Jacoco);
|
||||
}
|
||||
let first = head.lines().next().unwrap_or_default();
|
||||
if first.starts_with("mode:") {
|
||||
return Some(Format::GoProfile);
|
||||
}
|
||||
head.lines().any(|l| l.trim_start().starts_with("SF:")).then_some(Format::Lcov)
|
||||
}
|
||||
|
||||
/// Reads the report at `path` for the project at `root`. Blocking.
|
||||
pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut suite = match detect(&text) {
|
||||
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
|
||||
Some(Format::GoProfile) => {
|
||||
let (module, module_dir) = go_module_for(root, &text);
|
||||
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
|
||||
}
|
||||
Some(Format::Jacoco) => {
|
||||
// A report inside the project (`<module>/target/site/jacoco/…`) knows its module.
|
||||
let module = jacoco::module_dir(path);
|
||||
let module =
|
||||
if path.starts_with(root) && module.starts_with(root) && module != path.parent().unwrap_or(path) {
|
||||
module
|
||||
} else {
|
||||
root.to_path_buf()
|
||||
};
|
||||
let mut acc = Accumulator { has_branches: true, ..Default::default() };
|
||||
jacoco::parse_into(&text, &module, &mut acc)
|
||||
.map_err(|e| IdeError::invalid(format!("{}: {e}", path.display())))?;
|
||||
acc.finish("JaCoCo")
|
||||
}
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
for f in &mut suite.files {
|
||||
let p: &Path = f.path.as_ref();
|
||||
if !p.exists()
|
||||
&& let Some(found) = relocate(p, root)
|
||||
{
|
||||
f.path = found.into();
|
||||
}
|
||||
}
|
||||
suite.files.sort_by(|a, b| (a.path.as_ref() as &Path).cmp(b.path.as_ref()));
|
||||
Ok(suite)
|
||||
}
|
||||
|
||||
/// `/home/runner/work/app/app/src/lib.rs` → `<root>/src/lib.rs`: the longest suffix of
|
||||
/// `path` that exists under `root`.
|
||||
pub(crate) fn relocate(path: &Path, root: &Path) -> Option<PathBuf> {
|
||||
let parts: Vec<&std::ffi::OsStr> = path
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(s) => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(0..parts.len()).find_map(|skip| {
|
||||
let candidate = parts[skip..].iter().fold(root.to_path_buf(), |p, s| p.join(s));
|
||||
candidate.is_file().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
/// The Go module the profile's import paths belong to: the project's `go.mod`, else one
|
||||
/// a level or two down whose module path prefixes the profile's files.
|
||||
fn go_module_for(root: &Path, profile: &str) -> (String, PathBuf) {
|
||||
let first = profile
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty() && !l.starts_with("mode:"))
|
||||
.and_then(|l| l.rsplit_once(':').map(|(f, _)| f.to_owned()))
|
||||
.unwrap_or_default();
|
||||
let matches = |module: &str| !module.is_empty() && (first == module || first.starts_with(&format!("{module}/")));
|
||||
let read = |dir: &Path| -> Option<String> {
|
||||
let text = std::fs::read_to_string(dir.join("go.mod")).ok()?;
|
||||
text.lines().find_map(|l| l.trim().strip_prefix("module")).map(|m| m.trim().trim_matches('"').to_owned())
|
||||
};
|
||||
let mut candidates = vec![root.to_path_buf()];
|
||||
for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()) {
|
||||
candidates
|
||||
.extend(std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()));
|
||||
candidates.push(dir);
|
||||
}
|
||||
let found: Vec<(String, PathBuf)> = candidates.into_iter().filter_map(|d| read(&d).map(|m| (m, d))).collect();
|
||||
found
|
||||
.iter()
|
||||
.find(|(m, _)| matches(m))
|
||||
.or_else(|| found.iter().find(|(_, d)| d == root))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (String::new(), root.to_path_buf()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_formats() {
|
||||
assert_eq!(detect("TN:\nSF:a.rs\nDA:1,1\nend_of_record\n"), Some(Format::Lcov));
|
||||
assert_eq!(detect("mode: atomic\nm/a.go:1.1,2.2 1 0\n"), Some(Format::GoProfile));
|
||||
assert_eq!(
|
||||
detect("<?xml version=\"1.0\"?><!DOCTYPE report><report name=\"x\"></report>"),
|
||||
Some(Format::Jacoco)
|
||||
);
|
||||
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
|
||||
assert_eq!(detect("hello"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_lcov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/lib.rs"), "").unwrap();
|
||||
let report = tmp.path().join("lcov.info");
|
||||
std::fs::write(&report, "SF:/home/runner/work/app/app/src/lib.rs\nDA:1,1\nDA:2,0\nend_of_record\n").unwrap();
|
||||
let suite = import(&report, &root).unwrap();
|
||||
assert_eq!(suite.runner, "lcov");
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("src/lib.rs"));
|
||||
assert_eq!(suite.files[0].lines_covered, 1);
|
||||
assert!(import(&tmp.path().join("missing"), &root).is_err());
|
||||
std::fs::write(&report, "nothing").unwrap();
|
||||
assert!(import(&report, &root).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_go_profiles_of_nested_modules() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("svc/calc")).unwrap();
|
||||
std::fs::write(root.join("svc/go.mod"), "module example.com/svc\n").unwrap();
|
||||
let report = root.join("cover.out");
|
||||
std::fs::write(&report, "mode: set\nexample.com/svc/calc/calc.go:3.1,4.2 1 1\n").unwrap();
|
||||
let suite = import(&report, root).unwrap();
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("svc/calc/calc.go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_jacoco_against_the_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("p");
|
||||
std::fs::create_dir_all(root.join("src/main/java/com/x")).unwrap();
|
||||
std::fs::write(root.join("src/main/java/com/x/A.java"), "").unwrap();
|
||||
let report = tmp.path().join("jacoco.xml");
|
||||
let xml = r#"<report name="p"><package name="com/x"><sourcefile name="A.java"><line nr="2" mi="0" ci="1" mb="0" cb="0"/></sourcefile></package></report>"#;
|
||||
std::fs::write(&report, xml).unwrap();
|
||||
let suite = import(&report, &root).unwrap();
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("src/main/java/com/x/A.java"));
|
||||
assert!(suite.has_branches);
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,12 @@
|
||||
//! Lines in the model are 0-based, like every IPC coordinate.
|
||||
|
||||
mod goprofile;
|
||||
mod import;
|
||||
mod jacoco;
|
||||
mod lcov;
|
||||
mod plan;
|
||||
|
||||
pub use import::import;
|
||||
pub use plan::prepare;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
|
||||
@@ -288,6 +288,7 @@ mod tests {
|
||||
is_test: true,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+224
-15
@@ -15,7 +15,8 @@ use sqlx::{
|
||||
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
|
||||
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -184,6 +185,90 @@ fn looks_like_query(sql: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn file_setting(path: &str) -> Option<PathBuf> {
|
||||
let path = path.trim();
|
||||
(!path.is_empty()).then(|| crate::tunnel::expand(path))
|
||||
}
|
||||
|
||||
fn is_identifier(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
|
||||
fn mysql_value(v: &str) -> String {
|
||||
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
|
||||
if keyword || v.parse::<f64>().is_ok() {
|
||||
v.to_owned()
|
||||
} else {
|
||||
format!("'{}'", v.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How the Options and Advanced tabs reach a connection.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Setup {
|
||||
/// PostgreSQL run-time parameters (startup `options`), SQLite pragmas.
|
||||
pub params: Vec<(String, String)>,
|
||||
/// Run on every new connection: MySQL session settings, then the startup script.
|
||||
pub statements: Vec<String>,
|
||||
pub application_name: Option<String>,
|
||||
pub charset: Option<String>,
|
||||
pub collation: Option<String>,
|
||||
pub statement_cache: Option<usize>,
|
||||
pub connect_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
pub fn of(source: &DataSource) -> IdeResult<Self> {
|
||||
let mut s = Setup::default();
|
||||
let o = &source.options;
|
||||
match source.driver {
|
||||
DbDriver::Postgres => {
|
||||
if source.read_only {
|
||||
s.params.push(("default_transaction_read_only".into(), "on".into()));
|
||||
}
|
||||
if !o.default_schema.trim().is_empty() {
|
||||
s.params.push(("search_path".into(), o.default_schema.trim().into()));
|
||||
}
|
||||
if !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("TimeZone".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
}
|
||||
DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()),
|
||||
_ => {}
|
||||
}
|
||||
for p in &source.properties {
|
||||
let value = p.value.trim();
|
||||
let name = p.name.trim().replace('-', "_");
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_identifier(&name) {
|
||||
return Err(IdeError::invalid(format!("invalid driver property name: {}", p.name.trim())));
|
||||
}
|
||||
let number = |what: &str| {
|
||||
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
|
||||
};
|
||||
match (source.driver, name.to_ascii_lowercase().as_str()) {
|
||||
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
|
||||
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
|
||||
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))),
|
||||
_ => s.params.push((name, value.to_owned())),
|
||||
}
|
||||
}
|
||||
let script = o.startup_script.trim();
|
||||
if !script.is_empty() {
|
||||
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
pub fn driver(&self) -> DbDriver {
|
||||
match self {
|
||||
@@ -196,7 +281,24 @@ impl Pool {
|
||||
/// Opens a pool and one connection (so bad settings fail here).
|
||||
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
const MAX: u32 = 4;
|
||||
let timeout = Duration::from_secs(30);
|
||||
let setup = Setup::of(source)?;
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
// Runs the session statements (startup script…) on every new connection.
|
||||
macro_rules! after_connect {
|
||||
() => {{
|
||||
let init = init.clone();
|
||||
move |conn, _| {
|
||||
let init = init.clone();
|
||||
Box::pin(async move {
|
||||
for sql in init.iter() {
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}};
|
||||
}
|
||||
match source.driver {
|
||||
DbDriver::Sqlite => {
|
||||
let file = source.file.as_deref().ok_or_else(|| IdeError::invalid("no database file"))?;
|
||||
@@ -204,11 +306,18 @@ impl Pool {
|
||||
Some(r) if file.is_relative() => r.join(file),
|
||||
_ => file.to_path_buf(),
|
||||
};
|
||||
let opts =
|
||||
let mut opts =
|
||||
SqliteConnectOptions::new().filename(&file).create_if_missing(false).read_only(source.read_only);
|
||||
for (k, v) in setup.params {
|
||||
opts = opts.pragma(k, v);
|
||||
}
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
@@ -219,24 +328,39 @@ impl Pool {
|
||||
.host(&source.host)
|
||||
.port(source.port.unwrap_or(5432))
|
||||
.username(&source.user)
|
||||
.application_name("OpenIDEA")
|
||||
.application_name(setup.application_name.as_deref().unwrap_or("OpenIDEA"))
|
||||
.ssl_mode(match source.ssl {
|
||||
DbSslMode::Disable => PgSslMode::Disable,
|
||||
DbSslMode::Prefer => PgSslMode::Prefer,
|
||||
DbSslMode::Require => PgSslMode::Require,
|
||||
DbSslMode::VerifyCa => PgSslMode::VerifyCa,
|
||||
DbSslMode::VerifyFull => PgSslMode::VerifyFull,
|
||||
});
|
||||
if let Some(f) = file_setting(&source.ssl_ca_file) {
|
||||
opts = opts.ssl_root_cert(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_cert_file) {
|
||||
opts = opts.ssl_client_cert(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_key_file) {
|
||||
opts = opts.ssl_client_key(f);
|
||||
}
|
||||
if !source.database.is_empty() {
|
||||
opts = opts.database(&source.database);
|
||||
}
|
||||
if let Some(p) = password {
|
||||
opts = opts.password(p);
|
||||
}
|
||||
if source.read_only {
|
||||
opts = opts.options([("default_transaction_read_only", "on")]);
|
||||
if !setup.params.is_empty() {
|
||||
opts = opts.options(setup.params);
|
||||
}
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
@@ -251,25 +375,41 @@ impl Pool {
|
||||
DbSslMode::Disable => MySqlSslMode::Disabled,
|
||||
DbSslMode::Prefer => MySqlSslMode::Preferred,
|
||||
DbSslMode::Require => MySqlSslMode::Required,
|
||||
DbSslMode::VerifyCa => MySqlSslMode::VerifyCa,
|
||||
DbSslMode::VerifyFull => MySqlSslMode::VerifyIdentity,
|
||||
});
|
||||
if let Some(f) = file_setting(&source.ssl_ca_file) {
|
||||
opts = opts.ssl_ca(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_cert_file) {
|
||||
opts = opts.ssl_client_cert(f);
|
||||
}
|
||||
if let Some(f) = file_setting(&source.ssl_key_file) {
|
||||
opts = opts.ssl_client_key(f);
|
||||
}
|
||||
if !source.database.is_empty() {
|
||||
opts = opts.database(&source.database);
|
||||
}
|
||||
if let Some(p) = password {
|
||||
opts = opts.password(p);
|
||||
}
|
||||
let read_only = source.read_only;
|
||||
if let Some(c) = &setup.charset {
|
||||
opts = opts.charset(c);
|
||||
}
|
||||
if let Some(c) = &setup.collation {
|
||||
opts = opts.collation(c);
|
||||
}
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
let tz = source.options.time_zone.trim();
|
||||
if !tz.is_empty() {
|
||||
opts = opts.timezone(Some(tz.to_owned()));
|
||||
}
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(move |conn, _| {
|
||||
Box::pin(async move {
|
||||
if read_only {
|
||||
conn.execute("SET SESSION TRANSACTION READ ONLY").await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
@@ -282,6 +422,10 @@ impl Pool {
|
||||
with_pool!(self, p => p.close().await)
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
with_pool!(self, p => p.is_closed())
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection.
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max));
|
||||
@@ -434,3 +578,68 @@ fn statement_columns<S: sqlx::Statement>(s: &S) -> Vec<ResultColumn> {
|
||||
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.type_info().name().to_owned() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source::{DbOptions, DbProperty};
|
||||
|
||||
fn prop(name: &str, value: &str) -> DbProperty {
|
||||
DbProperty { name: name.into(), value: value.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_options_become_runtime_parameters() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Postgres,
|
||||
read_only: true,
|
||||
options: DbOptions {
|
||||
default_schema: "app, public".into(),
|
||||
time_zone: "Europe/Paris".into(),
|
||||
startup_script: "SET work_mem = '64MB'; select 1;".into(),
|
||||
..Default::default()
|
||||
},
|
||||
properties: vec![prop("statement_timeout", "5s"), prop("application_name", "me"), prop("", "x")],
|
||||
..Default::default()
|
||||
};
|
||||
let setup = Setup::of(&s).unwrap();
|
||||
let names: Vec<_> = setup.params.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(names, ["default_transaction_read_only", "search_path", "TimeZone", "statement_timeout"]);
|
||||
assert_eq!(setup.application_name.as_deref(), Some("me"));
|
||||
assert_eq!(setup.statements, ["SET work_mem = '64MB'", "select 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_properties_become_session_variables() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Mysql,
|
||||
read_only: true,
|
||||
properties: vec![
|
||||
prop("sql_mode", "ANSI_QUOTES"),
|
||||
prop("max_execution_time", "1000"),
|
||||
prop("charset", "utf8mb4"),
|
||||
prop("connect-timeout", "5"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let setup = Setup::of(&s).unwrap();
|
||||
assert_eq!(
|
||||
setup.statements,
|
||||
[
|
||||
"SET SESSION TRANSACTION READ ONLY",
|
||||
"SET SESSION sql_mode = 'ANSI_QUOTES'",
|
||||
"SET SESSION max_execution_time = 1000"
|
||||
]
|
||||
);
|
||||
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
|
||||
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_properties_become_pragmas() {
|
||||
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
|
||||
assert_eq!(Setup::of(&s).unwrap().params, [("cache_size".to_owned(), "-2000".to_owned())]);
|
||||
}
|
||||
}
|
||||
+47
-10
@@ -10,7 +10,9 @@ pub mod sql;
|
||||
pub mod tunnel;
|
||||
|
||||
pub use conn::{Held, Pool, QueryResult, ResultColumn};
|
||||
pub use source::{DataSource, DbDriver, DbSsh, DbSshAuth, DbSslMode};
|
||||
pub use source::{
|
||||
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode,
|
||||
};
|
||||
pub use sql::{CellValue, DbRowEdit, SortKey};
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
@@ -21,6 +23,7 @@ use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -219,6 +222,24 @@ fn through_tunnel(e: IdeError, source: &DataSource, tunneled: bool) -> IdeError
|
||||
)
|
||||
}
|
||||
|
||||
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
|
||||
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
|
||||
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
let pool = Arc::downgrade(pool);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(every);
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
tick.tick().await;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break };
|
||||
if let Err(e) = p.rows("SELECT 1", 1).await {
|
||||
tracing::debug!("keep-alive: {}", e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rolls back a session's transaction and releases its connection.
|
||||
async fn end_session(session: &Session) {
|
||||
let held = session.lock().await.take();
|
||||
@@ -261,14 +282,16 @@ impl DbManager {
|
||||
source::load(&self.root)
|
||||
}
|
||||
|
||||
/// The data source of connection target `target`, with its database for `<id>:<database>`.
|
||||
/// The connection settings of target `target` ([`DataSource::resolved`]), with
|
||||
/// its database for `<id>:<database>`.
|
||||
pub fn source(&self, target: &str) -> IdeResult<DataSource> {
|
||||
let (id, database) = split_target(target);
|
||||
let mut source = self
|
||||
.sources()?
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?;
|
||||
.ok_or_else(|| IdeError::not_found(format!("no data source {id}")))?
|
||||
.resolved()?;
|
||||
if let Some(db) = database {
|
||||
source.database = db.to_owned();
|
||||
}
|
||||
@@ -341,7 +364,10 @@ impl DbManager {
|
||||
}
|
||||
|
||||
async fn password(&self, source: &DataSource) -> Option<String> {
|
||||
self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await
|
||||
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
Some(p) => Some(p),
|
||||
None => source.url_password(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trusts the host key that failed the last connection of `id` (adds it to `known_hosts`).
|
||||
@@ -411,6 +437,7 @@ impl DbManager {
|
||||
password: Option<String>,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let source = &source.resolved()?;
|
||||
let password = match password {
|
||||
Some(p) => Some(p),
|
||||
None => self.password(source).await,
|
||||
@@ -458,9 +485,14 @@ impl DbManager {
|
||||
return Err(through_tunnel(e, &source, tunnel.is_some()));
|
||||
}
|
||||
};
|
||||
let mut pools = self.pools.lock();
|
||||
// Another caller may have connected meanwhile; keep theirs.
|
||||
Ok(pools.entry(id.to_owned()).or_insert(pool).clone())
|
||||
let kept = self.pools.lock().entry(id.to_owned()).or_insert_with(|| pool.clone()).clone();
|
||||
if Arc::ptr_eq(&kept, &pool)
|
||||
&& let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0)
|
||||
{
|
||||
keep_alive(&pool, Duration::from_secs(secs.into()));
|
||||
}
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// Closes the tunnel of `source_id` once none of its targets is connected.
|
||||
@@ -496,24 +528,29 @@ impl DbManager {
|
||||
|
||||
/// The databases of the server of data source `id` (PostgreSQL), the connected one first.
|
||||
pub async fn databases(&self, id: &str) -> IdeResult<Databases> {
|
||||
let pool = self.pool(split_target(id).0).await?;
|
||||
let id = split_target(id).0;
|
||||
let system = self.source(id)?.introspection.show_system;
|
||||
let pool = self.pool(id).await?;
|
||||
if pool.driver() != DbDriver::Postgres {
|
||||
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
|
||||
}
|
||||
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
|
||||
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
|
||||
let rows = pool.rows(sql::DATABASES, MAX_META_ROWS).await?;
|
||||
let rows = pool.rows(sql::databases(system), MAX_META_ROWS).await?;
|
||||
let mut names: Vec<String> =
|
||||
first(rows).filter(|n| *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
|
||||
first(rows).filter(|n| system || *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
|
||||
if !names.contains(¤t) {
|
||||
names.insert(0, current.clone());
|
||||
}
|
||||
Ok(Databases { current, names })
|
||||
}
|
||||
|
||||
/// Every schema (the Schemas tab picks the ones the tree shows); system schemas
|
||||
/// only with [`DbIntrospection::show_system`].
|
||||
pub async fn schemas(&self, id: &str) -> IdeResult<Vec<String>> {
|
||||
let system = self.source(id)?.introspection.show_system;
|
||||
let pool = self.pool(id).await?;
|
||||
let rows = pool.rows(sql::schemas(pool.driver()), MAX_META_ROWS).await?;
|
||||
let rows = pool.rows(sql::schemas(pool.driver(), system), MAX_META_ROWS).await?;
|
||||
Ok(rows.into_iter().filter_map(|r| r.into_iter().next().flatten()).collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,76 @@ pub enum DbSslMode {
|
||||
#[default]
|
||||
Prefer,
|
||||
Require,
|
||||
/// Require, and check the server certificate against the CA file.
|
||||
VerifyCa,
|
||||
/// [`DbSslMode::VerifyCa`], and check that the certificate names the host.
|
||||
VerifyFull,
|
||||
}
|
||||
|
||||
/// IDEA's data source colours (tree node and console tab tint), e.g. red for production.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbColor {
|
||||
Red,
|
||||
Orange,
|
||||
Yellow,
|
||||
Green,
|
||||
Blue,
|
||||
Violet,
|
||||
}
|
||||
|
||||
/// Transaction control of the data source's consoles.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbTxMode {
|
||||
#[default]
|
||||
Auto,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// Options tab: session settings applied to every connection.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbOptions {
|
||||
pub transaction: DbTxMode,
|
||||
/// PostgreSQL `search_path` (unqualified names resolve there).
|
||||
pub default_schema: String,
|
||||
/// Session time zone (`TimeZone` / `time_zone`); empty keeps the default.
|
||||
pub time_zone: String,
|
||||
/// Statements run on every new connection.
|
||||
pub startup_script: String,
|
||||
/// Run `SELECT 1` this often while connected.
|
||||
pub keep_alive_secs: Option<u32>,
|
||||
/// Disconnect after this long without statements (enforced by the UI, which sees the activity).
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbIntrospection {
|
||||
pub all_databases: bool,
|
||||
pub databases: Vec<String>,
|
||||
pub all_schemas: bool,
|
||||
pub schemas: Vec<String>,
|
||||
/// `pg_catalog`, `information_schema`, `mysql`…, and PostgreSQL template databases.
|
||||
pub show_system: bool,
|
||||
}
|
||||
|
||||
impl Default for DbIntrospection {
|
||||
fn default() -> Self {
|
||||
Self { all_databases: true, databases: Vec::new(), all_schemas: true, schemas: Vec::new(), show_system: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Advanced tab: a driver property. PostgreSQL: run-time parameters (and
|
||||
/// `application_name`); MySQL: session variables (and `charset`, `collation`);
|
||||
/// SQLite: pragmas. See [`crate::conn`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbProperty {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type").
|
||||
@@ -84,6 +154,20 @@ pub struct DataSource {
|
||||
pub read_only: bool,
|
||||
/// SSH tunnel settings (`None`: never configured; kept but unused when not `enabled`).
|
||||
pub ssh: Option<DbSsh>,
|
||||
/// Wire-compatible product preset (`cockroachdb`, `tidb`…; empty: the driver itself).
|
||||
pub vendor: String,
|
||||
pub color: Option<DbColor>,
|
||||
pub comment: String,
|
||||
/// Connect with [`DataSource::url`] instead of the host / port / database fields.
|
||||
pub url_only: bool,
|
||||
pub url: String,
|
||||
/// PEM files; `~/` is the home directory.
|
||||
pub ssl_ca_file: String,
|
||||
pub ssl_cert_file: String,
|
||||
pub ssl_key_file: String,
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -101,10 +185,188 @@ impl Default for DataSource {
|
||||
save_password: false,
|
||||
read_only: false,
|
||||
ssh: None,
|
||||
vendor: String::new(),
|
||||
color: None,
|
||||
comment: String::new(),
|
||||
url_only: false,
|
||||
url: String::new(),
|
||||
ssl_ca_file: String::new(),
|
||||
ssl_cert_file: String::new(),
|
||||
ssl_key_file: String::new(),
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection settings of a URL (`jdbc:` prefix allowed).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ParsedUrl {
|
||||
pub driver: Option<DbDriver>,
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
pub password: Option<String>,
|
||||
/// Query parameters, in order.
|
||||
pub params: Vec<(String, String)>,
|
||||
/// SQLite file.
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
let hex = |i: usize| b.get(i).and_then(|&c| (c as char).to_digit(16));
|
||||
while i < b.len() {
|
||||
if b[i] == b'%'
|
||||
&& let (Some(h), Some(l)) = (hex(i + 1), hex(i + 2))
|
||||
{
|
||||
out.push((h * 16 + l) as u8);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The driver speaking the protocol of URL scheme `scheme` (wire-compatible products included).
|
||||
pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
|
||||
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
|
||||
/// included, and `sqlite:path`.
|
||||
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
|
||||
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
|
||||
let url = raw.trim();
|
||||
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
|
||||
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
|
||||
let driver = scheme_driver(scheme).ok_or_else(bad)?;
|
||||
if driver == DbDriver::Sqlite {
|
||||
let file = rest.strip_prefix("//").unwrap_or(rest);
|
||||
let file = file.split('?').next().unwrap_or_default();
|
||||
if file.is_empty() {
|
||||
return Err(bad());
|
||||
}
|
||||
return Ok(ParsedUrl { driver: Some(driver), file: Some(percent_decode(file)), ..Default::default() });
|
||||
}
|
||||
let rest = rest.strip_prefix("//").ok_or_else(bad)?;
|
||||
let (main, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||
let (authority, database) = main.split_once('/').unwrap_or((main, ""));
|
||||
let (userinfo, hostport) = match authority.rsplit_once('@') {
|
||||
Some((u, h)) => (Some(u), h),
|
||||
None => (None, authority),
|
||||
};
|
||||
// Several hosts (failover lists): the first.
|
||||
let hostport = hostport.split(',').next().unwrap_or_default();
|
||||
let (host, port) = if let Some(v6) = hostport.strip_prefix('[') {
|
||||
let (h, after) = v6.split_once(']').ok_or_else(bad)?;
|
||||
(h.to_owned(), after.strip_prefix(':'))
|
||||
} else {
|
||||
match hostport.rsplit_once(':') {
|
||||
Some((h, p)) => (h.to_owned(), Some(p)),
|
||||
None => (hostport.to_owned(), None),
|
||||
}
|
||||
};
|
||||
let port = match port.filter(|p| !p.is_empty()) {
|
||||
Some(p) => Some(p.parse::<u16>().map_err(|_| bad())?),
|
||||
None => None,
|
||||
};
|
||||
let mut parsed = ParsedUrl {
|
||||
driver: Some(driver),
|
||||
host,
|
||||
port,
|
||||
database: percent_decode(database.trim_end_matches('/')),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(u) = userinfo {
|
||||
let (user, password) = match u.split_once(':') {
|
||||
Some((u, p)) => (u, Some(percent_decode(p))),
|
||||
None => (u, None),
|
||||
};
|
||||
parsed.user = percent_decode(user);
|
||||
parsed.password = password;
|
||||
}
|
||||
for pair in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
let (k, v) = (percent_decode(k), percent_decode(v));
|
||||
match k.as_str() {
|
||||
"user" if parsed.user.is_empty() => parsed.user = v,
|
||||
"password" if parsed.password.is_none() => parsed.password = Some(v),
|
||||
_ => parsed.params.push((k, v)),
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
impl DataSource {
|
||||
/// The settings to connect with: with [`DataSource::url_only`], host, port,
|
||||
/// database, user, SSL and driver properties come from the URL (properties
|
||||
/// of the Advanced tab still apply after the URL's).
|
||||
pub fn resolved(&self) -> IdeResult<DataSource> {
|
||||
if !self.url_only {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
let u = parse_url(&self.url)?;
|
||||
let mut s = self.clone();
|
||||
if let Some(d) = u.driver
|
||||
&& d != s.driver
|
||||
{
|
||||
return Err(IdeError::invalid(format!("the URL is not for the {:?} driver", s.driver)));
|
||||
}
|
||||
if s.driver == DbDriver::Sqlite {
|
||||
s.file = u.file.map(IpcPath::from);
|
||||
return Ok(s);
|
||||
}
|
||||
s.host = u.host;
|
||||
s.port = u.port;
|
||||
s.database = u.database;
|
||||
if !u.user.is_empty() {
|
||||
s.user = u.user;
|
||||
}
|
||||
let mut properties = Vec::new();
|
||||
for (k, v) in u.params {
|
||||
match k.to_ascii_lowercase().as_str() {
|
||||
"sslmode" | "ssl-mode" => {
|
||||
s.ssl = match v.to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"disable" | "disabled" => DbSslMode::Disable,
|
||||
"require" | "required" => DbSslMode::Require,
|
||||
"verify_ca" => DbSslMode::VerifyCa,
|
||||
"verify_full" | "verify_identity" => DbSslMode::VerifyFull,
|
||||
_ => DbSslMode::Prefer,
|
||||
}
|
||||
}
|
||||
"sslrootcert" | "ssl-ca" | "sslca" => s.ssl_ca_file = v,
|
||||
"sslcert" | "ssl-cert" => s.ssl_cert_file = v,
|
||||
"sslkey" | "ssl-key" => s.ssl_key_file = v,
|
||||
_ => properties.push(DbProperty { name: k, value: v }),
|
||||
}
|
||||
}
|
||||
properties.append(&mut s.properties);
|
||||
s.properties = properties;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The password written in the URL of a [`DataSource::url_only`] data source.
|
||||
pub fn url_password(&self) -> Option<String> {
|
||||
if !self.url_only {
|
||||
return None;
|
||||
}
|
||||
parse_url(&self.url).ok()?.password
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct SourcesFile {
|
||||
#[serde(default, rename = "data-source")]
|
||||
@@ -164,4 +426,77 @@ mod tests {
|
||||
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
|
||||
assert!(save(tmp.path(), &[DataSource::default()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_configs_written_before_the_new_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join(".openidea");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(FILE),
|
||||
"[[data-source]]\nid = \"a\"\nname = \"old\"\ndriver = \"postgres\"\nhost = \"db\"\nssl = \"require\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let s = load(tmp.path()).unwrap().remove(0);
|
||||
assert_eq!(s.ssl, DbSslMode::Require);
|
||||
assert!(s.introspection.all_databases && s.introspection.all_schemas);
|
||||
assert_eq!((s.color, s.url_only, s.options.transaction), (None, false, DbTxMode::Auto));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_the_new_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let s = DataSource {
|
||||
id: "b".into(),
|
||||
name: "prod".into(),
|
||||
driver: DbDriver::Mysql,
|
||||
vendor: "tidb".into(),
|
||||
color: Some(DbColor::Red),
|
||||
comment: "careful".into(),
|
||||
ssl: DbSslMode::VerifyFull,
|
||||
ssl_ca_file: "~/ca.pem".into(),
|
||||
options: DbOptions { time_zone: "UTC".into(), keep_alive_secs: Some(60), ..Default::default() },
|
||||
introspection: DbIntrospection { all_schemas: false, schemas: vec!["app*".into()], ..Default::default() },
|
||||
properties: vec![DbProperty { name: "sql_mode".into(), value: "ANSI".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
save(tmp.path(), std::slice::from_ref(&s)).unwrap();
|
||||
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_urls() {
|
||||
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
|
||||
assert_eq!(u.driver, Some(DbDriver::Postgres));
|
||||
assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(5433), "shop"));
|
||||
assert_eq!((u.user.as_str(), u.password.as_deref()), ("ann", Some("s@cret")));
|
||||
assert_eq!(u.params, vec![("sslmode".into(), "require".into()), ("search_path".into(), "app".into())]);
|
||||
let u = parse_url("mysql://[::1]/app?user=root").unwrap();
|
||||
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
|
||||
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
|
||||
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
|
||||
assert!(parse_url("oracle://x").is_err());
|
||||
assert!(parse_url("postgres://h:99999/db").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_only_sources_connect_with_the_url() {
|
||||
let s = DataSource {
|
||||
driver: DbDriver::Postgres,
|
||||
user: "fallback".into(),
|
||||
url_only: true,
|
||||
url: "postgres://ann:pw@h:6543/app?sslmode=verify-full&sslrootcert=/ca.pem&statement_timeout=5000".into(),
|
||||
properties: vec![DbProperty { name: "work_mem".into(), value: "64MB".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
let r = s.resolved().unwrap();
|
||||
assert_eq!((r.host.as_str(), r.port, r.database.as_str(), r.user.as_str()), ("h", Some(6543), "app", "ann"));
|
||||
assert_eq!((r.ssl, r.ssl_ca_file.as_str()), (DbSslMode::VerifyFull, "/ca.pem"));
|
||||
let names: Vec<_> = r.properties.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["statement_timeout", "work_mem"]);
|
||||
assert_eq!(s.url_password().as_deref(), Some("pw"));
|
||||
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
|
||||
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,26 @@ pub fn qualified(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
}
|
||||
|
||||
/// Databases one can connect to (PostgreSQL).
|
||||
pub const DATABASES: &str = "SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname";
|
||||
/// PostgreSQL databases one can connect to; templates only with `system`.
|
||||
pub fn databases(system: bool) -> &'static str {
|
||||
if system {
|
||||
"SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname"
|
||||
} else {
|
||||
"SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname"
|
||||
}
|
||||
}
|
||||
|
||||
/// Databases of managed services that their users cannot use (hidden unless connected to).
|
||||
pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"];
|
||||
|
||||
pub fn schemas(driver: DbDriver) -> &'static str {
|
||||
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
|
||||
DbDriver::Postgres if system => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Postgres => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
|
||||
@@ -200,7 +200,7 @@ fn home() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// `~/x` → `$HOME/x`.
|
||||
fn expand(path: &str) -> PathBuf {
|
||||
pub(crate) fn expand(path: &str) -> PathBuf {
|
||||
match (path.strip_prefix("~/"), home()) {
|
||||
(Some(rest), Some(h)) => h.join(rest),
|
||||
_ => PathBuf::from(path),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Introspection, queries and table edits against a real SQLite file.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbRowEdit, DbTableKind, SortKey};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
|
||||
|
||||
async fn setup() -> (tempfile::TempDir, DbManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -246,3 +246,23 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> {
|
||||
assert!(db.ddl("s1", "main", "nope").await.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeResult<()> {
|
||||
let (tmp, db) = setup().await;
|
||||
let mut s = db.sources()?.remove(0);
|
||||
s.properties = vec![DbProperty { name: "cache_size".into(), value: "-1234".into() }];
|
||||
s.options = DbOptions {
|
||||
startup_script: "CREATE TEMP TABLE boot (x); INSERT INTO boot VALUES (7);".into(),
|
||||
..Default::default()
|
||||
};
|
||||
db.save_sources(vec![s.clone()]).await?;
|
||||
let c = CancellationToken::new();
|
||||
let r = db.execute("s1", "SELECT (SELECT x FROM boot), (SELECT cache_size FROM pragma_cache_size)", 10, &c).await?;
|
||||
assert_eq!(r.rows, vec![vec![Some("7".into()), Some("-1234".into())]]);
|
||||
// URL only: the file comes from the URL.
|
||||
let url = format!("jdbc:sqlite:{}", tmp.path().join("app.db").display());
|
||||
db.save_sources(vec![DataSource { url_only: true, url, file: None, ..s }]).await?;
|
||||
assert_eq!(db.schemas("s1").await?, vec!["main"]);
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,3 +15,6 @@ which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Docker connections (which daemon the CLI talks to) and registries, as the
|
||||
//! Services tool window's "Add Service" menu creates them. Both are app-wide
|
||||
//! like in IDEA: `docker.json` in the app data directory; registry passwords
|
||||
//! live beside it in an owner-only `docker-passwords.json`.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Which daemon a docker command talks to.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum DockerEndpoint {
|
||||
/// Whatever the CLI is configured for (`DOCKER_HOST`, the current context).
|
||||
#[default]
|
||||
Default,
|
||||
/// A local daemon socket, e.g. `/var/run/docker.sock` (or a `npipe://` URL on Windows).
|
||||
Socket { path: String },
|
||||
/// A remote daemon, e.g. `tcp://build:2376` or `ssh://me@build`; with TLS when
|
||||
/// `tls_cert_dir` holds `ca.pem`, `cert.pem` and `key.pem`.
|
||||
Tcp { host: String, tls_cert_dir: Option<String> },
|
||||
/// A named `docker context`.
|
||||
Context { name: String },
|
||||
}
|
||||
|
||||
impl DockerEndpoint {
|
||||
/// Global CLI options selecting this endpoint (before the subcommand).
|
||||
pub fn global_args(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Default => Vec::new(),
|
||||
Self::Socket { path } => {
|
||||
let path = path.trim();
|
||||
let url = if path.contains("://") { path.to_owned() } else { format!("unix://{path}") };
|
||||
vec!["--host".into(), url]
|
||||
}
|
||||
Self::Tcp { host, tls_cert_dir } => {
|
||||
let host = host.trim();
|
||||
let url = if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") };
|
||||
let mut args = vec!["--host".into(), url];
|
||||
if let Some(dir) = tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
|
||||
let file = |name: &str| Path::new(dir).join(name).to_string_lossy().into_owned();
|
||||
args.extend([
|
||||
"--tlsverify".into(),
|
||||
"--tlscacert".into(),
|
||||
file("ca.pem"),
|
||||
"--tlscert".into(),
|
||||
file("cert.pem"),
|
||||
"--tlskey".into(),
|
||||
file("key.pem"),
|
||||
]);
|
||||
}
|
||||
args
|
||||
}
|
||||
Self::Context { name } => vec!["--context".into(), name.trim().to_owned()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the global options to `cmd`; an explicit endpoint also drops the
|
||||
/// inherited `DOCKER_HOST` / `DOCKER_CONTEXT`, which would conflict.
|
||||
pub(crate) fn apply(&self, cmd: &mut Command) {
|
||||
if *self != Self::Default {
|
||||
cmd.env_remove("DOCKER_HOST").env_remove("DOCKER_CONTEXT").env_remove("DOCKER_TLS_VERIFY");
|
||||
}
|
||||
cmd.args(self.global_args());
|
||||
}
|
||||
|
||||
/// The same selection as environment variables, for docker command lines run by
|
||||
/// others (run configurations). An empty value clears an inherited variable: the
|
||||
/// CLI treats `DOCKER_HOST=""` as unset.
|
||||
pub fn env_vars(&self) -> BTreeMap<String, String> {
|
||||
let mut env = BTreeMap::new();
|
||||
let mut set = |k: &str, v: String| {
|
||||
env.insert(k.to_owned(), v);
|
||||
};
|
||||
match self {
|
||||
Self::Default => {}
|
||||
Self::Socket { path } => {
|
||||
let path = path.trim();
|
||||
set("DOCKER_HOST", if path.contains("://") { path.to_owned() } else { format!("unix://{path}") });
|
||||
set("DOCKER_CONTEXT", String::new());
|
||||
set("DOCKER_TLS_VERIFY", String::new());
|
||||
}
|
||||
Self::Tcp { host, tls_cert_dir } => {
|
||||
let host = host.trim();
|
||||
set("DOCKER_HOST", if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") });
|
||||
set("DOCKER_CONTEXT", String::new());
|
||||
match tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
|
||||
// `DOCKER_CERT_PATH` holds `ca.pem`, `cert.pem` and `key.pem`, as `global_args` expects.
|
||||
Some(dir) => {
|
||||
set("DOCKER_TLS_VERIFY", "1".into());
|
||||
set("DOCKER_CERT_PATH", dir.to_owned());
|
||||
}
|
||||
None => set("DOCKER_TLS_VERIFY", String::new()),
|
||||
}
|
||||
}
|
||||
Self::Context { name } => {
|
||||
set("DOCKER_CONTEXT", name.trim().to_owned());
|
||||
set("DOCKER_HOST", String::new());
|
||||
set("DOCKER_TLS_VERIFY", String::new());
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> IdeResult<()> {
|
||||
let empty = match self {
|
||||
Self::Default => false,
|
||||
Self::Socket { path } => path.trim().is_empty(),
|
||||
Self::Tcp { host, .. } => host.trim().is_empty(),
|
||||
Self::Context { name } => name.trim().is_empty() || name.trim().starts_with('-'),
|
||||
};
|
||||
if empty { Err(IdeError::invalid("the Docker connection needs a socket, host or context")) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub endpoint: DockerEndpoint,
|
||||
}
|
||||
|
||||
/// A registry to log in to (Docker Hub, GHCR, a private one). The password is stored separately.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerRegistry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `docker.io`, `ghcr.io`, `registry.example.com:5000`.
|
||||
pub address: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerSettings {
|
||||
#[serde(default = "default_connections")]
|
||||
pub connections: Vec<DockerConnection>,
|
||||
#[serde(default)]
|
||||
pub registries: Vec<DockerRegistry>,
|
||||
}
|
||||
|
||||
impl Default for DockerSettings {
|
||||
fn default() -> Self {
|
||||
Self { connections: default_connections(), registries: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Before anything is configured the CLI's own daemon is shown, as before connections existed.
|
||||
fn default_connections() -> Vec<DockerConnection> {
|
||||
vec![DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default }]
|
||||
}
|
||||
|
||||
const SETTINGS_FILE: &str = "docker.json";
|
||||
const PASSWORDS_FILE: &str = "docker-passwords.json";
|
||||
|
||||
pub fn load_settings(data_dir: &Path) -> DockerSettings {
|
||||
match std::fs::read(data_dir.join(SETTINGS_FILE)) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("{SETTINGS_FILE}: {e}");
|
||||
DockerSettings::default()
|
||||
}),
|
||||
Err(_) => DockerSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_settings(data_dir: &Path, settings: &DockerSettings) -> IdeResult<()> {
|
||||
for c in &settings.connections {
|
||||
if c.id.is_empty() || c.name.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every Docker connection needs a name"));
|
||||
}
|
||||
c.endpoint.validate()?;
|
||||
}
|
||||
for r in &settings.registries {
|
||||
if r.id.is_empty() || r.address.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every Docker registry needs an address"));
|
||||
}
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(settings).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
write_file(&data_dir.join(SETTINGS_FILE), &bytes, false)?;
|
||||
// Forget passwords of registries that are gone.
|
||||
let mut passwords = read_passwords(data_dir);
|
||||
let before = passwords.len();
|
||||
passwords.retain(|id, _| settings.registries.iter().any(|r| &r.id == id));
|
||||
if passwords.len() != before { write_passwords(data_dir, &passwords) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn read_passwords(data_dir: &Path) -> BTreeMap<String, String> {
|
||||
std::fs::read(data_dir.join(PASSWORDS_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_passwords(data_dir: &Path, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
write_file(&data_dir.join(PASSWORDS_FILE), &bytes, true)
|
||||
}
|
||||
|
||||
pub fn registry_password(data_dir: &Path, id: &str) -> Option<String> {
|
||||
read_passwords(data_dir).remove(id)
|
||||
}
|
||||
|
||||
/// Saves (or with `None` forgets) the password of registry `id`.
|
||||
pub fn set_registry_password(data_dir: &Path, id: &str, password: Option<&str>) -> IdeResult<()> {
|
||||
let mut all = read_passwords(data_dir);
|
||||
match password {
|
||||
Some(p) => all.insert(id.to_owned(), p.to_owned()),
|
||||
None => all.remove(id),
|
||||
};
|
||||
write_passwords(data_dir, &all)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8], private: bool) -> IdeResult<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
std::io::Write::write_all(&mut options.open(&tmp)?, bytes)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical registry host: no scheme or path; Docker Hub's aliases become `docker.io`.
|
||||
pub fn registry_host(address: &str) -> String {
|
||||
let a = address.trim();
|
||||
let a = a.split_once("://").map_or(a, |(_, rest)| rest);
|
||||
let host = a.split('/').next().unwrap_or_default().to_ascii_lowercase();
|
||||
match host.as_str() {
|
||||
"index.docker.io" | "registry-1.docker.io" | "registry.hub.docker.com" | "hub.docker.com" => "docker.io".into(),
|
||||
_ => host,
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry host an image reference pulls from (`postgres:16` → `docker.io`).
|
||||
pub fn reference_registry(reference: &str) -> String {
|
||||
match reference.split_once('/') {
|
||||
Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => registry_host(first),
|
||||
_ => "docker.io".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials for a `docker login`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegistryAuth {
|
||||
pub address: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// The saved registry (with a password) matching `reference`, if any.
|
||||
pub fn auth_for(data_dir: &Path, reference: &str) -> Option<RegistryAuth> {
|
||||
let host = reference_registry(reference);
|
||||
let settings = load_settings(data_dir);
|
||||
let registry =
|
||||
settings.registries.into_iter().find(|r| registry_host(&r.address) == host && !r.username.is_empty())?;
|
||||
let password = registry_password(data_dir, ®istry.id)?;
|
||||
Some(RegistryAuth { address: registry.address, username: registry.username, password })
|
||||
}
|
||||
|
||||
/// A throwaway docker config directory: logins happen there, never in the user's
|
||||
/// `~/.docker/config.json`. Contexts are copied so a context endpoint still resolves.
|
||||
pub(crate) struct TempConfig {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TempConfig {
|
||||
pub fn new() -> IdeResult<Self> {
|
||||
let nanos =
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||
let dir = std::env::temp_dir().join(format!("openidea-docker-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
if let Some(contexts) = user_config_dir().map(|d| d.join("contexts")).filter(|d| d.is_dir()) {
|
||||
let _ = copy_dir(&contexts, &dir.join("contexts"));
|
||||
}
|
||||
Ok(Self { dir })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempConfig {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn user_config_dir() -> Option<PathBuf> {
|
||||
if let Some(dir) = std::env::var_os("DOCKER_CONFIG") {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })?;
|
||||
Some(PathBuf::from(home).join(".docker"))
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for e in std::fs::read_dir(from)?.flatten() {
|
||||
let target = to.join(e.file_name());
|
||||
if e.file_type()?.is_dir() {
|
||||
copy_dir(&e.path(), &target)?;
|
||||
} else {
|
||||
std::fs::copy(e.path(), target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_args() {
|
||||
assert!(DockerEndpoint::Default.global_args().is_empty());
|
||||
assert_eq!(
|
||||
DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.global_args(),
|
||||
["--host", "unix:///var/run/docker.sock"]
|
||||
);
|
||||
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.global_args();
|
||||
assert_eq!(tls[..3], ["--host", "tcp://build:2376", "--tlsverify"]);
|
||||
assert!(tls.contains(&Path::new("/certs").join("key.pem").to_string_lossy().into_owned()));
|
||||
assert_eq!(
|
||||
DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: Some(" ".into()) }.global_args(),
|
||||
["--host", "ssh://me@box"]
|
||||
);
|
||||
assert_eq!(DockerEndpoint::Context { name: "orbstack".into() }.global_args(), ["--context", "orbstack"]);
|
||||
assert!(DockerEndpoint::Context { name: "--x".into() }.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_env() {
|
||||
assert!(DockerEndpoint::Default.env_vars().is_empty());
|
||||
let socket = DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.env_vars();
|
||||
assert_eq!(socket["DOCKER_HOST"], "unix:///var/run/docker.sock");
|
||||
assert_eq!(socket["DOCKER_CONTEXT"], "");
|
||||
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.env_vars();
|
||||
assert_eq!(
|
||||
(tls["DOCKER_HOST"].as_str(), tls["DOCKER_TLS_VERIFY"].as_str(), tls["DOCKER_CERT_PATH"].as_str()),
|
||||
("tcp://build:2376", "1", "/certs")
|
||||
);
|
||||
let ssh = DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: None }.env_vars();
|
||||
assert_eq!((ssh["DOCKER_HOST"].as_str(), ssh["DOCKER_TLS_VERIFY"].as_str()), ("ssh://me@box", ""));
|
||||
assert!(!ssh.contains_key("DOCKER_CERT_PATH"));
|
||||
let ctx = DockerEndpoint::Context { name: " orbstack ".into() }.env_vars();
|
||||
assert_eq!((ctx["DOCKER_CONTEXT"].as_str(), ctx["DOCKER_HOST"].as_str()), ("orbstack", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registries_match_references() {
|
||||
assert_eq!(reference_registry("postgres:16"), "docker.io");
|
||||
assert_eq!(reference_registry("library/postgres"), "docker.io");
|
||||
assert_eq!(reference_registry("ghcr.io/org/app:1"), "ghcr.io");
|
||||
assert_eq!(reference_registry("localhost:5000/app"), "localhost:5000");
|
||||
assert_eq!(registry_host("https://index.docker.io/v1/"), "docker.io");
|
||||
assert_eq!(registry_host("GHCR.io"), "ghcr.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_and_passwords() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
assert_eq!(load_settings(dir).connections[0].endpoint, DockerEndpoint::Default);
|
||||
let settings = DockerSettings {
|
||||
connections: vec![DockerConnection {
|
||||
id: "c1".into(),
|
||||
name: "Build box".into(),
|
||||
endpoint: DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None },
|
||||
}],
|
||||
registries: vec![DockerRegistry {
|
||||
id: "r1".into(),
|
||||
name: "GHCR".into(),
|
||||
address: "ghcr.io".into(),
|
||||
username: "me".into(),
|
||||
}],
|
||||
};
|
||||
save_settings(dir, &settings).unwrap();
|
||||
assert_eq!(load_settings(dir), settings);
|
||||
set_registry_password(dir, "r1", Some("s3cret")).unwrap();
|
||||
let auth = auth_for(dir, "ghcr.io/org/app").unwrap();
|
||||
assert_eq!((auth.username.as_str(), auth.password.as_str()), ("me", "s3cret"));
|
||||
assert!(auth_for(dir, "postgres").is_none());
|
||||
save_settings(dir, &DockerSettings { registries: Vec::new(), ..settings }).unwrap();
|
||||
assert_eq!(registry_password(dir, "r1"), None);
|
||||
// An empty list stays empty (the default connection is only for a missing file).
|
||||
save_settings(dir, &DockerSettings { connections: Vec::new(), registries: Vec::new() }).unwrap();
|
||||
assert!(load_settings(dir).connections.is_empty());
|
||||
}
|
||||
}
|
||||
+109
-28
@@ -4,9 +4,11 @@
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod connection;
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use connection::*;
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -61,21 +63,38 @@ pub fn docker_executable() -> Option<PathBuf> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Environment for docker command lines a run configuration launches: the endpoint's
|
||||
/// variables plus a `PATH` that starts with docker's directory (GUI apps on macOS
|
||||
/// lack the usual install locations), so `docker` / `docker compose` resolve.
|
||||
pub fn run_env(ep: &DockerEndpoint) -> std::collections::BTreeMap<String, String> {
|
||||
let mut env = ep.env_vars();
|
||||
if let Some(dir) = docker_executable().as_deref().and_then(Path::parent) {
|
||||
let current = std::env::var_os("PATH").unwrap_or_default();
|
||||
let mut dirs: Vec<PathBuf> = vec![dir.to_path_buf()];
|
||||
dirs.extend(std::env::split_paths(¤t).filter(|d| d != dir));
|
||||
if let Ok(path) = std::env::join_paths(dirs) {
|
||||
env.insert("PATH".into(), path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
fn binary() -> IdeResult<PathBuf> {
|
||||
docker_executable()
|
||||
.ok_or_else(|| IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH"))
|
||||
}
|
||||
|
||||
fn command(args: &[&str]) -> IdeResult<Command> {
|
||||
fn command(ep: &DockerEndpoint, args: &[&str]) -> IdeResult<Command> {
|
||||
let mut cmd = Command::new(binary()?);
|
||||
ep.apply(&mut cmd);
|
||||
cmd.args(args).env("DOCKER_CLI_HINTS", "false").env("NO_COLOR", "1");
|
||||
tracing::debug!(?args, "docker");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Runs a docker command, mapping a non-zero exit to an error with docker's message.
|
||||
fn run(args: &[&str], timeout: Duration) -> IdeResult<String> {
|
||||
let out = process::run(command(args)?, timeout, None)?;
|
||||
fn run(ep: &DockerEndpoint, args: &[&str], timeout: Duration) -> IdeResult<String> {
|
||||
let out = process::run(command(ep, args)?, timeout, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
@@ -94,8 +113,8 @@ fn failure(args: &[&str], stderr: &str, stdout: &str) -> IdeError {
|
||||
}
|
||||
|
||||
/// Whether docker can be used right now.
|
||||
pub fn availability() -> DockerAvailability {
|
||||
let Ok(cmd) = command(&["version", "--format", "{{json .}}"]) else {
|
||||
pub fn availability(ep: &DockerEndpoint) -> DockerAvailability {
|
||||
let Ok(cmd) = command(ep, &["version", "--format", "{{json .}}"]) else {
|
||||
return DockerAvailability::NotInstalled;
|
||||
};
|
||||
match process::run(cmd, Duration::from_secs(15), None) {
|
||||
@@ -105,8 +124,8 @@ pub fn availability() -> DockerAvailability {
|
||||
}
|
||||
|
||||
/// Availability plus containers, images, volumes and networks (listed in parallel).
|
||||
pub fn snapshot() -> DockerSnapshot {
|
||||
let availability = availability();
|
||||
pub fn snapshot(ep: &DockerEndpoint) -> DockerSnapshot {
|
||||
let availability = availability(ep);
|
||||
let executable = docker_executable().map(|p| ide_core::ipc_path::to_ipc(&p));
|
||||
let mut snap = DockerSnapshot {
|
||||
availability,
|
||||
@@ -116,16 +135,17 @@ pub fn snapshot() -> DockerSnapshot {
|
||||
volumes: Vec::new(),
|
||||
networks: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
global_args: ep.global_args(),
|
||||
};
|
||||
if !matches!(snap.availability, DockerAvailability::Ready { .. }) {
|
||||
return snap;
|
||||
}
|
||||
const FMT: &str = "{{json .}}";
|
||||
let (containers, images, volumes, networks) = std::thread::scope(|s| {
|
||||
let c = s.spawn(|| run(&["ps", "--all", "--no-trunc", "--format", FMT], QUICK));
|
||||
let i = s.spawn(|| run(&["image", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let v = s.spawn(|| run(&["volume", "ls", "--format", FMT], QUICK));
|
||||
let n = s.spawn(|| run(&["network", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let c = s.spawn(|| run(ep, &["ps", "--all", "--no-trunc", "--format", FMT], QUICK));
|
||||
let i = s.spawn(|| run(ep, &["image", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let v = s.spawn(|| run(ep, &["volume", "ls", "--format", FMT], QUICK));
|
||||
let n = s.spawn(|| run(ep, &["network", "ls", "--no-trunc", "--format", FMT], QUICK));
|
||||
let join = |h: std::thread::ScopedJoinHandle<'_, IdeResult<String>>| {
|
||||
h.join().unwrap_or_else(|_| Err(IdeError::internal("docker listing panicked")))
|
||||
};
|
||||
@@ -178,13 +198,13 @@ impl ContainerOp {
|
||||
}
|
||||
|
||||
/// Starts / stops / … the containers `ids`.
|
||||
pub fn container_op(ids: &[String], op: ContainerOp) -> IdeResult<()> {
|
||||
pub fn container_op(ep: &DockerEndpoint, ids: &[String], op: ContainerOp) -> IdeResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut args = vec!["container", op.verb()];
|
||||
args.extend(ids.iter().map(String::as_str));
|
||||
run(&args, LIFECYCLE).map(drop)
|
||||
run(ep, &args, LIFECYCLE).map(drop)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -208,7 +228,7 @@ impl DockerObject {
|
||||
}
|
||||
|
||||
/// `docker <object> rm` (`force`: stop running containers / untag used images first).
|
||||
pub fn remove(object: DockerObject, ids: &[String], force: bool) -> IdeResult<()> {
|
||||
pub fn remove(ep: &DockerEndpoint, object: DockerObject, ids: &[String], force: bool) -> IdeResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -217,12 +237,12 @@ pub fn remove(object: DockerObject, ids: &[String], force: bool) -> IdeResult<()
|
||||
args.push("--force");
|
||||
}
|
||||
args.extend(ids.iter().map(String::as_str));
|
||||
run(&args, LIFECYCLE).map(drop)
|
||||
run(ep, &args, LIFECYCLE).map(drop)
|
||||
}
|
||||
|
||||
/// `docker inspect` of one object, pretty-printed.
|
||||
pub fn inspect(object: DockerObject, id: &str) -> IdeResult<String> {
|
||||
let out = run(&["inspect", "--type", object.noun(), id], QUICK)?;
|
||||
pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult<String> {
|
||||
let out = run(ep, &["inspect", "--type", object.noun(), id], QUICK)?;
|
||||
match serde_json::from_str::<serde_json::Value>(&out) {
|
||||
// A one-element array for a single id: show the object itself.
|
||||
Ok(serde_json::Value::Array(mut items)) if items.len() == 1 => {
|
||||
@@ -236,6 +256,7 @@ pub fn inspect(object: DockerObject, id: &str) -> IdeResult<String> {
|
||||
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
|
||||
/// until it ends or `cancel` fires.
|
||||
pub fn logs(
|
||||
ep: &DockerEndpoint,
|
||||
id: &str,
|
||||
tail: u32,
|
||||
follow: bool,
|
||||
@@ -252,16 +273,16 @@ pub fn logs(
|
||||
args.push("--timestamps");
|
||||
}
|
||||
args.push(id);
|
||||
let (ok, stderr) = process::stream(command(&args)?, cancel, &mut |stream, text| {
|
||||
let (ok, stderr) = process::stream(command(ep, &args)?, cancel, &mut |stream, text| {
|
||||
on_line(DockerLogLine { stderr: stream == Stream::Stderr, text })
|
||||
})?;
|
||||
if ok { Ok(()) } else { Err(failure(&args, &stderr.join("\n"), "")) }
|
||||
}
|
||||
|
||||
/// Follows `docker events` until it ends (daemon gone) or `cancel` fires.
|
||||
pub fn watch_events(cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent)) -> IdeResult<()> {
|
||||
pub fn watch_events(ep: &DockerEndpoint, cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent)) -> IdeResult<()> {
|
||||
let args = ["events", "--format", "{{json .}}"];
|
||||
let (ok, stderr) = process::stream(command(&args)?, cancel, &mut |stream, line| {
|
||||
let (ok, stderr) = process::stream(command(ep, &args)?, cancel, &mut |stream, line| {
|
||||
if stream == Stream::Stdout
|
||||
&& let Some(e) = parse_event(&line)
|
||||
{
|
||||
@@ -272,9 +293,19 @@ pub fn watch_events(cancel: &CancellationToken, on_event: &dyn Fn(DockerEvent))
|
||||
}
|
||||
|
||||
/// Runs a long docker command (pull, prune, compose…), streaming its output lines.
|
||||
fn run_streaming(args: &[&str], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
fn run_streaming(
|
||||
ep: &DockerEndpoint,
|
||||
config: Option<&Path>,
|
||||
args: &[&str],
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
on_output(format!("docker {}", args.join(" ")));
|
||||
let (ok, stderr) = process::stream(command(args)?, cancel, &mut |_, line| {
|
||||
let mut cmd = command(ep, args)?;
|
||||
if let Some(dir) = config {
|
||||
cmd.env("DOCKER_CONFIG", dir);
|
||||
}
|
||||
let (ok, stderr) = process::stream(cmd, cancel, &mut |_, line| {
|
||||
if !line.trim().is_empty() {
|
||||
on_output(line);
|
||||
}
|
||||
@@ -282,13 +313,57 @@ fn run_streaming(args: &[&str], cancel: &CancellationToken, on_output: &dyn Fn(S
|
||||
if ok { Ok(()) } else { Err(failure(args, &stderr.join("\n"), "")) }
|
||||
}
|
||||
|
||||
/// `docker pull <reference>`.
|
||||
pub fn pull(reference: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
/// `docker pull <reference>`; with `auth` it logs in to the registry first, in a
|
||||
/// throwaway config directory (the user's docker config stays untouched).
|
||||
pub fn pull(
|
||||
ep: &DockerEndpoint,
|
||||
reference: &str,
|
||||
auth: Option<&RegistryAuth>,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let reference = reference.trim();
|
||||
if reference.is_empty() || reference.starts_with('-') {
|
||||
return Err(IdeError::invalid("enter an image name, e.g. postgres:16"));
|
||||
}
|
||||
run_streaming(&["pull", reference], cancel, on_output)
|
||||
let Some(auth) = auth else { return run_streaming(ep, None, &["pull", reference], cancel, on_output) };
|
||||
let config = TempConfig::new()?;
|
||||
on_output(format!("Logging in to {} as {}", auth.address, auth.username));
|
||||
login_in(ep, &config.dir, auth, Some(cancel))?;
|
||||
run_streaming(ep, Some(&config.dir), &["pull", reference], cancel, on_output)
|
||||
}
|
||||
|
||||
/// `docker login` with `auth`; returns docker's answer (`Login Succeeded`). Nothing is
|
||||
/// stored in the user's docker config.
|
||||
pub fn login(ep: &DockerEndpoint, auth: &RegistryAuth) -> IdeResult<String> {
|
||||
let config = TempConfig::new()?;
|
||||
login_in(ep, &config.dir, auth, None)
|
||||
}
|
||||
|
||||
fn login_in(
|
||||
ep: &DockerEndpoint,
|
||||
config: &Path,
|
||||
auth: &RegistryAuth,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<String> {
|
||||
let host = registry_host(&auth.address);
|
||||
let user = auth.username.trim();
|
||||
if host.is_empty() || host.starts_with('-') || user.is_empty() || user.starts_with('-') {
|
||||
return Err(IdeError::invalid("the registry needs an address and a username"));
|
||||
}
|
||||
let mut args = vec!["login", "--username", user, "--password-stdin"];
|
||||
// Docker Hub is the CLI's default server.
|
||||
if host != "docker.io" {
|
||||
args.push(&host);
|
||||
}
|
||||
let mut cmd = command(ep, &args)?;
|
||||
cmd.env("DOCKER_CONFIG", config);
|
||||
let out = process::run_with_input(cmd, QUICK, cancel, Some(&auth.password))?;
|
||||
if out.success {
|
||||
Ok(out.stdout.lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("Login Succeeded").to_owned())
|
||||
} else {
|
||||
Err(failure(&args, &out.stderr, &out.stdout))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -306,7 +381,12 @@ pub enum PruneTarget {
|
||||
}
|
||||
|
||||
/// `docker <object> prune --force`.
|
||||
pub fn prune(target: PruneTarget, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
pub fn prune(
|
||||
ep: &DockerEndpoint,
|
||||
target: PruneTarget,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let args: &[&str] = match target {
|
||||
PruneTarget::Containers => &["container", "prune", "--force"],
|
||||
PruneTarget::Images => &["image", "prune", "--force"],
|
||||
@@ -315,7 +395,7 @@ pub fn prune(target: PruneTarget, cancel: &CancellationToken, on_output: &dyn Fn
|
||||
PruneTarget::Networks => &["network", "prune", "--force"],
|
||||
PruneTarget::System => &["system", "prune", "--force"],
|
||||
};
|
||||
run_streaming(args, cancel, on_output)
|
||||
run_streaming(ep, None, args, cancel, on_output)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -332,6 +412,7 @@ pub enum ComposeOp {
|
||||
/// labels. `files` / `working_dir` (from the labels too) are passed when they still
|
||||
/// exist, so commands that need the model keep working.
|
||||
pub fn compose_op(
|
||||
ep: &DockerEndpoint,
|
||||
project: &str,
|
||||
files: &[String],
|
||||
working_dir: Option<&str>,
|
||||
@@ -357,5 +438,5 @@ pub fn compose_op(
|
||||
ComposeOp::Restart => "restart",
|
||||
ComposeOp::Down => "down",
|
||||
});
|
||||
run_streaming(&args, cancel, on_output)
|
||||
run_streaming(ep, None, &args, cancel, on_output)
|
||||
}
|
||||
@@ -105,6 +105,9 @@ pub struct DockerSnapshot {
|
||||
pub networks: Vec<DockerNetwork>,
|
||||
/// Lists that failed although the daemon answered (shown, not fatal).
|
||||
pub errors: Vec<String>,
|
||||
/// Global CLI options selecting the connection's daemon (for `docker exec` in a terminal).
|
||||
#[serde(default)]
|
||||
pub global_args: Vec<String>,
|
||||
}
|
||||
|
||||
/// One relevant `docker events` entry.
|
||||
|
||||
@@ -60,9 +60,26 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
run_with_input(cmd, timeout, cancel, None)
|
||||
}
|
||||
|
||||
/// [`run`] with `input` written to the child's stdin (e.g. `--password-stdin`).
|
||||
pub(crate) fn run_with_input(
|
||||
mut cmd: Command,
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
input: Option<&str>,
|
||||
) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
if input.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
if let (Some(text), Some(mut stdin)) = (input, child.stdin.take()) {
|
||||
// Dropping `stdin` afterwards closes it, so the CLI sees EOF.
|
||||
let _ = std::io::Write::write_all(&mut stdin, text.as_bytes());
|
||||
}
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
@@ -181,10 +181,10 @@ impl IndexData {
|
||||
Some(old)
|
||||
}
|
||||
|
||||
/// Removes every file at or below `dir`; returns their relative paths.
|
||||
pub(crate) fn remove_tree(&mut self, dir: &Path) -> Vec<String> {
|
||||
/// Removes every file at or below `dir`; returns their paths.
|
||||
pub(crate) fn remove_tree(&mut self, dir: &Path) -> Vec<PathBuf> {
|
||||
let doomed: Vec<PathBuf> = self.by_path.keys().filter(|p| p.starts_with(dir)).cloned().collect();
|
||||
doomed.iter().filter_map(|p| self.remove(p)).map(|e| e.rel.into_string()).collect()
|
||||
doomed.into_iter().filter(|p| self.remove(p).is_some()).collect()
|
||||
}
|
||||
|
||||
/// Releases spare capacity (after large deletions).
|
||||
@@ -235,7 +235,7 @@ mod tests {
|
||||
assert_eq!((d.file_count, d.symbol_count), (1, 5));
|
||||
d.insert(entry("d.rs", 0));
|
||||
assert_eq!(d.files.len(), 2, "slot reused");
|
||||
assert_eq!(d.remove_tree(Path::new("/r/a")), vec!["a/b.rs".to_owned()]);
|
||||
assert_eq!(d.remove_tree(Path::new("/r/a")), vec![PathBuf::from("/r/a/b.rs")]);
|
||||
assert_eq!((d.file_count, d.symbol_count), (1, 0));
|
||||
let e = d.get(Path::new("/r/d.rs")).unwrap();
|
||||
assert_eq!((e.name(), e.dir()), ("d.rs", ""));
|
||||
|
||||
+136
-26
@@ -1,5 +1,6 @@
|
||||
//! Which paths belong in the index: `.gitignore`/`.ignore` rules (via the
|
||||
//! `ignore` crate) plus the user's `files.exclude` globs.
|
||||
//! `ignore` crate) plus the user's `files.exclude` globs, below the project root
|
||||
//! and the extra content roots (Project Structure).
|
||||
|
||||
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
@@ -14,10 +15,45 @@ const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj"];
|
||||
|
||||
pub(crate) struct PathFilter {
|
||||
root: PathBuf,
|
||||
/// Content roots outside `root`, indexed as `<folder name>/<path>`.
|
||||
extra: Vec<ExtraRoot>,
|
||||
excludes: GlobSet,
|
||||
/// Root-level ignore files, for paths reported by the watcher (the walker
|
||||
/// applies nested ignore files itself).
|
||||
gitignore: Gitignore,
|
||||
/// What the filter was built from: `(extra roots, exclude globs)`.
|
||||
config: (Vec<PathBuf>, Vec<String>),
|
||||
}
|
||||
|
||||
struct ExtraRoot {
|
||||
path: PathBuf,
|
||||
/// Shown before the paths inside it (`shared-lib/src/x.rs`).
|
||||
name: String,
|
||||
gitignore: Gitignore,
|
||||
}
|
||||
|
||||
fn root_ignores(root: &Path) -> Gitignore {
|
||||
let mut gi = GitignoreBuilder::new(root);
|
||||
for f in [".gitignore", ".ignore", ".git/info/exclude"] {
|
||||
let path = root.join(f);
|
||||
if path.is_file()
|
||||
&& let Some(e) = gi.add(&path)
|
||||
{
|
||||
tracing::debug!("{}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
gi.build().unwrap_or_else(|_| Gitignore::empty())
|
||||
}
|
||||
|
||||
fn rel_string(rel: &Path) -> String {
|
||||
let mut out = String::new();
|
||||
for c in rel.components() {
|
||||
if !out.is_empty() {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(&c.as_os_str().to_string_lossy());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -32,9 +68,9 @@ pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 {
|
||||
}
|
||||
|
||||
impl PathFilter {
|
||||
pub(crate) fn new(root: &Path, excludes: &[String]) -> Self {
|
||||
pub(crate) fn new(root: &Path, extra_roots: &[PathBuf], exclude_globs: &[String]) -> Self {
|
||||
let mut set = GlobSetBuilder::new();
|
||||
for raw in excludes {
|
||||
for raw in exclude_globs {
|
||||
let p = raw.trim().trim_end_matches('/');
|
||||
let anchored = p.starts_with('/');
|
||||
let p = p.trim_start_matches("./").trim_start_matches('/');
|
||||
@@ -55,34 +91,76 @@ impl PathFilter {
|
||||
}
|
||||
}
|
||||
let excludes = set.build().unwrap_or_else(|_| GlobSet::empty());
|
||||
let mut gi = GitignoreBuilder::new(root);
|
||||
for f in [".gitignore", ".ignore", ".git/info/exclude"] {
|
||||
let path = root.join(f);
|
||||
if path.is_file()
|
||||
&& let Some(e) = gi.add(&path)
|
||||
{
|
||||
tracing::debug!("{}: {e}", path.display());
|
||||
let mut extra: Vec<ExtraRoot> = Vec::new();
|
||||
for r in extra_roots {
|
||||
let path = dunce::canonicalize(r).unwrap_or_else(|_| r.clone());
|
||||
let nested = path.starts_with(root)
|
||||
|| root.starts_with(&path)
|
||||
|| extra.iter().any(|e| path.starts_with(&e.path) || e.path.starts_with(&path));
|
||||
if nested || !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
extra.push(ExtraRoot { gitignore: root_ignores(&path), name, path });
|
||||
}
|
||||
let gitignore = gi.build().unwrap_or_else(|_| Gitignore::empty());
|
||||
Self { root: root.to_owned(), excludes, gitignore }
|
||||
Self {
|
||||
root: root.to_owned(),
|
||||
extra,
|
||||
excludes,
|
||||
gitignore: root_ignores(root),
|
||||
config: (extra_roots.to_vec(), exclude_globs.to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the filter was built from these roots and globs.
|
||||
pub(crate) fn same_config(&self, extra_roots: &[PathBuf], excludes: &[String]) -> bool {
|
||||
self.config.0 == extra_roots && self.config.1 == excludes
|
||||
}
|
||||
|
||||
pub(crate) fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// `/`-separated path relative to the root.
|
||||
pub(crate) fn rel(&self, path: &Path) -> Option<String> {
|
||||
let rel = path.strip_prefix(&self.root).ok()?;
|
||||
let mut out = String::new();
|
||||
for c in rel.components() {
|
||||
if !out.is_empty() {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(&c.as_os_str().to_string_lossy());
|
||||
/// The project root, then the extra content roots.
|
||||
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
|
||||
std::iter::once(self.root.as_path()).chain(self.extra.iter().map(|e| e.path.as_path()))
|
||||
}
|
||||
|
||||
/// The root containing `path`, its display prefix (`None` for the project root) and ignore rules.
|
||||
fn base_of(&self, path: &Path) -> Option<(&Path, Option<&str>, &Gitignore)> {
|
||||
if path.starts_with(&self.root) {
|
||||
return Some((&self.root, None, &self.gitignore));
|
||||
}
|
||||
(!out.is_empty()).then_some(out)
|
||||
self.extra
|
||||
.iter()
|
||||
.find(|e| path.starts_with(&e.path))
|
||||
.map(|e| (e.path.as_path(), Some(e.name.as_str()), &e.gitignore))
|
||||
}
|
||||
|
||||
/// Whether `path` is (at or) below one of the roots.
|
||||
pub(crate) fn contains(&self, path: &Path) -> bool {
|
||||
self.base_of(path).is_some()
|
||||
}
|
||||
|
||||
/// `/`-separated path relative to its root, as displayed; paths in extra
|
||||
/// content roots start with the root's folder name.
|
||||
pub(crate) fn rel(&self, path: &Path) -> Option<String> {
|
||||
let (base, prefix, _) = self.base_of(path)?;
|
||||
let rel = rel_string(path.strip_prefix(base).ok()?);
|
||||
if rel.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(match prefix {
|
||||
Some(p) => format!("{p}/{rel}"),
|
||||
None => rel,
|
||||
})
|
||||
}
|
||||
|
||||
/// Key of `path` in the persistent store: only files below the project root
|
||||
/// are persisted (extra content roots are rescanned when the project opens).
|
||||
pub(crate) fn store_key(&self, path: &Path) -> Option<String> {
|
||||
let rel = rel_string(path.strip_prefix(&self.root).ok()?);
|
||||
(!rel.is_empty()).then_some(rel)
|
||||
}
|
||||
|
||||
fn excluded_rel(&self, rel: &str) -> bool {
|
||||
@@ -96,7 +174,11 @@ impl PathFilter {
|
||||
/// Whether a path reported by the watcher should be ignored (outside the
|
||||
/// root, inside an excluded/ignored directory, or ignored itself).
|
||||
pub(crate) fn is_excluded(&self, path: &Path, is_dir: bool) -> bool {
|
||||
let Some(rel) = self.rel(path) else { return true };
|
||||
let Some((base, _, gitignore)) = self.base_of(path) else { return true };
|
||||
let rel = path.strip_prefix(base).map(rel_string).unwrap_or_default();
|
||||
if rel.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut prefix = String::new();
|
||||
for seg in rel.split('/') {
|
||||
if Self::skip_name(seg) {
|
||||
@@ -110,14 +192,15 @@ impl PathFilter {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.gitignore.matched_path_or_any_parents(path, is_dir).is_ignore()
|
||||
gitignore.matched_path_or_any_parents(path, is_dir).is_ignore()
|
||||
}
|
||||
|
||||
/// All indexable files at or below `dir`, walked in parallel.
|
||||
/// Stops early (returning what was found so far) once `cancel` is set.
|
||||
pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec<Found> {
|
||||
let (tx, rx) = mpsc::channel::<Vec<Found>>();
|
||||
let root = self.root.clone();
|
||||
let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() };
|
||||
let root = root.to_path_buf();
|
||||
let excludes = self.excludes.clone();
|
||||
let walker = WalkBuilder::new(dir)
|
||||
.hidden(false)
|
||||
@@ -207,7 +290,7 @@ mod tests {
|
||||
touch(&root, f);
|
||||
}
|
||||
std::fs::write(root.join(".gitignore"), "build/\n").unwrap();
|
||||
let filter = PathFilter::new(&root, &["**/node_modules".into(), "target".into(), "docs/**".into()]);
|
||||
let filter = PathFilter::new(&root, &[], &["**/node_modules".into(), "target".into(), "docs/**".into()]);
|
||||
let mut found: Vec<String> =
|
||||
filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect();
|
||||
found.sort();
|
||||
@@ -220,4 +303,31 @@ mod tests {
|
||||
assert!(filter.is_excluded(Path::new("/elsewhere/x.rs"), false));
|
||||
assert!(!filter.is_excluded(&root.join("src/new.rs"), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_content_roots_are_walked_and_prefixed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(dir.path()).unwrap();
|
||||
let (root, shared) = (base.join("app"), base.join("shared-lib"));
|
||||
for f in ["app/src/a.rs", "shared-lib/src/b.rs", "shared-lib/target/gen.rs", "shared-lib/out/x.rs"] {
|
||||
touch(&base, f);
|
||||
}
|
||||
std::fs::write(shared.join(".gitignore"), "out/\n").unwrap();
|
||||
let filter = PathFilter::new(&root, &[shared.clone(), root.join("src")], &["target".into()]);
|
||||
assert_eq!(filter.roots().count(), 2, "roots inside the project are not extra roots");
|
||||
let mut found: Vec<String> = filter
|
||||
.roots()
|
||||
.flat_map(|r| filter.walk(r, &AtomicBool::new(false)))
|
||||
.filter_map(|f| filter.rel(&f.path))
|
||||
.collect();
|
||||
found.sort();
|
||||
assert_eq!(found, ["shared-lib/.gitignore", "shared-lib/src/b.rs", "src/a.rs"]);
|
||||
assert_eq!(filter.store_key(&shared.join("src/b.rs")), None, "extra roots are not persisted");
|
||||
assert_eq!(filter.store_key(&root.join("src/a.rs")).as_deref(), Some("src/a.rs"));
|
||||
assert!(!filter.is_excluded(&shared.join("src/new.rs"), false));
|
||||
assert!(filter.is_excluded(&shared.join("target/y.rs"), false));
|
||||
assert!(filter.is_excluded(&shared.join("out/y.rs"), false));
|
||||
assert!(filter.same_config(&[shared.clone(), root.join("src")], &["target".into()]));
|
||||
assert!(!filter.same_config(&[shared], &["target".into()]));
|
||||
}
|
||||
}
|
||||
@@ -67,14 +67,17 @@ pub struct IndexStatus {
|
||||
pub struct ProjectIndex {
|
||||
shared: Arc<Shared>,
|
||||
worker: Mutex<Option<JoinHandle<()>>>,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl ProjectIndex {
|
||||
/// Opens (or creates) the persistent index at `cache_dir` and starts a
|
||||
/// background full scan that skips files whose content hash is unchanged.
|
||||
/// `content_roots` are folders outside `root` indexed with it (not persisted).
|
||||
pub fn open(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
content_roots: Vec<PathBuf>,
|
||||
excludes: Vec<String>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<Syntax>,
|
||||
@@ -85,7 +88,7 @@ impl ProjectIndex {
|
||||
return Err(ide_core::IdeError::not_found(format!("{} is not a directory", root.display())));
|
||||
}
|
||||
let store = store::Store::open(cache_dir, &root, &syntax.fingerprint());
|
||||
let filter = filter::PathFilter::new(&root, &excludes);
|
||||
let filter = filter::PathFilter::new(&root, &content_roots, &excludes);
|
||||
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store));
|
||||
let worker = {
|
||||
let shared = shared.clone();
|
||||
@@ -94,7 +97,25 @@ impl ProjectIndex {
|
||||
.spawn(move || shared.run())
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("cannot start indexer: {e}")))?
|
||||
};
|
||||
Ok(Arc::new(Self { shared, worker: Mutex::new(Some(worker)) }))
|
||||
Ok(Arc::new(Self { shared, worker: Mutex::new(Some(worker)), root }))
|
||||
}
|
||||
|
||||
/// Changes what is indexed besides the root (content roots) and the exclude
|
||||
/// globs, rescanning in the background (unchanged files are not re-read).
|
||||
/// Returns whether anything changed.
|
||||
pub fn set_scope(&self, content_roots: Vec<PathBuf>, excludes: Vec<String>) -> bool {
|
||||
if self.shared.filter().same_config(&content_roots, &excludes) {
|
||||
return false;
|
||||
}
|
||||
self.shared.set_filter(filter::PathFilter::new(&self.root, &content_roots, &excludes));
|
||||
self.shared.enqueue(Job::Rescan);
|
||||
true
|
||||
}
|
||||
|
||||
/// File types changed (the registry the index shares was updated): re-parses
|
||||
/// the files whose language under `before` differs from the current one.
|
||||
pub fn languages_changed(&self, before: Arc<LanguageRegistry>) {
|
||||
self.shared.enqueue(Job::Reparse(before));
|
||||
}
|
||||
|
||||
pub fn status(&self) -> IndexStatus {
|
||||
@@ -202,14 +223,15 @@ impl ProjectIndex {
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
self.shared.filter.root()
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Absolute path inside the root, or `None` for paths outside the project / excluded.
|
||||
/// Absolute path inside the root (or a content root), or `None` for paths outside the project.
|
||||
fn normalize(&self, path: &Path) -> Option<PathBuf> {
|
||||
let root = self.shared.filter.root();
|
||||
let filter = self.shared.filter();
|
||||
let root = &self.root;
|
||||
let path = if path.is_absolute() { path.to_owned() } else { root.join(path) };
|
||||
let path = if path.starts_with(root) {
|
||||
let path = if filter.contains(&path) {
|
||||
path
|
||||
} else {
|
||||
// Watchers / the UI may report non-canonical spellings (`/tmp` vs `/private/tmp`).
|
||||
@@ -221,7 +243,7 @@ impl ProjectIndex {
|
||||
}
|
||||
}
|
||||
};
|
||||
(path.starts_with(root) && path != root).then_some(path)
|
||||
(filter.contains(&path) && filter.rel(&path).is_some()).then_some(path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ impl Fixture {
|
||||
let idx = ProjectIndex::open(
|
||||
&self.root,
|
||||
&self.cache,
|
||||
Vec::new(),
|
||||
vec!["**/node_modules".into(), "**/target".into()],
|
||||
languages,
|
||||
syntax,
|
||||
@@ -403,3 +404,72 @@ fn css_selectors_are_found_by_exact_name() {
|
||||
assert_eq!(idx.symbols_named("--gap", None, 10).len(), 1);
|
||||
assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_roots_and_excludes_change_without_reopening() {
|
||||
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/gen/out.rs", "fn generated() {}\n")]);
|
||||
let shared = fx._dir.path().join("shared-lib");
|
||||
std::fs::create_dir_all(shared.join("src")).unwrap();
|
||||
std::fs::write(shared.join("src/lib.rs"), "fn gamma() {}\n").unwrap();
|
||||
let idx = fx.open();
|
||||
assert!(files(&idx, "lib.rs").is_empty());
|
||||
|
||||
let excludes = vec!["**/node_modules".to_owned(), "**/target".to_owned()];
|
||||
assert!(idx.set_scope(vec![shared.clone()], excludes.clone()));
|
||||
assert!(!idx.set_scope(vec![shared.clone()], excludes.clone()), "unchanged scope");
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(files(&idx, "lib.rs"), ["shared-lib/src/lib.rs"]);
|
||||
let gamma = &idx.find_symbols("gamma", SymbolFilter::All, 5)[0];
|
||||
assert_eq!(gamma.path, dunce::canonicalize(shared.join("src/lib.rs")).unwrap());
|
||||
|
||||
// Files created in a content root are picked up incrementally.
|
||||
std::fs::write(shared.join("src/more.rs"), "fn delta() {}\n").unwrap();
|
||||
idx.update(&[shared.join("src/more.rs")], &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(symbols(&idx, "delta", SymbolFilter::All), ["delta"]);
|
||||
|
||||
// A new exclude drops what it matches; removing the root drops its files.
|
||||
let mut more = excludes.clone();
|
||||
more.push("gen".into());
|
||||
idx.set_scope(Vec::new(), more);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(files(&idx, "out.rs").is_empty());
|
||||
assert!(files(&idx, "lib.rs").is_empty());
|
||||
assert_eq!(idx.status().files, 1);
|
||||
drop(idx);
|
||||
|
||||
// Extra roots are not persisted: reopening without them lists only the project.
|
||||
let idx = fx.open();
|
||||
assert!(symbols(&idx, "gamma", SymbolFilter::All).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_type_changes_reparse_affected_files() {
|
||||
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/legacy.inc", "fn beta() {}\n")]);
|
||||
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
|
||||
let idx = ProjectIndex::open(
|
||||
&fx.root,
|
||||
&fx.cache,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
languages.clone(),
|
||||
Arc::new(Syntax::new()),
|
||||
Arc::new(NullSink),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(symbols(&idx, "beta", SymbolFilter::All).is_empty(), "plain text has no symbols");
|
||||
let parsed = idx.shared.parsed.load(Ordering::SeqCst);
|
||||
|
||||
let before = Arc::new(languages.snapshot());
|
||||
let mut ft = ide_language::FileTypes::default();
|
||||
ft.associations.insert(
|
||||
"rust".into(),
|
||||
ide_core::settings::FileTypeAssociation { patterns: vec!["*.inc".into()], ..Default::default() },
|
||||
);
|
||||
assert!(languages.set_file_types(ft));
|
||||
idx.languages_changed(before);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(symbols(&idx, "beta", SymbolFilter::All), ["beta"]);
|
||||
assert_eq!(idx.shared.parsed.load(Ordering::SeqCst), parsed + 1, "only the affected file is re-parsed");
|
||||
}
|
||||
@@ -24,6 +24,11 @@ pub(crate) enum Job {
|
||||
Changed(Vec<PathBuf>),
|
||||
Removed(Vec<PathBuf>),
|
||||
SaveRecent,
|
||||
/// The roots or excludes changed: scan again (unchanged files are kept).
|
||||
Rescan,
|
||||
/// File types changed: re-parse the indexed files whose language under the
|
||||
/// previous registry differs from the current one.
|
||||
Reparse(std::sync::Arc<LanguageRegistry>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -34,7 +39,7 @@ struct Queue {
|
||||
}
|
||||
|
||||
pub(crate) struct Shared {
|
||||
pub filter: PathFilter,
|
||||
filter: RwLock<std::sync::Arc<PathFilter>>,
|
||||
pub languages: std::sync::Arc<LanguageRegistry>,
|
||||
pub syntax: std::sync::Arc<Syntax>,
|
||||
pub sink: SharedSink,
|
||||
@@ -60,7 +65,7 @@ impl Shared {
|
||||
store: Option<Store>,
|
||||
) -> Self {
|
||||
Self {
|
||||
filter,
|
||||
filter: RwLock::new(std::sync::Arc::new(filter)),
|
||||
languages,
|
||||
syntax,
|
||||
sink,
|
||||
@@ -76,6 +81,14 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn filter(&self) -> std::sync::Arc<PathFilter> {
|
||||
self.filter.read().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_filter(&self, filter: PathFilter) {
|
||||
*self.filter.write() = std::sync::Arc::new(filter);
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue(&self, job: Job) {
|
||||
self.queue.lock().jobs.push_back(job);
|
||||
self.wake.notify_one();
|
||||
@@ -150,7 +163,7 @@ impl Shared {
|
||||
|
||||
fn load_persisted(&self) {
|
||||
let Some(store) = &self.store else { return };
|
||||
let root = self.filter.root().to_owned();
|
||||
let root = self.filter().root().to_owned();
|
||||
match store.load() {
|
||||
Ok(records) => {
|
||||
let entries: Vec<FileEntry> =
|
||||
@@ -198,26 +211,31 @@ impl Shared {
|
||||
done: false,
|
||||
});
|
||||
|
||||
let found = self.filter.walk(self.filter.root(), &self.cancelled);
|
||||
let filter = self.filter();
|
||||
let mut found = Vec::new();
|
||||
for root in filter.roots() {
|
||||
found.extend(filter.walk(root, &self.cancelled));
|
||||
}
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
|
||||
let (todo, stale): (Vec<&Found>, Vec<String>) = {
|
||||
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
|
||||
let d = self.data.read();
|
||||
let todo = found
|
||||
.iter()
|
||||
.filter(|f| d.get(&f.path).is_none_or(|e| e.mtime != f.mtime || e.size != f.size))
|
||||
.collect();
|
||||
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).filter_map(|p| self.filter.rel(p)).collect();
|
||||
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect();
|
||||
(todo, stale)
|
||||
};
|
||||
if !stale.is_empty() {
|
||||
let mut d = self.data.write();
|
||||
for rel in &stale {
|
||||
d.remove(&self.filter.root().join(rel));
|
||||
for path in &stale {
|
||||
d.remove(path);
|
||||
}
|
||||
}
|
||||
let stale: Vec<String> = stale.iter().filter_map(|p| filter.store_key(p)).collect();
|
||||
self.persist(&[], &stale, false);
|
||||
|
||||
let total = todo.len();
|
||||
@@ -227,7 +245,7 @@ impl Shared {
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
self.index_files(chunk.iter().copied());
|
||||
self.index_files(chunk.iter().copied(), false);
|
||||
done += chunk.len();
|
||||
if last_progress.elapsed() >= PROGRESS_EVERY {
|
||||
last_progress = Instant::now();
|
||||
@@ -261,16 +279,18 @@ impl Shared {
|
||||
}
|
||||
|
||||
/// Extracts records in parallel, then publishes them to memory and disk.
|
||||
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>) {
|
||||
/// `force` re-parses files whose content is unchanged (their language changed).
|
||||
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) {
|
||||
let files: Vec<&Found> = files.collect();
|
||||
let filter = self.filter();
|
||||
let extracted: Vec<(String, FileRecord, PathBuf)> = files
|
||||
.par_iter()
|
||||
.filter_map(|f| {
|
||||
if self.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let rel = self.filter.rel(&f.path)?;
|
||||
let prev_hash = self.data.read().get(&f.path).map(|e| e.hash);
|
||||
let rel = filter.rel(&f.path)?;
|
||||
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
|
||||
match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
@@ -288,7 +308,7 @@ impl Shared {
|
||||
return;
|
||||
}
|
||||
let puts: Vec<(String, FileRecord)> =
|
||||
extracted.iter().map(|(rel, rec, _)| (rel.clone(), rec.clone())).collect();
|
||||
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
|
||||
{
|
||||
let entries: Vec<FileEntry> =
|
||||
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
|
||||
@@ -346,13 +366,24 @@ impl Shared {
|
||||
let mut changed: Vec<PathBuf> = Vec::new();
|
||||
let mut removed: Vec<PathBuf> = Vec::new();
|
||||
let mut save_recent = false;
|
||||
let mut rescan = false;
|
||||
let mut reparse: Vec<std::sync::Arc<LanguageRegistry>> = Vec::new();
|
||||
for job in jobs {
|
||||
match job {
|
||||
Job::Changed(p) => changed.extend(p),
|
||||
Job::Removed(p) => removed.extend(p),
|
||||
Job::SaveRecent => save_recent = true,
|
||||
Job::Rescan => rescan = true,
|
||||
Job::Reparse(before) => reparse.push(before),
|
||||
}
|
||||
}
|
||||
if rescan {
|
||||
// The scan also picks up whatever `changed` / `removed` reported.
|
||||
self.full_scan();
|
||||
changed.clear();
|
||||
removed.clear();
|
||||
}
|
||||
let filter = self.filter();
|
||||
let mut touched = false;
|
||||
|
||||
// Removals first; a path removed then re-created shows up in `changed` too.
|
||||
@@ -363,11 +394,11 @@ impl Shared {
|
||||
continue;
|
||||
}
|
||||
let mut d = self.data.write();
|
||||
if let Some(e) = d.remove(&path) {
|
||||
deletes.push(e.rel.into_string());
|
||||
} else {
|
||||
deletes.extend(d.remove_tree(&path));
|
||||
}
|
||||
let gone = match d.remove(&path) {
|
||||
Some(_) => vec![path],
|
||||
None => d.remove_tree(&path),
|
||||
};
|
||||
deletes.extend(gone.iter().filter_map(|p| filter.store_key(p)));
|
||||
}
|
||||
if !deletes.is_empty() {
|
||||
touched = true;
|
||||
@@ -381,11 +412,11 @@ impl Shared {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
|
||||
if self.filter.is_excluded(&path, meta.is_dir()) {
|
||||
if filter.is_excluded(&path, meta.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
files.extend(self.filter.walk(&path, &self.cancelled));
|
||||
files.extend(filter.walk(&path, &self.cancelled));
|
||||
} else if meta.is_file() {
|
||||
files.push(Found { mtime: mtime_of(&meta), size: meta.len(), path });
|
||||
}
|
||||
@@ -403,11 +434,32 @@ impl Shared {
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.index_files(chunk.iter());
|
||||
self.index_files(chunk.iter(), false);
|
||||
}
|
||||
}
|
||||
for before in reparse {
|
||||
let affected: Vec<Found> = {
|
||||
let d = self.data.read();
|
||||
d.paths()
|
||||
.filter(|p| before.detect(p, None) != self.languages.detect(p, None))
|
||||
.filter_map(|p| {
|
||||
let e = d.get(p)?;
|
||||
Some(Found { path: p.clone(), mtime: e.mtime, size: e.size })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if !affected.is_empty() {
|
||||
touched = true;
|
||||
for chunk in affected.chunks(CHUNK) {
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.index_files(chunk.iter(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if save_recent && let Some(store) = &self.store {
|
||||
let rels: Vec<String> = self.recent.lock().iter().filter_map(|p| self.filter.rel(p)).collect();
|
||||
let rels: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
|
||||
if let Err(e) = store.save_recent(&rels) {
|
||||
tracing::warn!("index cache: saving recent files failed: {e}");
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ use std::collections::BTreeMap;
|
||||
|
||||
mod registry;
|
||||
mod syntax;
|
||||
pub use registry::LanguageRegistry;
|
||||
pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES,
|
||||
ParsedTree, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
|
||||
WebReferenceKind,
|
||||
ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind,
|
||||
SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
@@ -119,6 +119,18 @@ pub struct LanguageInfo {
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub debuggable: bool,
|
||||
/// Names of the configured language servers (Settings › Inspections).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub language_servers: Vec<String>,
|
||||
/// Exact file names and file-name patterns of the definition (Settings › File Types).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub filenames: Vec<String>,
|
||||
/// First-line regex of the definition (shebangs, `<?php`).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub first_line: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&LanguageConfig> for LanguageInfo {
|
||||
@@ -132,6 +144,9 @@ impl From<&LanguageConfig> for LanguageInfo {
|
||||
block_comment: c.block_comment.clone(),
|
||||
indent: c.indent,
|
||||
debuggable: c.debugger.is_some(),
|
||||
language_servers: c.language_servers.iter().map(|s| s.name.clone()).collect(),
|
||||
filenames: c.filenames.clone(),
|
||||
first_line: c.first_line.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::LanguageConfig;
|
||||
use ide_core::settings::{FileSettings, FileTypeAssociation};
|
||||
use ide_core::{IdeError, IdeResult, LanguageId};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
@@ -78,11 +81,130 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
|
||||
pub struct LanguageRegistry {
|
||||
languages: Vec<LanguageConfig>,
|
||||
/// Lookup tables of the definitions with the user's file types applied.
|
||||
tables: RwLock<Arc<Tables>>,
|
||||
file_types: RwLock<FileTypes>,
|
||||
}
|
||||
|
||||
/// The user's file type associations (Settings › Editor › File Types) and
|
||||
/// per-file overrides (Override File Type), resolved for one project.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FileTypes {
|
||||
/// Language id → patterns added to / removed from its definition.
|
||||
pub associations: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Absolute path → language id.
|
||||
pub overrides: HashMap<PathBuf, LanguageId>,
|
||||
}
|
||||
|
||||
impl FileTypes {
|
||||
/// From `files.fileTypes` / `files.fileTypeOverrides`; relative override paths resolve against `root`.
|
||||
pub fn from_settings(files: &FileSettings, root: Option<&Path>) -> Self {
|
||||
let overrides = files
|
||||
.file_type_overrides
|
||||
.iter()
|
||||
.filter(|(_, lang)| !lang.trim().is_empty())
|
||||
.filter_map(|(path, lang)| {
|
||||
let p = Path::new(path.as_str());
|
||||
let abs = if p.is_absolute() { p.to_path_buf() } else { root?.join(p) };
|
||||
Some((abs, LanguageId::new(lang.trim())))
|
||||
})
|
||||
.collect();
|
||||
Self { associations: files.file_types.clone(), overrides }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Tables {
|
||||
by_ext: HashMap<String, usize>,
|
||||
by_name: HashMap<String, usize>,
|
||||
/// File-name patterns containing `*`, e.g. `Dockerfile.*`, `tsconfig.*.json`.
|
||||
/// File-name patterns with wildcards, e.g. `Dockerfile.*`, `tsconfig.*.json`; the user's first.
|
||||
name_globs: Vec<(String, usize)>,
|
||||
/// Interpreter names of user hashbang patterns.
|
||||
hashbangs: Vec<(String, usize)>,
|
||||
first_line: Vec<(regex_lite::Regex, usize)>,
|
||||
overrides: HashMap<PathBuf, usize>,
|
||||
}
|
||||
|
||||
/// How a file name pattern is looked up.
|
||||
enum Pattern<'a> {
|
||||
/// `*.ext` (lower-cased).
|
||||
Ext(String),
|
||||
Name(&'a str),
|
||||
Glob(&'a str),
|
||||
}
|
||||
|
||||
fn classify(pattern: &str) -> Pattern<'_> {
|
||||
let wild = |s: &str| s.contains(['*', '?']);
|
||||
match pattern.strip_prefix("*.") {
|
||||
Some(ext) if !ext.is_empty() && !wild(ext) => Pattern::Ext(ext.to_ascii_lowercase()),
|
||||
_ if wild(pattern) => Pattern::Glob(pattern),
|
||||
_ => Pattern::Name(pattern),
|
||||
}
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
fn build(languages: &[LanguageConfig], file_types: &FileTypes) -> Self {
|
||||
let mut t = Tables::default();
|
||||
let index_of = |id: &str| languages.iter().position(|l| l.id.as_str() == id);
|
||||
let removed = |l: &LanguageConfig, pattern: &str| {
|
||||
file_types.associations.get(l.id.as_str()).is_some_and(|a| {
|
||||
a.removed_patterns.iter().any(|r| r.trim() == pattern || r.trim().eq_ignore_ascii_case(pattern))
|
||||
})
|
||||
};
|
||||
let mut builtin_globs = Vec::new();
|
||||
for (i, l) in languages.iter().enumerate() {
|
||||
for ext in &l.extensions {
|
||||
if !removed(l, &format!("*.{ext}")) {
|
||||
t.by_ext.insert(ext.to_ascii_lowercase(), i);
|
||||
}
|
||||
}
|
||||
for name in l.filenames.iter().filter(|n| !removed(l, n)) {
|
||||
if name.contains(['*', '?']) {
|
||||
builtin_globs.push((name.clone(), i));
|
||||
} else {
|
||||
t.by_name.insert(name.clone(), i);
|
||||
}
|
||||
}
|
||||
if let Some(pattern) = l.first_line.as_deref() {
|
||||
match regex_lite::Regex::new(pattern) {
|
||||
Ok(re) => t.first_line.push((re, i)),
|
||||
Err(e) => tracing::warn!("language {}: invalid first-line pattern: {e}", l.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
// The user's patterns win over the definitions'.
|
||||
let mut user_globs = Vec::new();
|
||||
for (id, assoc) in &file_types.associations {
|
||||
let Some(i) = index_of(id) else { continue };
|
||||
for pattern in assoc.patterns.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
|
||||
match classify(pattern) {
|
||||
Pattern::Ext(ext) => {
|
||||
t.by_ext.insert(ext, i);
|
||||
}
|
||||
Pattern::Name(name) => {
|
||||
t.by_name.insert(name.to_owned(), i);
|
||||
}
|
||||
Pattern::Glob(glob) => user_globs.push((glob.to_owned(), i)),
|
||||
}
|
||||
}
|
||||
for h in assoc.hashbangs.iter().map(|h| h.trim()).filter(|h| !h.is_empty()) {
|
||||
t.hashbangs.push((h.to_owned(), i));
|
||||
}
|
||||
}
|
||||
// More specific (longer) patterns first: `tsconfig.*.json` before `*.json`-like ones.
|
||||
user_globs.sort_by_key(|(p, _): &(String, usize)| std::cmp::Reverse(p.len()));
|
||||
builtin_globs.sort_by_key(|(p, _): &(String, usize)| std::cmp::Reverse(p.len()));
|
||||
t.name_globs = user_globs.into_iter().chain(builtin_globs).collect();
|
||||
t.overrides =
|
||||
file_types.overrides.iter().filter_map(|(p, id)| Some((p.clone(), index_of(id.as_str())?))).collect();
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the `#!` line runs `interpreter` (`#!/usr/bin/env node`, `#!/bin/node -x` for `node`).
|
||||
fn hashbang_runs(line: &str, interpreter: &str) -> bool {
|
||||
let Some(rest) = line.strip_prefix("#!") else { return false };
|
||||
rest.split(|c: char| c.is_whitespace() || c == '/').any(|token| token == interpreter)
|
||||
}
|
||||
|
||||
impl LanguageRegistry {
|
||||
@@ -116,31 +238,31 @@ impl LanguageRegistry {
|
||||
None => languages.push(cfg),
|
||||
}
|
||||
}
|
||||
let mut by_ext = HashMap::new();
|
||||
let mut by_name = HashMap::new();
|
||||
let mut name_globs = Vec::new();
|
||||
let mut first_line = Vec::new();
|
||||
for (i, l) in languages.iter().enumerate() {
|
||||
for ext in &l.extensions {
|
||||
by_ext.insert(ext.to_ascii_lowercase(), i);
|
||||
}
|
||||
for name in &l.filenames {
|
||||
if name.contains('*') {
|
||||
name_globs.push((name.clone(), i));
|
||||
} else {
|
||||
by_name.insert(name.clone(), i);
|
||||
}
|
||||
}
|
||||
if let Some(pattern) = l.first_line.as_deref() {
|
||||
match regex_lite::Regex::new(pattern) {
|
||||
Ok(re) => first_line.push((re, i)),
|
||||
Err(e) => tracing::warn!("language {}: invalid first-line pattern: {e}", l.id),
|
||||
}
|
||||
}
|
||||
let tables = Tables::build(&languages, &FileTypes::default());
|
||||
Self { languages, tables: RwLock::new(Arc::new(tables)), file_types: RwLock::default() }
|
||||
}
|
||||
|
||||
/// A registry with the same definitions and no user file types (one per project).
|
||||
pub fn fork(&self) -> Self {
|
||||
Self::from_configs(self.languages.clone())
|
||||
}
|
||||
|
||||
/// A copy with the same definitions and file types (to compare detection before / after a change).
|
||||
pub fn snapshot(&self) -> Self {
|
||||
let copy = self.fork();
|
||||
copy.set_file_types(self.file_types.read().clone());
|
||||
copy
|
||||
}
|
||||
|
||||
/// Applies the user's file types; returns whether they changed.
|
||||
pub fn set_file_types(&self, file_types: FileTypes) -> bool {
|
||||
let mut current = self.file_types.write();
|
||||
if *current == file_types {
|
||||
return false;
|
||||
}
|
||||
// More specific (longer) patterns first: `tsconfig.*.json` before `*.json`-like ones.
|
||||
name_globs.sort_by_key(|(p, _): &(String, usize)| std::cmp::Reverse(p.len()));
|
||||
Self { languages, by_ext, by_name, name_globs, first_line }
|
||||
*self.tables.write() = Arc::new(Tables::build(&self.languages, &file_types));
|
||||
*current = file_types;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<LanguageConfig> {
|
||||
@@ -151,48 +273,67 @@ impl LanguageRegistry {
|
||||
self.languages.iter().find(|l| &l.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Detects by exact file name, then file-name pattern (`Dockerfile.*`), then
|
||||
/// extension (longest compound first, e.g. `d.ts`), then first line; falls
|
||||
/// back to plaintext.
|
||||
/// Detects by Override File Type, exact file name, then file-name pattern
|
||||
/// (`Dockerfile.*`), then extension (longest compound first, e.g. `d.ts`),
|
||||
/// then hashbang / first line; falls back to plaintext. The user's patterns
|
||||
/// win over the definitions'.
|
||||
pub fn detect(&self, path: &Path, first_line: Option<&str>) -> LanguageId {
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if let Some(&i) = self.by_name.get(file_name) {
|
||||
let t = self.tables.read().clone();
|
||||
if !t.overrides.is_empty()
|
||||
&& let Some(&i) = t.overrides.get(path)
|
||||
{
|
||||
return self.languages[i].id.clone();
|
||||
}
|
||||
if let Some((_, i)) = self.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
if let Some(&i) = t.by_name.get(file_name) {
|
||||
return self.languages[i].id.clone();
|
||||
}
|
||||
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
|
||||
return self.languages[*i].id.clone();
|
||||
}
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
let mut rest = lower.as_str();
|
||||
while let Some(dot) = rest.find('.') {
|
||||
rest = &rest[dot + 1..];
|
||||
if let Some(&i) = self.by_ext.get(rest) {
|
||||
if let Some(&i) = t.by_ext.get(rest) {
|
||||
return self.languages[i].id.clone();
|
||||
}
|
||||
}
|
||||
if let Some(line) = first_line
|
||||
&& let Some((_, i)) = self.first_line.iter().find(|(re, _)| re.is_match(line))
|
||||
{
|
||||
return self.languages[*i].id.clone();
|
||||
if let Some(line) = first_line {
|
||||
if let Some((_, i)) = t.hashbangs.iter().find(|(h, _)| hashbang_runs(line, h)) {
|
||||
return self.languages[*i].id.clone();
|
||||
}
|
||||
if let Some((_, i)) = t.first_line.iter().find(|(re, _)| re.is_match(line)) {
|
||||
return self.languages[*i].id.clone();
|
||||
}
|
||||
}
|
||||
LanguageId::plain_text()
|
||||
}
|
||||
}
|
||||
|
||||
/// `*` matches any (possibly empty) run of characters; everything else is literal.
|
||||
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
|
||||
fn wildcard_match(pattern: &str, name: &str) -> bool {
|
||||
let mut parts = pattern.split('*');
|
||||
let first = parts.next().unwrap_or_default();
|
||||
let Some(mut rest) = name.strip_prefix(first) else { return false };
|
||||
let parts: Vec<&str> = parts.collect();
|
||||
let Some((last, middle)) = parts.split_last() else { return rest.is_empty() };
|
||||
for part in middle {
|
||||
match rest.find(part) {
|
||||
Some(i) => rest = &rest[i + part.len()..],
|
||||
None => return false,
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let n: Vec<char> = name.chars().collect();
|
||||
let (mut pi, mut ni) = (0, 0);
|
||||
// Position after the last `*` and the name position it is matched up to.
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
while ni < n.len() {
|
||||
if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
|
||||
pi += 1;
|
||||
ni += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star = Some((pi + 1, ni));
|
||||
pi += 1;
|
||||
} else if let Some((sp, sn)) = star {
|
||||
pi = sp;
|
||||
ni = sn + 1;
|
||||
star = Some((sp, sn + 1));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
rest.len() >= last.len() && rest.ends_with(last)
|
||||
p[pi..].iter().all(|&c| c == '*')
|
||||
}
|
||||
|
||||
fn parse(src: &str) -> Result<LanguageConfig, String> {
|
||||
@@ -573,6 +714,60 @@ mod tests {
|
||||
assert!(wildcard_match("*.Dockerfile", "web.Dockerfile"));
|
||||
assert!(wildcard_match("a*b*c", "aXbYc"));
|
||||
assert!(!wildcard_match("a*b*c", "aXcYb"));
|
||||
assert!(wildcard_match("file?.txt", "file1.txt"));
|
||||
assert!(!wildcard_match("file?.txt", "file.txt"));
|
||||
assert!(wildcard_match("*", ""));
|
||||
assert!(wildcard_match("a**", "abc"));
|
||||
}
|
||||
|
||||
fn assoc(patterns: &[&str], removed: &[&str], hashbangs: &[&str]) -> FileTypeAssociation {
|
||||
let v = |xs: &[&str]| xs.iter().map(|s| (*s).to_owned()).collect();
|
||||
FileTypeAssociation { patterns: v(patterns), removed_patterns: v(removed), hashbangs: v(hashbangs) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_file_types_add_remove_and_win() {
|
||||
let reg = reg();
|
||||
let mut ft = FileTypes::default();
|
||||
ft.associations
|
||||
.insert("python".into(), assoc(&["*.foo", "Pipfile.lock", "build_*.cfg", "*.LOG"], &[], &["mypy"]));
|
||||
ft.associations.insert("groovy".into(), assoc(&[], &["Jenkinsfile"], &[]));
|
||||
ft.associations.insert("shellscript".into(), assoc(&["Jenkinsfile"], &[], &[]));
|
||||
ft.associations.insert("rust".into(), assoc(&[], &["*.rs"], &[]));
|
||||
assert!(reg.set_file_types(ft.clone()));
|
||||
assert!(!reg.set_file_types(ft), "unchanged file types report no change");
|
||||
assert_eq!(detect(®, "a/x.foo"), "python");
|
||||
assert_eq!(detect(®, "x.FOO"), "python");
|
||||
assert_eq!(detect(®, "Pipfile.lock"), "python");
|
||||
assert_eq!(detect(®, "build_dev.cfg"), "python");
|
||||
assert_eq!(detect(®, "setup.cfg"), "ini");
|
||||
assert_eq!(detect(®, "server.log"), "python", "a user extension overrides a built-in one");
|
||||
assert_eq!(detect(®, "Jenkinsfile"), "shellscript");
|
||||
assert_eq!(detect(®, "main.rs"), "plaintext");
|
||||
assert_eq!(reg.detect(Path::new("tool"), Some("#!/usr/bin/env mypy --strict")).as_str(), "python");
|
||||
assert_eq!(reg.detect(Path::new("tool"), Some("#!/usr/bin/env mypyc")).as_str(), "plaintext");
|
||||
reg.set_file_types(FileTypes::default());
|
||||
assert_eq!(detect(®, "main.rs"), "rust");
|
||||
assert_eq!(detect(®, "x.foo"), "plaintext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_file_type_per_file() {
|
||||
let reg = reg().fork();
|
||||
let files = FileSettings {
|
||||
file_type_overrides: [
|
||||
("notes.txt".to_owned(), "markdown".to_owned()),
|
||||
("/abs/run".to_owned(), "python".to_owned()),
|
||||
("bad.txt".to_owned(), "no-such-language".to_owned()),
|
||||
]
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
reg.set_file_types(FileTypes::from_settings(&files, Some(Path::new("/proj"))));
|
||||
assert_eq!(detect(®, "/proj/notes.txt"), "markdown");
|
||||
assert_eq!(detect(®, "/other/notes.txt"), "plaintext");
|
||||
assert_eq!(detect(®, "/abs/run"), "python");
|
||||
assert_eq!(detect(®, "/proj/bad.txt"), "plaintext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! filter ("In comments", "Except string literals", …).
|
||||
|
||||
use super::folding::is_comment;
|
||||
use tree_sitter::{Node, Tree};
|
||||
use tree_sitter::{Node, Tree, TreeCursor};
|
||||
|
||||
/// What a [`Literal`] range is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -57,3 +57,70 @@ pub(crate) fn literals(tree: &Tree) -> Vec<Literal> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`SpellingRegion`] covers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SpellingKind {
|
||||
Comment,
|
||||
String,
|
||||
/// The name a declaration introduces (not its uses).
|
||||
Name,
|
||||
}
|
||||
|
||||
/// Text the Typo inspection checks: byte range in document order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SpellingRegion {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub kind: SpellingKind,
|
||||
}
|
||||
|
||||
fn is_name_leaf(kind: &str) -> bool {
|
||||
kind.contains("identifier") || kind == "name"
|
||||
}
|
||||
|
||||
/// Whether the identifier leaf under `cursor` is the name a declaration
|
||||
/// introduces (`fn name`, `let pattern`, `int *declarator`, parameters,
|
||||
/// `name = …` assignments) rather than a use of one.
|
||||
fn declares(cursor: &TreeCursor<'_>, node: &Node<'_>) -> bool {
|
||||
let parent = node.parent().map(|p| p.kind()).unwrap_or_default();
|
||||
match cursor.field_name() {
|
||||
Some("name" | "pattern" | "declarator") => true,
|
||||
Some("left") => parent.contains("assignment") || parent.contains("declarator"),
|
||||
_ => parent.contains("parameter"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Comments, string literals and declared names, for spell checking.
|
||||
pub(crate) fn spelling_regions(tree: &Tree) -> Vec<SpellingRegion> {
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = tree.walk();
|
||||
loop {
|
||||
let node = cursor.node();
|
||||
let literal = kind_of(&node);
|
||||
let region = |kind| SpellingRegion { start: node.start_byte(), end: node.end_byte(), kind };
|
||||
match literal {
|
||||
Some(LiteralKind::Comment) => out.push(region(SpellingKind::Comment)),
|
||||
Some(LiteralKind::String) => out.push(region(SpellingKind::String)),
|
||||
None if node.is_named()
|
||||
&& node.child_count() == 0
|
||||
&& is_name_leaf(node.kind())
|
||||
&& declares(&cursor, &node) =>
|
||||
{
|
||||
out.push(region(SpellingKind::Name))
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if literal.is_none() && cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
if cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
|
||||
use grammars::{SPECS, Spec};
|
||||
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, Range};
|
||||
pub use literals::{Literal, LiteralKind};
|
||||
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
|
||||
pub use outline::merge_visibility;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
@@ -235,6 +236,12 @@ impl Syntax {
|
||||
self.analyze(lang, text, |_, tree, _| Some(literals::literals(tree)))
|
||||
}
|
||||
|
||||
/// Comments, string literals and declared names (byte ranges, document
|
||||
/// order) for the Typo inspection; `None` like [`Self::literals`].
|
||||
pub fn spelling_regions(&self, lang: &LanguageId, text: &str) -> Option<Vec<SpellingRegion>> {
|
||||
self.analyze(lang, text, |_, tree, _| Some(literals::spelling_regions(tree)))
|
||||
}
|
||||
|
||||
/// Syntax-aware selection ranges for IDEA "Extend Selection" (⌥↑), innermost first.
|
||||
pub fn selection_ranges(&self, lang: &LanguageId, text: &str, offset_utf16: u32) -> Vec<Range> {
|
||||
match self.analyze(lang, text, |_, tree, text| Some(selection::selection_ranges(tree, text, offset_utf16))) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use super::Grammar;
|
||||
use super::lines::LineIndex;
|
||||
use crate::SymbolTag;
|
||||
use ide_core::{OutlineItem, SymbolKind};
|
||||
use ide_core::{OutlineItem, SymbolKind, Visibility};
|
||||
use std::collections::HashMap;
|
||||
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
|
||||
|
||||
@@ -139,11 +139,16 @@ struct Raw {
|
||||
container_name: String,
|
||||
explicit_container: Option<String>,
|
||||
detail: Option<String>,
|
||||
/// From a modifier keyword in the declaration (`pub`, `private`, ...).
|
||||
visibility: Option<Visibility>,
|
||||
/// Rust `impl Trait for Type`: members take the trait's visibility.
|
||||
trait_impl: bool,
|
||||
}
|
||||
|
||||
struct Built {
|
||||
raw: Raw,
|
||||
kind: SymbolKind,
|
||||
visibility: Option<Visibility>,
|
||||
children: Vec<Built>,
|
||||
}
|
||||
|
||||
@@ -188,6 +193,7 @@ fn collect(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<Built>
|
||||
base = last.to_owned();
|
||||
kind = ItemKind::Symbol(SymbolKind::Method);
|
||||
}
|
||||
let trait_impl = kind == ItemKind::Impl && trait_.is_some();
|
||||
let name = match (kind, trait_) {
|
||||
(ItemKind::Impl, Some(t)) => format!("impl {} for {base}", clean(node_text(&t, text), 120)),
|
||||
(ItemKind::Impl, None) => format!("impl {base}"),
|
||||
@@ -213,6 +219,8 @@ fn collect(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<Built>
|
||||
container_name,
|
||||
explicit_container: container,
|
||||
detail: detail.map(|d| clean(node_text(&d, text), 80)).filter(|d| !d.is_empty()),
|
||||
visibility: explicit_visibility(&node, text),
|
||||
trait_impl,
|
||||
};
|
||||
match by_node.get(&raw.node_id) {
|
||||
Some(existing) if existing.pattern <= raw.pattern => {}
|
||||
@@ -239,6 +247,7 @@ fn build_tree(g: &Grammar, raws: Vec<Raw>) -> Vec<Built> {
|
||||
push_child(&mut stack, &mut roots, done);
|
||||
}
|
||||
let parent_kind = stack.last().map(|p| p.raw.kind);
|
||||
let parent_trait_impl = stack.last().is_some_and(|p| p.raw.trait_impl);
|
||||
let mut kind = raw.kind.symbol_kind();
|
||||
if kind == SymbolKind::Function && parent_kind.is_some_and(ItemKind::holds_methods) {
|
||||
kind = SymbolKind::Method;
|
||||
@@ -250,7 +259,10 @@ fn build_tree(g: &Grammar, raws: Vec<Raw>) -> Vec<Built> {
|
||||
kind = SymbolKind::Constructor;
|
||||
}
|
||||
}
|
||||
stack.push(Built { raw, kind, children: Vec::new() });
|
||||
let visibility = raw
|
||||
.visibility
|
||||
.or_else(|| implicit_visibility(g.spec.name, raw.kind, kind, &raw.name, parent_kind, parent_trait_impl));
|
||||
stack.push(Built { raw, kind, visibility, children: Vec::new() });
|
||||
}
|
||||
while let Some(done) = stack.pop() {
|
||||
push_child(&mut stack, &mut roots, done);
|
||||
@@ -278,6 +290,7 @@ pub(crate) fn outline(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) ->
|
||||
range: b.raw.range,
|
||||
selection_range: b.raw.sel,
|
||||
children: b.children.into_iter().map(convert).collect(),
|
||||
visibility: b.visibility,
|
||||
}
|
||||
}
|
||||
collect(g, q, tree, text).into_iter().map(convert).collect()
|
||||
@@ -308,6 +321,107 @@ pub(crate) fn tags(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Ve
|
||||
out
|
||||
}
|
||||
|
||||
/// Visibility spelled out by a modifier child (`visibility_modifier`, `modifiers`,
|
||||
/// `accessibility_modifier`, ...) of the declaration.
|
||||
fn explicit_visibility(node: &Node, text: &str) -> Option<Visibility> {
|
||||
let mut cursor = node.walk();
|
||||
let mut found: Option<Visibility> = None;
|
||||
for child in node.children(&mut cursor) {
|
||||
if !child.kind().contains("modifier") {
|
||||
continue;
|
||||
}
|
||||
let t = node_text(&child, text);
|
||||
if child.kind() == "visibility_modifier" && t.starts_with("pub") {
|
||||
// Rust: `pub` / `pub(crate)` / `pub(super)` / `pub(in path)`.
|
||||
let v = if t.trim() == "pub" { Visibility::Public } else { Visibility::Package };
|
||||
found = Some(found.map_or(v, |f| narrower(f, v)));
|
||||
continue;
|
||||
}
|
||||
for word in t.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
|
||||
let v = match word {
|
||||
"public" | "open" => Visibility::Public,
|
||||
"protected" => Visibility::Protected,
|
||||
"internal" | "fileprivate" => Visibility::Package,
|
||||
"private" => Visibility::Private,
|
||||
_ => continue,
|
||||
};
|
||||
found = Some(found.map_or(v, |f| narrower(f, v)));
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Several keywords (C# `private protected`): the narrower one wins.
|
||||
fn narrower(a: Visibility, b: Visibility) -> Visibility {
|
||||
let rank = |v: Visibility| match v {
|
||||
Visibility::Public => 0,
|
||||
Visibility::Protected => 1,
|
||||
Visibility::Package => 2,
|
||||
Visibility::Private => 3,
|
||||
};
|
||||
if rank(a) >= rank(b) { a } else { b }
|
||||
}
|
||||
|
||||
/// The language's default when no modifier is written.
|
||||
fn implicit_visibility(
|
||||
lang: &str,
|
||||
item: ItemKind,
|
||||
kind: SymbolKind,
|
||||
name: &str,
|
||||
parent: Option<ItemKind>,
|
||||
parent_trait_impl: bool,
|
||||
) -> Option<Visibility> {
|
||||
use SymbolKind as K;
|
||||
if matches!(item, ItemKind::Impl | ItemKind::Extension | ItemKind::Heading)
|
||||
|| matches!(kind, K::EnumMember | K::Package | K::Namespace)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let in_interface = matches!(parent, Some(ItemKind::Symbol(K::Interface | K::Trait)));
|
||||
match lang {
|
||||
"rust" => (!in_interface && !parent_trait_impl && kind != K::Macro).then_some(Visibility::Private),
|
||||
"java" => (!in_interface).then_some(Visibility::Package),
|
||||
"csharp" => match parent {
|
||||
_ if in_interface => None,
|
||||
Some(ItemKind::Symbol(K::Class | K::Struct | K::Enum)) => Some(Visibility::Private),
|
||||
_ => Some(Visibility::Package),
|
||||
},
|
||||
"go" => name.starts_with(|c: char| c.is_lowercase() || c == '_').then_some(Visibility::Package),
|
||||
"python" => {
|
||||
(name.starts_with('_') && !(name.starts_with("__") && name.ends_with("__"))).then_some(Visibility::Private)
|
||||
}
|
||||
"dart" => name.starts_with('_').then_some(Visibility::Private),
|
||||
"typescript" | "tsx" | "javascript" => name.starts_with('#').then_some(Visibility::Private),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies tree-sitter visibility onto language-server symbols (which carry none),
|
||||
/// matching by name and identifier start.
|
||||
pub fn merge_visibility(target: &mut [OutlineItem], source: &[OutlineItem]) {
|
||||
fn index(items: &[OutlineItem], out: &mut HashMap<(ide_core::Pos, String), Visibility>) {
|
||||
for i in items {
|
||||
if let Some(v) = i.visibility {
|
||||
out.insert((i.selection_range.start, i.name.clone()), v);
|
||||
}
|
||||
index(&i.children, out);
|
||||
}
|
||||
}
|
||||
fn apply(items: &mut [OutlineItem], map: &HashMap<(ide_core::Pos, String), Visibility>) {
|
||||
for i in items {
|
||||
if i.visibility.is_none() {
|
||||
i.visibility = map.get(&(i.selection_range.start, i.name.clone())).copied();
|
||||
}
|
||||
apply(&mut i.children, map);
|
||||
}
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
index(source, &mut map);
|
||||
if !map.is_empty() {
|
||||
apply(target, &map);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of `node` without trailing whitespace (C macros and others include the newline).
|
||||
fn trimmed_end(node: &Node, text: &str) -> usize {
|
||||
let (s, e) = (node.start_byte(), node.end_byte());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use crate::FoldKind;
|
||||
use ide_core::{OutlineItem, Pos, SymbolKind};
|
||||
use ide_core::{OutlineItem, Pos, SymbolKind, Visibility};
|
||||
|
||||
fn syntax() -> &'static Syntax {
|
||||
static S: OnceLock<Syntax> = OnceLock::new();
|
||||
@@ -1187,3 +1187,109 @@ fn literals_are_outermost_comments_and_strings() {
|
||||
);
|
||||
assert!(syntax().literals(&lang("plaintext"), "// x").is_none());
|
||||
}
|
||||
|
||||
/// `name: visibility` for every outline item, depth-first.
|
||||
fn visibilities(id: &str, src: &str) -> Vec<(String, Option<Visibility>)> {
|
||||
fn go(items: &[OutlineItem], out: &mut Vec<(String, Option<Visibility>)>) {
|
||||
for i in items {
|
||||
out.push((i.name.clone(), i.visibility));
|
||||
go(&i.children, out);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
go(&syntax().outline(&lang(id), src), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn vis_of(list: &[(String, Option<Visibility>)], name: &str) -> Option<Visibility> {
|
||||
list.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no {name} in {list:?}")).1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_visibility_rust() {
|
||||
let src = "pub struct S { a: i32, pub b: i32, pub(crate) c: i32 }\n\
|
||||
trait T { fn req(&self); }\n\
|
||||
impl T for S { fn req(&self) {} }\n\
|
||||
impl S { pub fn new() {} fn helper() {} }\n\
|
||||
fn main() {}\n";
|
||||
let v = visibilities("rust", src);
|
||||
assert_eq!(vis_of(&v, "S"), Some(Visibility::Public));
|
||||
assert_eq!(vis_of(&v, "a"), Some(Visibility::Private));
|
||||
assert_eq!(vis_of(&v, "b"), Some(Visibility::Public));
|
||||
assert_eq!(vis_of(&v, "c"), Some(Visibility::Package));
|
||||
assert_eq!(vis_of(&v, "new"), Some(Visibility::Public));
|
||||
assert_eq!(vis_of(&v, "helper"), Some(Visibility::Private));
|
||||
assert_eq!(vis_of(&v, "main"), Some(Visibility::Private));
|
||||
assert_eq!(vis_of(&v, "impl T for S"), None);
|
||||
// Trait members and trait impl members follow the trait.
|
||||
assert!(v.iter().filter(|(n, _)| n == "req").all(|(_, vis)| vis.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_visibility_java_ts_python() {
|
||||
let java = "public class A {\n private int x;\n protected void p() {}\n void pkg() {}\n public A() {}\n}\ninterface I { void m(); }\n";
|
||||
let v = visibilities("java", java);
|
||||
assert_eq!(vis_of(&v, "x"), Some(Visibility::Private));
|
||||
assert_eq!(vis_of(&v, "p"), Some(Visibility::Protected));
|
||||
assert_eq!(vis_of(&v, "pkg"), Some(Visibility::Package));
|
||||
assert_eq!(vis_of(&v, "A"), Some(Visibility::Public));
|
||||
assert_eq!(vis_of(&v, "m"), None);
|
||||
|
||||
let ts = "class C {\n private a = 1;\n protected b(): void {}\n c(): void {}\n #d = 2;\n}\n";
|
||||
let v = visibilities("typescript", ts);
|
||||
assert_eq!(vis_of(&v, "a"), Some(Visibility::Private));
|
||||
assert_eq!(vis_of(&v, "b"), Some(Visibility::Protected));
|
||||
assert_eq!(vis_of(&v, "c"), None);
|
||||
assert_eq!(vis_of(&v, "#d"), Some(Visibility::Private));
|
||||
|
||||
let py = "class K:\n def _hidden(self): pass\n def __init__(self): pass\n def shown(self): pass\n";
|
||||
let v = visibilities("python", py);
|
||||
assert_eq!(vis_of(&v, "_hidden"), Some(Visibility::Private));
|
||||
assert_eq!(vis_of(&v, "__init__"), None);
|
||||
assert_eq!(vis_of(&v, "shown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_visibility_matches_by_name_and_position() {
|
||||
fn clear(items: &mut [OutlineItem]) {
|
||||
for i in items {
|
||||
i.visibility = None;
|
||||
clear(&mut i.children);
|
||||
}
|
||||
}
|
||||
let src = "struct S { a: i32 }\nfn f() {}\n";
|
||||
let ts = syntax().outline(&lang("rust"), src);
|
||||
let mut lsp = ts.clone();
|
||||
clear(&mut lsp);
|
||||
lsp[1].name = "renamed".into();
|
||||
crate::merge_visibility(&mut lsp, &ts);
|
||||
assert_eq!(lsp[0].visibility, Some(Visibility::Private));
|
||||
assert_eq!(lsp[0].children[0].visibility, Some(Visibility::Private));
|
||||
assert_eq!(lsp[1].visibility, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling_regions_cover_declared_names_only() {
|
||||
let src = "// note\nfn parse_input(raw_text: &str) -> Value { let mut counter = helper(raw_text); \"hi\" }\n";
|
||||
let found: Vec<(&str, SpellingKind)> =
|
||||
syntax().spelling_regions(&lang("rust"), src).unwrap().iter().map(|r| (&src[r.start..r.end], r.kind)).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![
|
||||
("// note", SpellingKind::Comment),
|
||||
("parse_input", SpellingKind::Name),
|
||||
("raw_text", SpellingKind::Name),
|
||||
("counter", SpellingKind::Name),
|
||||
("\"hi\"", SpellingKind::String),
|
||||
]
|
||||
);
|
||||
let py = "def load_config(path, retries=3):\n \"\"\"Doc.\"\"\"\n total = 0\n";
|
||||
let names: Vec<&str> = syntax()
|
||||
.spelling_regions(&lang("python"), py)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r.kind == SpellingKind::Name)
|
||||
.map(|r| &py[r.start..r.end])
|
||||
.collect();
|
||||
assert_eq!(names, ["load_config", "path", "retries", "total"]);
|
||||
}
|
||||
@@ -157,6 +157,7 @@ pub fn client_capabilities() -> Value {
|
||||
"augmentsSyntaxTokens": true
|
||||
},
|
||||
"inlayHint": { "dynamicRegistration": false },
|
||||
"inlineCompletion": { "dynamicRegistration": true },
|
||||
"callHierarchy": { "dynamicRegistration": false },
|
||||
"typeHierarchy": { "dynamicRegistration": false }
|
||||
},
|
||||
@@ -204,6 +205,7 @@ pub fn capability_key(method: &str) -> Option<&'static str> {
|
||||
"textDocument/formatting" => "documentFormattingProvider",
|
||||
"textDocument/rangeFormatting" => "documentRangeFormattingProvider",
|
||||
"textDocument/inlayHint" => "inlayHintProvider",
|
||||
"textDocument/inlineCompletion" => "inlineCompletionProvider",
|
||||
"textDocument/semanticTokens" | "textDocument/semanticTokens/full" => "semanticTokensProvider",
|
||||
"textDocument/codeLens" => "codeLensProvider",
|
||||
"textDocument/documentLink" => "documentLinkProvider",
|
||||
|
||||
@@ -434,6 +434,101 @@ pub fn lsp_completion_item(text: &DocText, enc: Encoding, item: &CompletionItem)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- inline completion
|
||||
|
||||
/// `InlineCompletionList | InlineCompletionItem[] | null` → items (malformed ones skipped).
|
||||
pub fn inline_completion_items(conv: &Conv, path: &Path, v: Value, server: ServerId) -> Vec<InlineCompletionItem> {
|
||||
let items = match v {
|
||||
Value::Array(a) => a,
|
||||
Value::Object(mut o) => match o.remove("items") {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
};
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|it| {
|
||||
let insert_text = match it.get("insertText")? {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Object(o) if o.get("kind").and_then(Value::as_str) == Some("snippet") => {
|
||||
snippet_text(o.get("value")?.as_str()?)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let range = it
|
||||
.get("range")
|
||||
.and_then(|r| serde_json::from_value::<lsp::Range>(r.clone()).ok())
|
||||
.map(|r| conv.range(path, r));
|
||||
let filter_text = it.get("filterText").and_then(Value::as_str).map(str::to_owned);
|
||||
let cmd = it.get("command").and_then(|c| serde_json::from_value::<lsp::Command>(c.clone()).ok());
|
||||
Some(InlineCompletionItem { insert_text, range, filter_text, command: cmd.map(command), server })
|
||||
})
|
||||
.filter(|i| !i.insert_text.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text a snippet inserts with every placeholder at its default:
|
||||
/// `foo(${1:a}, $2)$0` → `foo(a, )`; a choice takes its first option.
|
||||
pub fn snippet_text(snippet: &str) -> String {
|
||||
fn parse(chars: &[char], i: &mut usize, out: &mut String, nested: bool) {
|
||||
while let Some(&ch) = chars.get(*i) {
|
||||
let next = chars.get(*i + 1).copied();
|
||||
match ch {
|
||||
'\\' if next.is_some() => {
|
||||
out.extend(next);
|
||||
*i += 2;
|
||||
}
|
||||
'}' if nested => {
|
||||
*i += 1;
|
||||
return;
|
||||
}
|
||||
'$' if next.is_some_and(|c| c.is_ascii_digit()) => {
|
||||
*i += 1;
|
||||
while chars.get(*i).is_some_and(char::is_ascii_digit) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
'$' if next == Some('{') => {
|
||||
*i += 2;
|
||||
while chars.get(*i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_') {
|
||||
*i += 1;
|
||||
}
|
||||
match chars.get(*i) {
|
||||
Some(':') => {
|
||||
*i += 1;
|
||||
parse(chars, i, out, true);
|
||||
}
|
||||
Some('|') => {
|
||||
*i += 1;
|
||||
let mut first = true;
|
||||
while let Some(&c) = chars.get(*i).filter(|c| **c != '|') {
|
||||
first &= c != ',';
|
||||
if first {
|
||||
out.push(c);
|
||||
}
|
||||
*i += 1;
|
||||
}
|
||||
*i = (*i + 2).min(chars.len());
|
||||
}
|
||||
// `${1}`, or a variable without a default.
|
||||
Some('}') => *i += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
out.push(ch);
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let chars: Vec<char> = snippet.chars().collect();
|
||||
let mut out = String::new();
|
||||
parse(&chars, &mut 0, &mut out, false);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- commands & actions
|
||||
|
||||
pub fn command(c: lsp::Command) -> Command {
|
||||
@@ -614,6 +709,7 @@ pub fn document_symbols(conv: &Conv, path: &Path, r: lsp::DocumentSymbolResponse
|
||||
range,
|
||||
selection_range: range,
|
||||
children: Vec::new(),
|
||||
visibility: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -632,6 +728,7 @@ fn document_symbol(conv: &Conv, path: &Path, s: lsp::DocumentSymbol) -> OutlineI
|
||||
range: conv.range(path, s.range),
|
||||
selection_range: conv.range(path, s.selection_range),
|
||||
children: s.children.unwrap_or_default().into_iter().map(|c| document_symbol(conv, path, c)).collect(),
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +976,7 @@ pub fn diagnostic(conv: &Conv, path: &Path, d: lsp::Diagnostic, server_name: &st
|
||||
}),
|
||||
unnecessary: tags.contains(&lsp::DiagnosticTag::UNNECESSARY),
|
||||
deprecated: tags.contains(&lsp::DiagnosticTag::DEPRECATED),
|
||||
server: Some(server_name.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,6 +990,25 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn snippet_text_takes_placeholder_defaults() {
|
||||
assert_eq!(snippet_text("foo(${1:a}, $2)$0"), "foo(a, )");
|
||||
assert_eq!(snippet_text("${1:outer ${2:inner}} \\$x ${3|one,two|}${TM_FILENAME}"), "outer inner $x one");
|
||||
assert_eq!(snippet_text("plain"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_completion_shapes() {
|
||||
let conv = Conv::utf16();
|
||||
let path = Path::new("/a.rs");
|
||||
let list = inline_completion_items(&conv, path, json!([{ "insertText": "x" }]), ServerId(1));
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(inline_completion_items(&conv, path, Value::Null, ServerId(1)).is_empty());
|
||||
assert!(
|
||||
inline_completion_items(&conv, path, json!({ "items": [{ "label": "no text" }] }), ServerId(1)).is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
fn p(l: u32, c: u32) -> Pos {
|
||||
Pos::new(l, c)
|
||||
}
|
||||
|
||||
@@ -804,6 +804,40 @@ impl LspManager {
|
||||
out.finish(CompletionList { is_incomplete: false, items: Vec::new() })
|
||||
}
|
||||
|
||||
/// LSP 3.18 `textDocument/inlineCompletion` from every server advertising
|
||||
/// `inlineCompletionProvider` (`automatic`: triggered by typing, not explicitly).
|
||||
pub async fn inline_completion(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
automatic: bool,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Vec<InlineCompletionItem>> {
|
||||
let (snap, targets) = self.doc_targets(doc, "inline completion", |c| c.has("inlineCompletionProvider")).await?;
|
||||
let requests = targets.iter().map(|t| {
|
||||
let mut params = snap.pos_params(t, pos);
|
||||
params["context"] = json!({ "triggerKind": if automatic { 2 } else { 1 } });
|
||||
let cancel = &cancel;
|
||||
async move {
|
||||
let r =
|
||||
self.call::<_, Value>(t, "textDocument/inlineCompletion", params, Some(cancel), COMPLETION_TIMEOUT);
|
||||
(t, r.await)
|
||||
}
|
||||
});
|
||||
let mut out = Outcome::new();
|
||||
for (t, result) in join_all(requests).await {
|
||||
match result {
|
||||
Ok(v) => {
|
||||
let conv = self.conv(t.enc());
|
||||
let items = convert::inline_completion_items(&conv, &snap.path, v, t.server.id);
|
||||
out.value.get_or_insert_with(Vec::new).extend(items);
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
}
|
||||
}
|
||||
out.finish(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn resolve_completion(&self, doc: DocId, item: CompletionItem) -> IdeResult<CompletionItem> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(item) };
|
||||
|
||||
@@ -145,6 +145,21 @@ pub struct Command {
|
||||
pub arguments: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// One `textDocument/inlineCompletion` suggestion (ghost text at the caret).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InlineCompletionItem {
|
||||
/// Text that replaces `range`; snippets arrive as their plain text.
|
||||
pub insert_text: String,
|
||||
/// Replaced range; `None` inserts at the caret.
|
||||
pub range: Option<Range>,
|
||||
/// What the typed text is matched against while the suggestion stays shown (default: `insert_text`).
|
||||
pub filter_text: Option<String>,
|
||||
/// Run after the suggestion is accepted.
|
||||
pub command: Option<Command>,
|
||||
pub server: ServerId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeAction {
|
||||
|
||||
@@ -415,6 +415,42 @@ async fn completion_mapping_merge_and_resolve() {
|
||||
assert!(b.requested("completionItem/resolve").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn inline_completion_only_from_servers_that_advertise_it() {
|
||||
let h = harness(&[
|
||||
("plain", incremental_caps()),
|
||||
("ghost", json!({ "textDocumentSync": 2, "inlineCompletionProvider": {} })),
|
||||
("late", json!({ "textDocumentSync": 2 })),
|
||||
]);
|
||||
let (doc, _) = h.open("a.mk", "let x = fo");
|
||||
for name in ["plain", "ghost", "late"] {
|
||||
h.wait_status(name, ServerStatus::Ready).await;
|
||||
}
|
||||
let (plain, ghost) = (h.launcher.state("plain"), h.launcher.state("ghost"));
|
||||
let init = &plain.requested("initialize")[0];
|
||||
assert_eq!(init["capabilities"]["textDocument"]["inlineCompletion"], json!({ "dynamicRegistration": true }));
|
||||
ghost.respond(
|
||||
"textDocument/inlineCompletion",
|
||||
json!({ "items": [
|
||||
{ "insertText": "foo(1)", "range": {"start":{"line":0,"character":8},"end":{"line":0,"character":10}},
|
||||
"command": { "title": "accepted", "command": "ghost.accepted", "arguments": [7] } },
|
||||
{ "insertText": { "kind": "snippet", "value": "foo(${1:a}, $2)$0" }, "filterText": "fo" },
|
||||
{ "insertText": "" }
|
||||
] }),
|
||||
);
|
||||
let items = h.mgr.inline_completion(doc, Pos::new(0, 10), true, Default::default()).await.unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].insert_text, "foo(1)");
|
||||
assert_eq!(items[0].range, Some(rng(0, 8, 0, 10)));
|
||||
assert_eq!(items[0].command.as_ref().unwrap().command, "ghost.accepted");
|
||||
assert_eq!(items[1].insert_text, "foo(a, )");
|
||||
assert_eq!(items[1].filter_text.as_deref(), Some("fo"));
|
||||
let req = &ghost.requested("textDocument/inlineCompletion")[0];
|
||||
assert_eq!(req["context"], json!({ "triggerKind": 2 }));
|
||||
assert_eq!(req["position"], json!({ "line": 0, "character": 10 }));
|
||||
assert!(plain.requested("textDocument/inlineCompletion").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -233,6 +233,7 @@ mod tests {
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ specta.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
tokio.workspace = true
|
||||
portable-pty.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
|
||||
+208
-21
@@ -12,9 +12,25 @@
|
||||
//! is-test = false
|
||||
//! debug-adapter = "codelldb"
|
||||
//! allow-parallel = true
|
||||
//! folder = "Servers"
|
||||
//! params = { mode = "command" }
|
||||
//! env-files = [".env"]
|
||||
//! include-system-env = false
|
||||
//! stdin-file = "input.txt"
|
||||
//! output-file = "out/server.log"
|
||||
//! before-launch = [{ kind = "build" }, { kind = "run", target = "user:migrate" }]
|
||||
//! emulate-terminal = true
|
||||
//! stop-command = "docker stop server"
|
||||
//!
|
||||
//! [[templates]] # per-type defaults for new configurations
|
||||
//! kind = "npm"
|
||||
//! env = { NODE_ENV = "development" }
|
||||
//! ```
|
||||
//!
|
||||
//! Configurations not stored as project files live in the same format in the
|
||||
//! workspace-local, git-ignored `run.local.toml`.
|
||||
|
||||
use crate::RunConfig;
|
||||
use crate::{BeforeLaunchTask, RunConfig, RunExtras};
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -23,16 +39,30 @@ use std::path::{Path, PathBuf};
|
||||
use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, value};
|
||||
|
||||
const FILE: &str = "run.toml";
|
||||
const LOCAL_FILE: &str = "run.local.toml";
|
||||
const CONFIGURATIONS: &str = "configurations";
|
||||
const TEMPLATES: &str = "templates";
|
||||
|
||||
/// Where run configurations are written.
|
||||
pub(crate) fn config_path(root: &Path) -> PathBuf {
|
||||
project_dir::path(root, FILE)
|
||||
}
|
||||
|
||||
/// Workspace-local configurations ("Store as project file" off).
|
||||
pub(crate) fn local_path(root: &Path) -> PathBuf {
|
||||
project_dir::path(root, LOCAL_FILE)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FileDoc {
|
||||
#[serde(default)]
|
||||
configurations: Vec<FileConfig>,
|
||||
#[serde(default)]
|
||||
templates: Vec<FileConfig>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -54,6 +84,33 @@ struct FileConfig {
|
||||
debug_adapter: Option<String>,
|
||||
#[serde(default, alias = "allowParallel", alias = "allow_parallel")]
|
||||
allow_parallel: bool,
|
||||
#[serde(default)]
|
||||
params: BTreeMap<String, String>,
|
||||
folder: Option<String>,
|
||||
#[serde(default = "yes", alias = "includeSystemEnv")]
|
||||
include_system_env: bool,
|
||||
#[serde(default, alias = "envFiles")]
|
||||
env_files: Vec<String>,
|
||||
#[serde(alias = "stdinFile")]
|
||||
stdin_file: Option<String>,
|
||||
#[serde(alias = "outputFile")]
|
||||
output_file: Option<String>,
|
||||
#[serde(default, alias = "beforeLaunch")]
|
||||
before_launch: Vec<BeforeLaunchTask>,
|
||||
#[serde(default)]
|
||||
compound: Vec<String>,
|
||||
#[serde(default, alias = "showOnStdout")]
|
||||
show_on_stdout: bool,
|
||||
#[serde(default, alias = "showOnStderr")]
|
||||
show_on_stderr: bool,
|
||||
#[serde(alias = "openToolWindow")]
|
||||
open_tool_window: Option<bool>,
|
||||
#[serde(default, alias = "focusToolWindow")]
|
||||
focus_tool_window: bool,
|
||||
#[serde(default, alias = "emulateTerminal")]
|
||||
emulate_terminal: bool,
|
||||
#[serde(alias = "stopCommand")]
|
||||
stop_command: Option<String>,
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
@@ -68,17 +125,22 @@ fn slug(s: &str) -> String {
|
||||
out.trim_matches('-').to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn load(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
let path = config_path(root);
|
||||
let text = match fs::read_to_string(&path) {
|
||||
fn read_doc(path: &Path) -> IdeResult<Option<FileDoc>> {
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let doc: FileDoc =
|
||||
toml::from_str(&text).map_err(|e| IdeError::invalid(format!("{}: {}", path.display(), e.message())))?;
|
||||
let mut out = Vec::with_capacity(doc.configurations.len());
|
||||
for (i, c) in doc.configurations.into_iter().enumerate() {
|
||||
toml::from_str(&text).map(Some).map_err(|e| IdeError::invalid(format!("{}: {}", path.display(), e.message())))
|
||||
}
|
||||
|
||||
fn non_empty(s: Option<String>) -> Option<String> {
|
||||
s.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
fn convert(configs: Vec<FileConfig>, local: bool) -> Vec<RunConfig> {
|
||||
let mut out = Vec::with_capacity(configs.len());
|
||||
for (i, c) in configs.into_iter().enumerate() {
|
||||
let name = c.name.filter(|n| !n.is_empty()).unwrap_or_else(|| {
|
||||
if c.command.is_empty() { format!("Configuration {}", i + 1) } else { c.command.clone() }
|
||||
});
|
||||
@@ -95,11 +157,42 @@ pub(crate) fn load(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
is_test: c.is_test,
|
||||
debug_adapter: c.debug_adapter.filter(|d| !d.is_empty()),
|
||||
allow_parallel: c.allow_parallel,
|
||||
extras: RunExtras {
|
||||
params: c.params,
|
||||
folder: non_empty(c.folder),
|
||||
local,
|
||||
include_system_env: c.include_system_env,
|
||||
env_files: c.env_files.into_iter().filter(|f| !f.trim().is_empty()).collect(),
|
||||
stdin_file: non_empty(c.stdin_file),
|
||||
output_file: non_empty(c.output_file),
|
||||
before_launch: c.before_launch.into_iter().filter(|t| !t.kind.is_empty()).collect(),
|
||||
compound: c.compound,
|
||||
show_on_stdout: c.show_on_stdout,
|
||||
show_on_stderr: c.show_on_stderr,
|
||||
open_tool_window: c.open_tool_window,
|
||||
focus_tool_window: c.focus_tool_window,
|
||||
emulate_terminal: c.emulate_terminal,
|
||||
stop_command: non_empty(c.stop_command),
|
||||
},
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Project configurations (`run.toml`) followed by workspace-local ones (`run.local.toml`).
|
||||
pub(crate) fn load(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
let mut out = read_doc(&config_path(root))?.map(|d| convert(d.configurations, false)).unwrap_or_default();
|
||||
if let Some(local) = read_doc(&local_path(root))? {
|
||||
let ids: Vec<String> = out.iter().map(|c| c.id.clone()).collect();
|
||||
out.extend(convert(local.configurations, true).into_iter().filter(|c| !ids.contains(&c.id)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn load_templates(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
Ok(read_doc(&config_path(root))?.map(|d| convert(d.templates, false)).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn to_table(c: &RunConfig) -> Table {
|
||||
let mut t = Table::new();
|
||||
t.insert("id", value(&c.id));
|
||||
@@ -128,12 +221,66 @@ fn to_table(c: &RunConfig) -> Table {
|
||||
if c.allow_parallel {
|
||||
t.insert("allow-parallel", value(true));
|
||||
}
|
||||
let x = &c.extras;
|
||||
if let Some(folder) = &x.folder {
|
||||
t.insert("folder", value(folder));
|
||||
}
|
||||
if !x.params.is_empty() {
|
||||
let mut params = InlineTable::new();
|
||||
for (k, v) in &x.params {
|
||||
params.insert(k, v.into());
|
||||
}
|
||||
t.insert("params", value(params));
|
||||
}
|
||||
if !x.include_system_env {
|
||||
t.insert("include-system-env", value(false));
|
||||
}
|
||||
if !x.env_files.is_empty() {
|
||||
t.insert("env-files", value(x.env_files.iter().collect::<Array>()));
|
||||
}
|
||||
if let Some(f) = &x.stdin_file {
|
||||
t.insert("stdin-file", value(f));
|
||||
}
|
||||
if let Some(f) = &x.output_file {
|
||||
t.insert("output-file", value(f));
|
||||
}
|
||||
if !x.before_launch.is_empty() {
|
||||
let mut tasks = Array::new();
|
||||
for task in &x.before_launch {
|
||||
let mut it = InlineTable::new();
|
||||
it.insert("kind", task.kind.as_str().into());
|
||||
if !task.target.is_empty() {
|
||||
it.insert("target", task.target.as_str().into());
|
||||
}
|
||||
tasks.push(it);
|
||||
}
|
||||
t.insert("before-launch", value(tasks));
|
||||
}
|
||||
if !x.compound.is_empty() {
|
||||
t.insert("compound", value(x.compound.iter().collect::<Array>()));
|
||||
}
|
||||
for (key, on) in [
|
||||
("show-on-stdout", x.show_on_stdout),
|
||||
("show-on-stderr", x.show_on_stderr),
|
||||
("focus-tool-window", x.focus_tool_window),
|
||||
("emulate-terminal", x.emulate_terminal),
|
||||
] {
|
||||
if on {
|
||||
t.insert(key, value(true));
|
||||
}
|
||||
}
|
||||
if let Some(open) = x.open_tool_window {
|
||||
t.insert("open-tool-window", value(open));
|
||||
}
|
||||
if let Some(stop) = &x.stop_command {
|
||||
t.insert("stop-command", value(stop));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub(crate) fn save(root: &Path, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
let path = config_path(root);
|
||||
let existing = match fs::read_to_string(&path) {
|
||||
/// Replaces the `key` array of tables in the file at `path`, keeping unrelated keys and comments.
|
||||
fn write_array(path: &Path, key: &str, configs: &[&RunConfig]) -> IdeResult<()> {
|
||||
let existing = match fs::read_to_string(path) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -141,25 +288,65 @@ pub(crate) fn save(root: &Path, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
if existing.is_none() && configs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Keep unrelated keys and comments; a file we cannot parse is replaced.
|
||||
// A file we cannot parse is replaced.
|
||||
let mut doc = existing.as_deref().and_then(|t| t.parse::<DocumentMut>().ok()).unwrap_or_default();
|
||||
let mut array = ArrayOfTables::new();
|
||||
for c in configs {
|
||||
array.push(to_table(c));
|
||||
}
|
||||
if configs.is_empty() {
|
||||
doc.remove("configurations");
|
||||
doc.remove(key);
|
||||
} else {
|
||||
doc.insert("configurations", Item::ArrayOfTables(array));
|
||||
let mut array = ArrayOfTables::new();
|
||||
for c in configs {
|
||||
array.push(to_table(c));
|
||||
}
|
||||
doc.insert(key, Item::ArrayOfTables(array));
|
||||
}
|
||||
let text = doc.to_string();
|
||||
if existing.as_deref() == Some(text.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
fs::write(&tmp, doc.to_string())?;
|
||||
fs::rename(&tmp, &path).inspect_err(|_| {
|
||||
fs::write(&tmp, text)?;
|
||||
fs::rename(&tmp, path).inspect_err(|_| {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keeps the workspace-local file out of version control (`.openidea/.gitignore`).
|
||||
fn ignore_local(root: &Path) -> IdeResult<()> {
|
||||
let entry = format!("/{LOCAL_FILE}");
|
||||
let file = project_dir::path(root, ".gitignore");
|
||||
let current = fs::read_to_string(&file).unwrap_or_default();
|
||||
if current.lines().any(|l| l.trim() == entry) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut text = current;
|
||||
if text.is_empty() {
|
||||
text.push_str("# Per-user IDE state\n");
|
||||
} else if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&entry);
|
||||
text.push('\n');
|
||||
if let Some(dir) = file.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
fs::write(file, text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn save(root: &Path, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
let (local, shared): (Vec<&RunConfig>, Vec<&RunConfig>) = configs.iter().partition(|c| c.extras.local);
|
||||
write_array(&config_path(root), CONFIGURATIONS, &shared)?;
|
||||
if !local.is_empty() {
|
||||
ignore_local(root)?;
|
||||
}
|
||||
write_array(&local_path(root), CONFIGURATIONS, &local)
|
||||
}
|
||||
|
||||
pub(crate) fn save_templates(root: &Path, templates: &[RunConfig]) -> IdeResult<()> {
|
||||
write_array(&config_path(root), TEMPLATES, &templates.iter().collect::<Vec<_>>())
|
||||
}
|
||||
@@ -38,6 +38,7 @@ fn config(id: impl Into<String>, name: impl Into<String>, kind: &str, command: &
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `.env` files ("Paths to .env files" / EnvFile): `KEY=value` lines, `export`
|
||||
//! prefixes, `#` comments, single- and double-quoted values (escapes in double
|
||||
//! quotes) and `${VAR}` references to earlier variables or the base environment.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Parses `text`; `lookup` resolves `${VAR}` / `$VAR` not defined earlier in the file.
|
||||
pub fn parse(text: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let mut lines = text.lines();
|
||||
while let Some(raw) = lines.next() {
|
||||
let line = raw.trim_start_matches('\u{feff}').trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let line = line.strip_prefix("export ").map(str::trim_start).unwrap_or(line);
|
||||
let Some((key, rest)) = line.split_once('=') else { continue };
|
||||
let key = key.trim();
|
||||
if key.is_empty() || key.contains(char::is_whitespace) {
|
||||
continue;
|
||||
}
|
||||
let rest = rest.trim_start();
|
||||
let value = if let Some(body) = rest.strip_prefix('\'') {
|
||||
quoted(body, '\'', &mut lines).unwrap_or_else(|| rest.to_owned())
|
||||
} else if let Some(body) = rest.strip_prefix('"') {
|
||||
let v = quoted(body, '"', &mut lines).unwrap_or_else(|| rest.to_owned());
|
||||
expand(&unescape(&v), &out, lookup)
|
||||
} else {
|
||||
let v = match rest.find(" #") {
|
||||
Some(i) => &rest[..i],
|
||||
None => rest,
|
||||
};
|
||||
expand(v.trim_end(), &out, lookup)
|
||||
};
|
||||
out.retain(|(k, _)| k != key);
|
||||
out.push((key.to_owned(), value));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The body of a quoted value up to the closing `quote`, which may be on a later line.
|
||||
fn quoted<'a>(first: &str, quote: char, lines: &mut impl Iterator<Item = &'a str>) -> Option<String> {
|
||||
let mut text = first.to_owned();
|
||||
loop {
|
||||
let mut escaped = false;
|
||||
for (i, c) in text.char_indices() {
|
||||
if quote == '"' && c == '\\' && !escaped {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if c == quote && !escaped {
|
||||
return Some(text[..i].to_owned());
|
||||
}
|
||||
escaped = false;
|
||||
}
|
||||
text.push('\n');
|
||||
text.push_str(lines.next()?);
|
||||
}
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('n') => out.push('\n'),
|
||||
Some('t') => out.push('\t'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some(other) => out.push(other),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn expand(value: &str, earlier: &[(String, String)], lookup: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let resolve = |name: &str| {
|
||||
earlier
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(k, _)| k == name)
|
||||
.map(|(_, v)| v.clone())
|
||||
.or_else(|| lookup(name))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let bytes = value.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < value.len() {
|
||||
if bytes[i] == b'$' {
|
||||
if bytes.get(i + 1) == Some(&b'{')
|
||||
&& let Some(end) = value[i + 2..].find('}')
|
||||
{
|
||||
out.push_str(&resolve(&value[i + 2..i + 2 + end]));
|
||||
i += end + 3;
|
||||
continue;
|
||||
}
|
||||
let name_len = value[i + 1..].bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count();
|
||||
if name_len > 0 {
|
||||
out.push_str(&resolve(&value[i + 1..i + 1 + name_len]));
|
||||
i += name_len + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let c = value[i..].chars().next().unwrap_or_default();
|
||||
out.push(c);
|
||||
i += c.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Variables of every file in order (later files win); unreadable files are an error.
|
||||
pub fn load(
|
||||
paths: &[std::path::PathBuf],
|
||||
base: &BTreeMap<String, String>,
|
||||
) -> std::io::Result<BTreeMap<String, String>> {
|
||||
let mut vars: BTreeMap<String, String> = BTreeMap::new();
|
||||
for path in paths {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", path.display())))?;
|
||||
let lookup = |name: &str| vars.get(name).or_else(|| base.get(name)).cloned();
|
||||
let parsed = parse(&text, &lookup);
|
||||
vars.extend(parsed);
|
||||
}
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn none(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_common_forms() {
|
||||
let text = "# comment\nA=1\nexport B = two words # trailing\nC='lit $A'\nD=\"x\\ny $A ${B}\"\nE=\"multi\nline\"\n\nbad line\nF=\n";
|
||||
let v = parse(text, &none);
|
||||
let get = |k: &str| v.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str());
|
||||
assert_eq!(get("A"), Some("1"));
|
||||
assert_eq!(get("B"), Some("two words"));
|
||||
assert_eq!(get("C"), Some("lit $A"));
|
||||
assert_eq!(get("D"), Some("x\ny 1 two words"));
|
||||
assert_eq!(get("E"), Some("multi\nline"));
|
||||
assert_eq!(get("F"), Some(""));
|
||||
assert_eq!(v.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_fall_back_to_base() {
|
||||
let v = parse("P=$HOME/bin:${MISSING}x", &|n| (n == "HOME").then(|| "/h".to_owned()));
|
||||
assert_eq!(v, [("P".to_owned(), "/h/bin:x".to_owned())]);
|
||||
}
|
||||
}
|
||||
+126
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
mod config;
|
||||
mod detect;
|
||||
pub mod envfile;
|
||||
pub mod junit;
|
||||
mod process;
|
||||
mod utf8;
|
||||
@@ -38,6 +39,113 @@ pub struct RunConfig {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
#[specta(optional)]
|
||||
pub allow_parallel: bool,
|
||||
/// Options of user-defined configurations (all optional; old `run.toml` files load unchanged).
|
||||
#[serde(flatten)]
|
||||
pub extras: RunExtras,
|
||||
}
|
||||
|
||||
/// A step run before a configuration launches; a failing step aborts the launch.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BeforeLaunchTask {
|
||||
/// `run` (another configuration, `target` = its id) | `build` | `tool` (external tool id) |
|
||||
/// `command` (a shell command line) | `toolWindow` (tool window id).
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunExtras {
|
||||
/// Values of the configuration type's own form fields (the command line derives from them).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
#[specta(optional)]
|
||||
pub params: BTreeMap<String, String>,
|
||||
/// Folder in the Run/Debug Configurations tree.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub folder: Option<String>,
|
||||
/// Stored in the workspace (`.openidea/run.local.toml`, not shared) instead of `run.toml`.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
#[specta(optional)]
|
||||
pub local: bool,
|
||||
/// Off: the process gets only the configured variables.
|
||||
#[serde(default = "yes", skip_serializing_if = "is_true")]
|
||||
#[specta(optional)]
|
||||
pub include_system_env: bool,
|
||||
/// `.env` files (relative to the project root) loaded before `env`, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[specta(optional)]
|
||||
pub env_files: Vec<String>,
|
||||
/// "Redirect input from": the process reads this file as stdin.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub stdin_file: Option<String>,
|
||||
/// "Save console output to file" (overwritten on each run).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub output_file: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[specta(optional)]
|
||||
pub before_launch: Vec<BeforeLaunchTask>,
|
||||
/// Compound configurations: ids of the configurations started together.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
#[specta(optional)]
|
||||
pub compound: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
#[specta(optional)]
|
||||
pub show_on_stdout: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
#[specta(optional)]
|
||||
pub show_on_stderr: bool,
|
||||
/// Overrides Settings › Run › "Show the Run tool window when a process starts".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub open_tool_window: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
#[specta(optional)]
|
||||
pub focus_tool_window: bool,
|
||||
/// "Emulate terminal in output console": the process runs on a pseudo-terminal
|
||||
/// (colours, line editing, full-screen programs); the console renders it as a terminal.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
#[specta(optional)]
|
||||
pub emulate_terminal: bool,
|
||||
/// Shell command line run on Stop before the process is signalled (e.g. `docker stop <name>`),
|
||||
/// with the run's working directory and environment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub stop_command: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RunExtras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: BTreeMap::new(),
|
||||
folder: None,
|
||||
local: false,
|
||||
include_system_env: true,
|
||||
env_files: Vec::new(),
|
||||
stdin_file: None,
|
||||
output_file: None,
|
||||
before_launch: Vec::new(),
|
||||
compound: Vec::new(),
|
||||
show_on_stdout: false,
|
||||
show_on_stderr: false,
|
||||
open_tool_window: None,
|
||||
focus_tool_window: false,
|
||||
emulate_terminal: false,
|
||||
stop_command: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
@@ -63,6 +171,8 @@ pub struct RunInfo {
|
||||
pub name: String,
|
||||
pub pid: Option<u32>,
|
||||
pub running: bool,
|
||||
/// Runs on a pseudo-terminal: output is raw terminal data, input goes in keystroke by keystroke.
|
||||
pub pty: bool,
|
||||
}
|
||||
|
||||
/// Detects configs from package.json scripts, Cargo.toml targets, build.gradle(.kts),
|
||||
@@ -76,11 +186,26 @@ pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
config::load(root)
|
||||
}
|
||||
|
||||
/// Writes `<root>/.openidea/run.toml`, preserving unrelated content in the file.
|
||||
/// Writes `<root>/.openidea/run.toml` (and the workspace-local `run.local.toml` for
|
||||
/// configurations not stored as project files), preserving unrelated content.
|
||||
pub fn save_user_configs(root: &Path, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
config::save(root, configs)
|
||||
}
|
||||
|
||||
/// Configuration templates (per-type defaults for new configurations) from `run.toml`.
|
||||
pub fn load_templates(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
config::load_templates(root)
|
||||
}
|
||||
|
||||
pub fn save_templates(root: &Path, templates: &[RunConfig]) -> IdeResult<()> {
|
||||
config::save_templates(root, templates)
|
||||
}
|
||||
|
||||
/// The environment runs inherit (unless "Include system environment variables" is off).
|
||||
pub fn system_env() -> BTreeMap<String, String> {
|
||||
std::env::vars().collect()
|
||||
}
|
||||
|
||||
/// Path of the user run configuration file for `root`.
|
||||
pub fn user_configs_path(root: &Path) -> PathBuf {
|
||||
config::config_path(root)
|
||||
|
||||
+341
-33
@@ -1,13 +1,15 @@
|
||||
//! Process lifecycle for run configurations: spawn, stream output, stdin, stop.
|
||||
//! Process lifecycle for run configurations: spawn (on pipes, or on a pseudo-terminal
|
||||
//! for "Emulate terminal in output console"), stream output, stdin, stop.
|
||||
|
||||
use crate::utf8::OutputDecoder;
|
||||
use crate::{OutputChunk, RunConfig, RunInfo};
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, RunId, SharedSink};
|
||||
use parking_lot::Mutex;
|
||||
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{ChildStdin, Command, ExitStatus, Stdio};
|
||||
use std::process::{Command, ExitStatus, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
|
||||
@@ -20,15 +22,39 @@ const BATCH_BYTES: usize = 64 * 1024;
|
||||
const EXIT_DRAIN: Duration = Duration::from_millis(500);
|
||||
/// Delay between SIGINT and SIGKILL on [`ProcessManager::stop`].
|
||||
const STOP_GRACE: Duration = Duration::from_secs(3);
|
||||
/// Longest a configuration's stop command (e.g. `docker stop`) may take before signals follow.
|
||||
const STOP_COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// Finished runs kept for [`ProcessManager::list`].
|
||||
const MAX_FINISHED: usize = 20;
|
||||
/// PTY size until the console reports its own.
|
||||
const DEFAULT_PTY: (u16, u16) = (120, 30);
|
||||
|
||||
type OutputFn = Arc<dyn Fn(OutputChunk) + Send + Sync>;
|
||||
|
||||
/// Environment of a launched process: the inherited one (unless cleared) plus `vars`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct LaunchEnv {
|
||||
clear: bool,
|
||||
vars: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// A configuration's stop command and where it runs.
|
||||
struct StopCommand {
|
||||
line: String,
|
||||
cwd: PathBuf,
|
||||
env: LaunchEnv,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
id: RunId,
|
||||
config_id: String,
|
||||
name: String,
|
||||
pid: Option<u32>,
|
||||
stdin: Mutex<Option<ChildStdin>>,
|
||||
stdin: Mutex<Option<Box<dyn Write + Send>>>,
|
||||
/// The PTY master of a terminal-emulated run (resize; dropped on exit).
|
||||
pty: Option<Mutex<Option<Box<dyn MasterPty + Send>>>>,
|
||||
stop: Option<StopCommand>,
|
||||
output: OutputFn,
|
||||
exited: AtomicBool,
|
||||
stopping: AtomicBool,
|
||||
}
|
||||
@@ -41,6 +67,7 @@ impl Entry {
|
||||
name: self.name.clone(),
|
||||
pid: self.pid,
|
||||
running: !self.exited.load(Ordering::Acquire),
|
||||
pty: self.pty.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,9 +90,14 @@ enum Stream {
|
||||
Stderr,
|
||||
}
|
||||
|
||||
enum Exit {
|
||||
Pipes(std::io::Result<ExitStatus>),
|
||||
Pty(std::io::Result<portable_pty::ExitStatus>),
|
||||
}
|
||||
|
||||
enum Msg {
|
||||
Data(Stream, Vec<u8>),
|
||||
Exited(std::io::Result<ExitStatus>),
|
||||
Exited(Exit),
|
||||
}
|
||||
|
||||
/// The program and arguments actually executed, plus the echoed command line.
|
||||
@@ -154,6 +186,23 @@ fn resolve_program(command: &str, cwd: &Path, env: &BTreeMap<String, String>, wi
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
/// "Save console output to file": everything shown in the console is also written to `path`.
|
||||
fn tee_output(path: &Path, on_output: OutputFn) -> IdeResult<OutputFn> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let file = std::fs::File::create(path)
|
||||
.map_err(|e| IdeError::invalid(format!("cannot write console output to {}: {e}", path.display())))?;
|
||||
let file = Mutex::new(std::io::LineWriter::new(file));
|
||||
Ok(Arc::new(move |chunk: OutputChunk| {
|
||||
let text = match &chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => text,
|
||||
};
|
||||
let _ = file.lock().write_all(text.as_bytes());
|
||||
on_output(chunk);
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_cwd(root: &Path, cwd: Option<&Path>) -> PathBuf {
|
||||
match cwd {
|
||||
Some(c) if c.is_absolute() => c.to_path_buf(),
|
||||
@@ -184,6 +233,24 @@ impl ProcessManager {
|
||||
return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display())));
|
||||
}
|
||||
let inv = invocation(config, &cwd);
|
||||
let extras = &config.extras;
|
||||
if extras.emulate_terminal && extras.stdin_file.is_some() {
|
||||
return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal"));
|
||||
}
|
||||
let env = launch_env(root, config)?;
|
||||
let on_output: OutputFn = Arc::from(on_output);
|
||||
let on_output = match extras.output_file.as_deref() {
|
||||
Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?,
|
||||
None => on_output,
|
||||
};
|
||||
let stop = extras.stop_command.as_deref().map(str::trim).filter(|l| !l.is_empty()).map(|line| StopCommand {
|
||||
line: line.to_owned(),
|
||||
cwd: cwd.clone(),
|
||||
env: env.clone(),
|
||||
});
|
||||
if extras.emulate_terminal {
|
||||
return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&inv.program);
|
||||
if inv.raw_args {
|
||||
@@ -191,11 +258,21 @@ impl ProcessManager {
|
||||
} else {
|
||||
cmd.args(&inv.args);
|
||||
}
|
||||
cmd.current_dir(&cwd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
if matches!(config.kind.as_str(), "python" | "pytest") && !config.env.contains_key("PYTHONUNBUFFERED") {
|
||||
cmd.env("PYTHONUNBUFFERED", "1");
|
||||
}
|
||||
cmd.envs(&config.env);
|
||||
cmd.current_dir(&cwd).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let redirected = match extras.stdin_file.as_deref() {
|
||||
Some(file) => {
|
||||
let path = resolve_cwd(root, Some(Path::new(file)));
|
||||
let f = std::fs::File::open(&path)
|
||||
.map_err(|e| IdeError::not_found(format!("cannot redirect input from {}: {e}", path.display())))?;
|
||||
cmd.stdin(Stdio::from(f));
|
||||
true
|
||||
}
|
||||
None => {
|
||||
cmd.stdin(Stdio::piped());
|
||||
false
|
||||
}
|
||||
};
|
||||
apply_env(&mut cmd, &env);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -214,12 +291,19 @@ impl ProcessManager {
|
||||
})?;
|
||||
|
||||
let id = RunId::next();
|
||||
let stdin: Option<Box<dyn Write + Send>> = match child.stdin.take() {
|
||||
Some(pipe) if !redirected => Some(Box::new(pipe)),
|
||||
_ => None,
|
||||
};
|
||||
let entry = Arc::new(Entry {
|
||||
id,
|
||||
config_id: config.id.clone(),
|
||||
name: config.name.clone(),
|
||||
pid: Some(child.id()),
|
||||
stdin: Mutex::new(child.stdin.take()),
|
||||
stdin: Mutex::new(stdin),
|
||||
pty: None,
|
||||
stop,
|
||||
output: on_output.clone(),
|
||||
exited: AtomicBool::new(false),
|
||||
stopping: AtomicBool::new(false),
|
||||
});
|
||||
@@ -252,7 +336,7 @@ impl ProcessManager {
|
||||
.name(format!("openidea-run-wait-{}", id.0))
|
||||
.spawn(move || {
|
||||
let status = child.wait();
|
||||
let _ = waiter_tx.send(Msg::Exited(status));
|
||||
let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status)));
|
||||
})
|
||||
.map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?;
|
||||
|
||||
@@ -260,6 +344,94 @@ impl ProcessManager {
|
||||
Ok(entry.info())
|
||||
}
|
||||
|
||||
/// [`Self::start`] on a pseudo-terminal: one merged output stream of raw terminal data.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_pty(
|
||||
&self,
|
||||
config: &RunConfig,
|
||||
inv: &Invocation,
|
||||
cwd: &Path,
|
||||
env: &LaunchEnv,
|
||||
stop: Option<StopCommand>,
|
||||
options: &StartOptions,
|
||||
sink: SharedSink,
|
||||
on_output: OutputFn,
|
||||
) -> IdeResult<RunInfo> {
|
||||
let pair = native_pty_system()
|
||||
.openpty(pty_size(DEFAULT_PTY.0, DEFAULT_PTY.1))
|
||||
.map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?;
|
||||
let mut cmd = CommandBuilder::new(&inv.program);
|
||||
cmd.args(&inv.args);
|
||||
cmd.cwd(cwd);
|
||||
if env.clear {
|
||||
cmd.env_clear();
|
||||
}
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
for (k, v) in &env.vars {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = pair.slave.spawn_command(cmd).map_err(|e| {
|
||||
let text = e.to_string();
|
||||
if text.contains("No such file") || text.contains("not found") {
|
||||
IdeError::unavailable(format!("command not found: {}", config.command))
|
||||
} else {
|
||||
IdeError::unavailable(format!("failed to start `{}`: {text}", config.command))
|
||||
}
|
||||
})?;
|
||||
// The child holds its own copy of the slave; dropping ours lets reads hit EOF on exit.
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| IdeError::internal(format!("pty reader: {e}")))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| IdeError::internal(format!("pty writer: {e}")))?;
|
||||
|
||||
let id = RunId::next();
|
||||
let entry = Arc::new(Entry {
|
||||
id,
|
||||
config_id: config.id.clone(),
|
||||
name: config.name.clone(),
|
||||
pid: child.process_id(),
|
||||
stdin: Mutex::new(Some(writer)),
|
||||
pty: Some(Mutex::new(Some(pair.master))),
|
||||
stop,
|
||||
output: on_output.clone(),
|
||||
exited: AtomicBool::new(false),
|
||||
stopping: AtomicBool::new(false),
|
||||
});
|
||||
self.register(entry.clone());
|
||||
on_output(OutputChunk::System { text: format!("{}\n", inv.display) });
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let result = spawn_reader(id, Stream::Stdout, reader, tx.clone())
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone()));
|
||||
if let Err(e) = result {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
entry.exited.store(true, Ordering::Release);
|
||||
return Err(IdeError::internal(format!("failed to start output threads: {e}")));
|
||||
}
|
||||
std::thread::Builder::new()
|
||||
.name(format!("openidea-run-wait-{}", id.0))
|
||||
.spawn(move || {
|
||||
let status = child.wait();
|
||||
let _ = tx.send(Msg::Exited(Exit::Pty(status)));
|
||||
})
|
||||
.map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?;
|
||||
tracing::debug!(%id, config = %config.id, pid = ?entry.pid, "run started on a pty");
|
||||
Ok(entry.info())
|
||||
}
|
||||
|
||||
/// Resizes the pseudo-terminal of a terminal-emulated run.
|
||||
pub fn resize(&self, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
let entry = self.entry(id)?;
|
||||
let Some(pty) = &entry.pty else { return Err(IdeError::invalid("the process does not run in a terminal")) };
|
||||
match pty.lock().as_ref() {
|
||||
Some(master) => {
|
||||
master.resize(pty_size(cols, rows)).map_err(|e| IdeError::internal(format!("resize failed: {e}")))
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn register(&self, entry: Arc<Entry>) {
|
||||
let mut runs = self.runs.lock();
|
||||
runs.retain(|_, e| !(e.config_id == entry.config_id && e.exited.load(Ordering::Acquire)));
|
||||
@@ -293,9 +465,13 @@ impl ProcessManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closes the process's stdin (sends EOF).
|
||||
/// Closes the process's stdin (sends EOF; on a terminal, types ^D).
|
||||
pub fn close_stdin(&self, id: RunId) -> IdeResult<()> {
|
||||
self.entry(id)?.stdin.lock().take();
|
||||
let entry = self.entry(id)?;
|
||||
if entry.pty.is_some() {
|
||||
return self.write_stdin(id, "\u{4}");
|
||||
}
|
||||
entry.stdin.lock().take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -304,7 +480,8 @@ impl ProcessManager {
|
||||
self.stop_with_grace(id, STOP_GRACE)
|
||||
}
|
||||
|
||||
/// [`Self::stop`] with an explicit grace period before the kill.
|
||||
/// [`Self::stop`] with an explicit grace period before the kill. A configuration's
|
||||
/// stop command runs first; signals follow only if the process outlives it.
|
||||
pub fn stop_with_grace(&self, id: RunId, grace: Duration) -> IdeResult<()> {
|
||||
let entry = self.entry(id)?;
|
||||
if entry.exited.load(Ordering::Acquire) {
|
||||
@@ -316,19 +493,31 @@ impl ProcessManager {
|
||||
kill_tree(pid, true);
|
||||
return Ok(());
|
||||
}
|
||||
kill_tree(pid, false);
|
||||
if entry.stop.is_none() {
|
||||
kill_tree(pid, false);
|
||||
}
|
||||
let entry = entry.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("openidea-run-stop-{}", id.0))
|
||||
.spawn(move || {
|
||||
let deadline = Instant::now() + grace;
|
||||
while Instant::now() < deadline {
|
||||
if !group_alive(pid, &entry) {
|
||||
let exits_within = |limit: Duration| {
|
||||
let deadline = Instant::now() + limit;
|
||||
while Instant::now() < deadline {
|
||||
if !group_alive(pid, &entry) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
!group_alive(pid, &entry)
|
||||
};
|
||||
if let Some(stop) = &entry.stop {
|
||||
run_stop_command(stop, &entry.output);
|
||||
if exits_within(grace) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
kill_tree(pid, false);
|
||||
}
|
||||
if group_alive(pid, &entry) {
|
||||
if !exits_within(grace) {
|
||||
tracing::debug!(id = %entry.id, pid, "process ignored interrupt; killing");
|
||||
kill_tree(pid, true);
|
||||
}
|
||||
@@ -354,6 +543,90 @@ impl Drop for ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment a configuration's process gets on top of the inherited one: `.env`
|
||||
/// files, then the configured variables (later wins).
|
||||
fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
|
||||
let extras = &config.extras;
|
||||
let mut vars = if extras.env_files.is_empty() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
let base: BTreeMap<String, String> =
|
||||
if extras.include_system_env { std::env::vars().collect() } else { BTreeMap::new() };
|
||||
let paths: Vec<PathBuf> = extras.env_files.iter().map(|f| resolve_cwd(root, Some(Path::new(f)))).collect();
|
||||
crate::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))?
|
||||
};
|
||||
if matches!(config.kind.as_str(), "python" | "pytest")
|
||||
&& !config.env.contains_key("PYTHONUNBUFFERED")
|
||||
&& !vars.contains_key("PYTHONUNBUFFERED")
|
||||
{
|
||||
vars.insert("PYTHONUNBUFFERED".into(), "1".into());
|
||||
}
|
||||
vars.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
Ok(LaunchEnv { clear: !extras.include_system_env, vars })
|
||||
}
|
||||
|
||||
fn apply_env(cmd: &mut Command, env: &LaunchEnv) {
|
||||
if env.clear {
|
||||
cmd.env_clear();
|
||||
}
|
||||
cmd.envs(&env.vars);
|
||||
}
|
||||
|
||||
/// Runs `stop.line` through the platform shell and waits for it (bounded), echoing it
|
||||
/// and any error output to the console.
|
||||
fn run_stop_command(stop: &StopCommand, output: &OutputFn) {
|
||||
output(OutputChunk::System { text: format!("{}\n", stop.line) });
|
||||
let inv = shell_invocation(&stop.line, cfg!(windows), std::env::var("COMSPEC").ok());
|
||||
let mut cmd = Command::new(&inv.program);
|
||||
if inv.raw_args {
|
||||
add_raw_args(&mut cmd, &inv.args);
|
||||
} else {
|
||||
cmd.args(&inv.args);
|
||||
}
|
||||
cmd.current_dir(&stop.cwd).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
apply_env(&mut cmd, &stop.env);
|
||||
ide_core::process::no_window(&mut cmd);
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
output(OutputChunk::Stderr { text: format!("Cannot run the stop command: {e}\n") });
|
||||
return;
|
||||
}
|
||||
};
|
||||
let stderr = child.stderr.take().map(|mut pipe| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
})
|
||||
});
|
||||
let deadline = Instant::now() + STOP_COMMAND_TIMEOUT;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break Some(status),
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break None;
|
||||
}
|
||||
}
|
||||
};
|
||||
let err = stderr.and_then(|h| h.join().ok()).unwrap_or_default();
|
||||
if !status.is_some_and(|s| s.success()) && !err.trim().is_empty() {
|
||||
output(OutputChunk::Stderr { text: format!("{}\n", err.trim_end()) });
|
||||
}
|
||||
}
|
||||
|
||||
fn pty_size(cols: u16, rows: u16) -> PtySize {
|
||||
PtySize {
|
||||
cols: if cols == 0 { DEFAULT_PTY.0 } else { cols },
|
||||
rows: if rows == 0 { DEFAULT_PTY.1 } else { rows },
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn group_alive(pid: u32, _entry: &Entry) -> bool {
|
||||
// Signal 0 probes the group; it fails with ESRCH once every member is gone.
|
||||
@@ -440,7 +713,7 @@ fn chunk(stream: Stream, text: String) -> OutputChunk {
|
||||
/// Merges both streams in arrival order, batching consecutive output of the same stream.
|
||||
fn spawn_pump(
|
||||
rx: mpsc::Receiver<Msg>,
|
||||
on_output: Box<dyn Fn(OutputChunk) + Send + Sync>,
|
||||
on_output: OutputFn,
|
||||
entry: Arc<Entry>,
|
||||
sink: SharedSink,
|
||||
encoding: Option<String>,
|
||||
@@ -451,7 +724,7 @@ fn spawn_pump(
|
||||
let mut pending = String::new();
|
||||
let mut pending_stream = Stream::Stdout;
|
||||
let mut batch_started: Option<Instant> = None;
|
||||
let mut status: Option<std::io::Result<ExitStatus>> = None;
|
||||
let mut status: Option<Exit> = None;
|
||||
let mut drain_deadline: Option<Instant> = None;
|
||||
|
||||
let flush = |pending: &mut String, stream: Stream, started: &mut Option<Instant>| {
|
||||
@@ -512,6 +785,9 @@ fn spawn_pump(
|
||||
let (code, message) = describe_exit(status);
|
||||
on_output(OutputChunk::System { text: format!("\n{message}\n") });
|
||||
entry.stdin.lock().take();
|
||||
if let Some(pty) = &entry.pty {
|
||||
pty.lock().take();
|
||||
}
|
||||
entry.exited.store(true, Ordering::Release);
|
||||
tracing::debug!(id = %entry.id, ?code, "run exited");
|
||||
sink.emit(IdeEvent::RunExited { run: entry.id, code });
|
||||
@@ -542,9 +818,9 @@ fn signal_name(sig: i32) -> &'static str {
|
||||
}
|
||||
|
||||
/// IDEA wording: `Process finished with exit code 130 (interrupted by signal 2:SIGINT)`.
|
||||
fn describe_exit(status: Option<std::io::Result<ExitStatus>>) -> (Option<i32>, String) {
|
||||
fn describe_exit(status: Option<Exit>) -> (Option<i32>, String) {
|
||||
match status {
|
||||
Some(Ok(s)) => {
|
||||
Some(Exit::Pipes(Ok(s))) => {
|
||||
if let Some(code) = s.code() {
|
||||
return (Some(code), format!("Process finished with exit code {code}"));
|
||||
}
|
||||
@@ -552,23 +828,55 @@ fn describe_exit(status: Option<std::io::Result<ExitStatus>>) -> (Option<i32>, S
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
if let Some(sig) = s.signal() {
|
||||
let code = 128 + sig;
|
||||
return (
|
||||
Some(code),
|
||||
format!(
|
||||
"Process finished with exit code {code} (interrupted by signal {sig}:{})",
|
||||
signal_name(sig)
|
||||
),
|
||||
);
|
||||
return signalled(sig);
|
||||
}
|
||||
}
|
||||
(None, "Process finished".to_owned())
|
||||
}
|
||||
Some(Err(e)) => (None, format!("Process finished with unknown exit status: {e}")),
|
||||
Some(Exit::Pty(Ok(s))) => match s.signal() {
|
||||
None => {
|
||||
let code = s.exit_code() as i32;
|
||||
(Some(code), format!("Process finished with exit code {code}"))
|
||||
}
|
||||
Some(name) => match signal_number(name) {
|
||||
Some(sig) => signalled(sig),
|
||||
None => (None, format!("Process finished ({name})")),
|
||||
},
|
||||
},
|
||||
Some(Exit::Pipes(Err(e)) | Exit::Pty(Err(e))) => {
|
||||
(None, format!("Process finished with unknown exit status: {e}"))
|
||||
}
|
||||
None => (None, "Process finished".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signalled(sig: i32) -> (Option<i32>, String) {
|
||||
let code = 128 + sig;
|
||||
(Some(code), format!("Process finished with exit code {code} (interrupted by signal {sig}:{})", signal_name(sig)))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn signalled(sig: i32) -> (Option<i32>, String) {
|
||||
(Some(128 + sig), format!("Process finished with exit code {}", 128 + sig))
|
||||
}
|
||||
|
||||
/// The number of a signal from the `strsignal` description portable-pty reports.
|
||||
#[cfg(unix)]
|
||||
fn signal_number(description: &str) -> Option<i32> {
|
||||
(1..32).find(|&sig| {
|
||||
// SAFETY: `strsignal` returns a pointer to a NUL-terminated C string that stays
|
||||
// valid until the next call on this thread; it is copied before that.
|
||||
let name = unsafe { libc::strsignal(sig) };
|
||||
!name.is_null() && unsafe { std::ffi::CStr::from_ptr(name) }.to_string_lossy() == description
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn signal_number(_description: &str) -> Option<i32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -261,6 +261,7 @@ mod tests {
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ fn sample() -> Vec<RunConfig> {
|
||||
is_test: false,
|
||||
debug_adapter: Some("codelldb".into()),
|
||||
allow_parallel: true,
|
||||
extras: Default::default(),
|
||||
},
|
||||
RunConfig {
|
||||
id: "user:tests".into(),
|
||||
@@ -30,6 +31,7 @@ fn sample() -> Vec<RunConfig> {
|
||||
is_test: true,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -112,3 +114,58 @@ fn invalid_file_is_reported() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert!(err.message.contains("run.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extras_round_trip_and_local_file() {
|
||||
use ide_run::{BeforeLaunchTask, load_templates, save_templates};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut shared = sample().remove(0);
|
||||
shared.extras.folder = Some("Servers".into());
|
||||
shared.extras.params = BTreeMap::from([("mode".into(), "command".into())]);
|
||||
shared.extras.include_system_env = false;
|
||||
shared.extras.env_files = vec![".env".into(), "local.env".into()];
|
||||
shared.extras.stdin_file = Some("in.txt".into());
|
||||
shared.extras.output_file = Some("out.log".into());
|
||||
shared.extras.before_launch = vec![
|
||||
BeforeLaunchTask { kind: "build".into(), target: String::new() },
|
||||
BeforeLaunchTask { kind: "run".into(), target: "user:migrate".into() },
|
||||
];
|
||||
shared.extras.show_on_stderr = true;
|
||||
shared.extras.open_tool_window = Some(false);
|
||||
shared.extras.focus_tool_window = true;
|
||||
shared.extras.emulate_terminal = true;
|
||||
shared.extras.stop_command = Some("docker stop server".into());
|
||||
let mut local = sample().remove(1);
|
||||
local.extras.local = true;
|
||||
let mut compound = sample().remove(1);
|
||||
compound.id = "user:all".into();
|
||||
compound.name = "All".into();
|
||||
compound.kind = "compound".into();
|
||||
compound.command = String::new();
|
||||
compound.extras.compound = vec!["user:server".into(), "user:tests".into()];
|
||||
let configs = vec![shared, compound, local];
|
||||
save_user_configs(dir.path(), &configs).unwrap();
|
||||
assert_eq!(load_user_configs(dir.path()).unwrap(), configs);
|
||||
|
||||
let project = fs::read_to_string(user_configs_path(dir.path())).unwrap();
|
||||
assert!(!project.contains("\"user:tests\"\n"), "{project}");
|
||||
let private = fs::read_to_string(dir.path().join(".openidea/run.local.toml")).unwrap();
|
||||
assert!(private.contains("id = \"user:tests\""), "{private}");
|
||||
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
|
||||
assert!(ignore.lines().any(|l| l == "/run.local.toml"), "{ignore}");
|
||||
|
||||
// Making it a project file moves it back.
|
||||
let mut moved = configs.clone();
|
||||
moved[2].extras.local = false;
|
||||
save_user_configs(dir.path(), &moved).unwrap();
|
||||
assert_eq!(load_user_configs(dir.path()).unwrap(), moved);
|
||||
assert!(!fs::read_to_string(dir.path().join(".openidea/run.local.toml")).unwrap().contains("user:tests"));
|
||||
|
||||
// Templates share run.toml without disturbing configurations.
|
||||
let mut template = sample().remove(1);
|
||||
template.id = "template:npm".into();
|
||||
template.kind = "npm".into();
|
||||
save_templates(dir.path(), std::slice::from_ref(&template)).unwrap();
|
||||
assert_eq!(load_templates(dir.path()).unwrap(), vec![template]);
|
||||
assert_eq!(load_user_configs(dir.path()).unwrap(), moved);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ fn cfg(id: &str, command: &str, args: &[&str]) -> RunConfig {
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,3 +274,108 @@ fn exit_event_goes_to_the_starters_sink_only() {
|
||||
assert!(other_events.try_recv().is_err());
|
||||
assert!(h.events.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_files_system_env_and_redirects() {
|
||||
let h = harness();
|
||||
let root = h.root.path();
|
||||
std::fs::write(root.join(".env"), "FROM_FILE=file\nOVERRIDDEN=file\n").unwrap();
|
||||
std::fs::write(root.join("input.txt"), "from file\n").unwrap();
|
||||
let mut config = cfg(
|
||||
"t:extras",
|
||||
"/bin/sh",
|
||||
&["-c", "printf '%s|%s|%s|' \"$FROM_FILE\" \"$OVERRIDDEN\" \"${HOME:-unset}\"; cat"],
|
||||
);
|
||||
config.env.insert("OVERRIDDEN".into(), "config".into());
|
||||
config.extras.env_files = vec![".env".into()];
|
||||
config.extras.include_system_env = false;
|
||||
config.extras.stdin_file = Some("input.txt".into());
|
||||
config.extras.output_file = Some("logs/out.log".into());
|
||||
let (id, chunks) = start(&h, &config);
|
||||
// stdin comes from the file: writing to it is refused.
|
||||
assert!(h.pm.write_stdin(id, "x").is_err());
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0));
|
||||
assert_eq!(text(&chunks, stdout), "file|config|unset|from file\n");
|
||||
let saved = std::fs::read_to_string(root.join("logs/out.log")).unwrap();
|
||||
assert!(saved.contains("file|config|unset|from file\n"), "{saved}");
|
||||
assert!(saved.contains("Process finished with exit code 0"), "{saved}");
|
||||
|
||||
// With the system environment, HOME is inherited.
|
||||
config.extras.include_system_env = true;
|
||||
config.extras.stdin_file = None;
|
||||
config.extras.output_file = None;
|
||||
config.args = vec!["-c".into(), "printf '%s' \"${HOME:+set}\"".into()];
|
||||
let (id, chunks) = start(&h, &config);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0));
|
||||
assert_eq!(text(&chunks, stdout), "set");
|
||||
|
||||
config.extras.env_files = vec!["missing.env".into()];
|
||||
assert!(h.pm.start(root, &config, &StartOptions::default(), h.sink.clone(), Box::new(|_| {})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulated_terminal_is_a_tty_with_input_resize_and_exit_code() {
|
||||
let h = harness();
|
||||
let script = "[ -t 1 ] && echo tty-yes; echo \"term=$TERM\"; read line; echo \"got:$line\"; stty size; exit 3";
|
||||
let mut config = cfg("t:pty", "/bin/sh", &["-c", script]);
|
||||
config.extras.emulate_terminal = true;
|
||||
let (id, chunks) = start(&h, &config);
|
||||
assert!(h.pm.list().iter().any(|r| r.id == id && r.pty));
|
||||
wait_for(&chunks, "term=xterm-256color");
|
||||
h.pm.resize(id, 100, 40).unwrap();
|
||||
h.pm.write_stdin(id, "hello\r").unwrap();
|
||||
let out = wait_for(&chunks, "40 100");
|
||||
assert!(out.contains("tty-yes"), "{out:?}");
|
||||
assert!(out.contains("got:hello"), "{out:?}");
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(3));
|
||||
assert!(text(&chunks, system).contains("Process finished with exit code 3"));
|
||||
// Stdout and stderr share the terminal.
|
||||
assert_eq!(text(&chunks, stderr), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulated_terminal_stop_reports_the_signal() {
|
||||
let h = harness();
|
||||
let mut config = cfg("t:pty-stop", "/bin/sh", &["-c", "echo ready; sleep 30"]);
|
||||
config.extras.emulate_terminal = true;
|
||||
let (id, chunks) = start(&h, &config);
|
||||
wait_for(&chunks, "ready");
|
||||
h.pm.stop(id).unwrap();
|
||||
let code = wait_exit(&h, id, Duration::from_secs(10));
|
||||
assert_eq!(code, Some(130), "{:?}", text(&chunks, system));
|
||||
assert!(text(&chunks, system).contains("SIGINT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulated_terminal_refuses_input_redirection() {
|
||||
let h = harness();
|
||||
let mut config = cfg("t:pty-stdin", "/bin/cat", &[]);
|
||||
config.extras.emulate_terminal = true;
|
||||
config.extras.stdin_file = Some("input.txt".into());
|
||||
assert!(h.pm.start(h.root.path(), &config, &StartOptions::default(), h.sink.clone(), Box::new(|_| {})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_runs_cannot_be_resized() {
|
||||
let h = harness();
|
||||
let (id, _chunks) = start(&h, &cfg("t:pipes", "/bin/sh", &["-c", "sleep 5"]));
|
||||
assert!(!h.pm.list().iter().find(|r| r.id == id).unwrap().pty);
|
||||
assert!(h.pm.resize(id, 80, 24).is_err());
|
||||
h.pm.stop(id).unwrap();
|
||||
wait_exit(&h, id, Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_command_runs_first_with_the_run_environment() {
|
||||
let h = harness();
|
||||
let mut config = cfg("t:stop-cmd", "/bin/sh", &["-c", "echo ready; sleep 30"]);
|
||||
config.env.insert("OPENIDEA_MARKER".into(), "stopped".into());
|
||||
config.extras.stop_command = Some("touch \"$OPENIDEA_MARKER\"".into());
|
||||
let (id, chunks) = start(&h, &config);
|
||||
wait_for(&chunks, "ready");
|
||||
h.pm.stop_with_grace(id, Duration::from_millis(200)).unwrap();
|
||||
// `sleep` ignores nothing: the signals that follow the stop command end it.
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(130));
|
||||
assert!(h.root.path().join("stopped").exists(), "stop command did not run");
|
||||
assert!(text(&chunks, system).contains("touch \"$OPENIDEA_MARKER\""));
|
||||
}
|
||||
@@ -10,6 +10,7 @@ dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-run.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -32,12 +32,12 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// The configured `node` executable's directory.
|
||||
fn node_dir(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
pub(crate) fn node_dir(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.node.interpreter).and_then(|p| p.parent().map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
/// The configured Rust toolchain's `bin` directory and rustup toolchain name.
|
||||
fn rust_bin(root: &Path, s: &Settings) -> Option<(PathBuf, Option<String>)> {
|
||||
pub(crate) fn rust_bin(root: &Path, s: &Settings) -> Option<(PathBuf, Option<String>)> {
|
||||
let dir = setting_path(root, &s.languages.rust.toolchain)?;
|
||||
let bin = if dir.join("bin").join(exe("rustc")).is_file() || dir.join("bin").join(exe("cargo")).is_file() {
|
||||
dir.join("bin")
|
||||
@@ -52,7 +52,7 @@ fn rust_bin(root: &Path, s: &Settings) -> Option<(PathBuf, Option<String>)> {
|
||||
Some((bin, toolchain))
|
||||
}
|
||||
|
||||
fn goroot(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
pub(crate) fn goroot(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.go.goroot)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,9 +44,25 @@ pub(crate) fn which(name: &str) -> Option<PathBuf> {
|
||||
/// Runs `program args` and returns trimmed stdout (stderr when stdout is empty)
|
||||
/// on success; `None` on failure or after `timeout`.
|
||||
pub(crate) fn query(program: &Path, args: &[&str], timeout: Duration) -> Option<String> {
|
||||
let mut child = ide_core::process::no_window(&mut Command::new(program))
|
||||
query_in(program, args, None, &[], timeout)
|
||||
}
|
||||
|
||||
/// [`query`] in `cwd` with extra environment variables.
|
||||
pub(crate) fn query_in(
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
cwd: Option<&Path>,
|
||||
env: &[(&str, &str)],
|
||||
timeout: Duration,
|
||||
) -> Option<String> {
|
||||
let mut cmd = Command::new(program);
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let mut child = ide_core::process::no_window(&mut cmd)
|
||||
.args(args)
|
||||
.env("PATH", search_path())
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod apply;
|
||||
mod discover;
|
||||
mod exec;
|
||||
pub mod java;
|
||||
pub mod libraries;
|
||||
pub mod locate;
|
||||
pub mod lombok;
|
||||
pub mod python;
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! "External Libraries" of a project (IDEA's library / SDK roots in the
|
||||
//! Project view): the folders holding the sources of the project's SDKs and
|
||||
//! dependencies — the Python interpreter's standard library and site-packages,
|
||||
//! the JDK, `node_modules` packages, Cargo crates and the Rust standard
|
||||
//! library, GOROOT and the Go module cache.
|
||||
//!
|
||||
//! Each ecosystem is detected only when the project has its manifest, and its
|
||||
//! result is cached until the manifests (or the relevant settings) change.
|
||||
//!
|
||||
//! Archives are listed as browsable roots (`<archive>!`, see `ide_vfs`): the
|
||||
//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred).
|
||||
|
||||
use crate::exe;
|
||||
use crate::exec::{query, query_in, which};
|
||||
use ide_core::settings::Settings;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// One SDK or library with its source folders (`< Python 3.12 (.venv) >`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryGroup {
|
||||
/// Display name, e.g. `Python 3.12 (.venv)`.
|
||||
pub name: String,
|
||||
/// `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle`.
|
||||
pub kind: String,
|
||||
pub roots: Vec<LibraryRoot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryRoot {
|
||||
/// Folder name, e.g. `python3.12` or `site-packages`, the package (`serde 1.0.200`)
|
||||
/// or the archive (`src.zip`, `guava-33.0.0-jre-sources.jar`).
|
||||
pub name: String,
|
||||
/// A folder, or an archive's root (`<archive>!`).
|
||||
pub path: ide_core::IpcPath,
|
||||
}
|
||||
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// `cargo metadata` / `go list` load the whole dependency graph.
|
||||
const GRAPH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Prints the interpreter's library folders as JSON: `[stdlib, platstdlib, purelib, platlib]`.
|
||||
const PYTHON_PATHS: &str = "import json,sysconfig;p=sysconfig.get_paths();\
|
||||
print(json.dumps([p.get(k) for k in ('stdlib','platstdlib','purelib','platlib')]))";
|
||||
|
||||
const JAVA_MANIFESTS: &[&str] =
|
||||
&["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"];
|
||||
const NODE_MANIFESTS: &[&str] = &["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"];
|
||||
const CARGO_MANIFESTS: &[&str] = &["Cargo.toml", "Cargo.lock", "rust-toolchain", "rust-toolchain.toml"];
|
||||
const GO_MANIFESTS: &[&str] = &["go.mod", "go.sum", "go.work"];
|
||||
|
||||
/// A Maven / Gradle dependency of the project, from the build model.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct BuildDependency {
|
||||
/// Resolved by Gradle (else Maven): looked up in the Gradle cache first.
|
||||
pub gradle: bool,
|
||||
pub group: String,
|
||||
pub artifact: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// The project's library groups; empty when no SDK is configured. Runs the
|
||||
/// toolchains (in parallel), so call it off the UI / command thread.
|
||||
/// `dependencies` (from the build model) are listed when their jars are in the local repositories.
|
||||
pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[BuildDependency]) -> Vec<LibraryGroup> {
|
||||
type Detect = fn(&Path, &Settings) -> Vec<LibraryGroup>;
|
||||
let detectors: [Detect; 5] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups];
|
||||
std::thread::scope(|s| {
|
||||
let handles: Vec<_> = detectors.iter().map(|d| s.spawn(move || d(root, settings))).collect();
|
||||
let deps = s.spawn(|| dependency_groups(root, settings, dependencies));
|
||||
handles.into_iter().chain([deps]).flat_map(|h| h.join().unwrap_or_default()).collect()
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ cache
|
||||
|
||||
/// Size and modification time of each manifest (`None`: missing).
|
||||
type Stamp = Vec<(PathBuf, Option<(SystemTime, u64)>)>;
|
||||
|
||||
struct Cached {
|
||||
/// Settings the result depends on.
|
||||
key: String,
|
||||
stamp: Stamp,
|
||||
groups: Vec<LibraryGroup>,
|
||||
}
|
||||
|
||||
static CACHE: LazyLock<Mutex<HashMap<(PathBuf, &'static str), Cached>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn stamp(files: &[PathBuf]) -> Stamp {
|
||||
files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let meta = std::fs::metadata(f).ok().and_then(|m| Some((m.modified().ok()?, m.len())));
|
||||
(f.clone(), meta)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `detect()` unless the last result for `(root, ecosystem)` was computed with
|
||||
/// the same `key` and unchanged `manifests`.
|
||||
pub(crate) fn cached(
|
||||
root: &Path,
|
||||
ecosystem: &'static str,
|
||||
key: String,
|
||||
manifests: &[PathBuf],
|
||||
detect: impl FnOnce() -> Vec<LibraryGroup>,
|
||||
) -> Vec<LibraryGroup> {
|
||||
let id = (root.to_path_buf(), ecosystem);
|
||||
let now = stamp(manifests);
|
||||
if let Some(c) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).get(&id)
|
||||
&& c.key == key
|
||||
&& c.stamp == now
|
||||
{
|
||||
return c.groups.clone();
|
||||
}
|
||||
let groups = detect();
|
||||
CACHE.lock().unwrap_or_else(|e| e.into_inner()).insert(id, Cached { key, stamp: now, groups: groups.clone() });
|
||||
groups
|
||||
}
|
||||
|
||||
/// `names` below `root` when at least one of them exists (the project uses that ecosystem).
|
||||
fn manifests(root: &Path, names: &[&str]) -> Option<Vec<PathBuf>> {
|
||||
let files: Vec<PathBuf> = names.iter().map(|n| root.join(n)).collect();
|
||||
files.iter().any(|f| f.is_file()).then_some(files)
|
||||
}
|
||||
|
||||
fn group(name: impl Into<String>, kind: &str, roots: Vec<LibraryRoot>) -> Option<LibraryGroup> {
|
||||
(!roots.is_empty()).then(|| LibraryGroup { name: name.into(), kind: kind.into(), roots })
|
||||
}
|
||||
|
||||
/// A configured toolchain `bin` dir's `name`, else `name` on the search path.
|
||||
fn tool(bin: Option<PathBuf>, name: &str) -> Option<PathBuf> {
|
||||
bin.map(|b| b.join(exe(name))).filter(|p| p.is_file()).or_else(|| which(name))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ python
|
||||
|
||||
fn python_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let Some(python) = crate::apply::python_interpreter(root, s) else { return Vec::new() };
|
||||
let mut files = vec![python.clone()];
|
||||
files.extend(crate::venv_of(&python).map(|v| v.join("pyvenv.cfg")));
|
||||
cached(root, "python", python.display().to_string(), &files, || {
|
||||
python_libraries(&python, Some(root)).into_iter().collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Standard library and site-packages folders of a Python interpreter.
|
||||
pub fn python_libraries(python: &Path, root: Option<&Path>) -> Option<LibraryGroup> {
|
||||
let out = query(python, &["-c", PYTHON_PATHS], QUERY_TIMEOUT)?;
|
||||
let name = crate::discover::python_sdk(python, "system", root).map(|s| s.name).unwrap_or_else(|| "Python".into());
|
||||
group(name, "python", library_roots(parse_python_paths(&out)))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_python_paths(out: &str) -> Vec<PathBuf> {
|
||||
let json = out.lines().rev().find(|l| l.trim_start().starts_with('[')).unwrap_or("[]");
|
||||
serde_json::from_str::<Vec<Option<String>>>(json)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Existing folders among `paths`, deduplicated (also through symlinks), in order.
|
||||
pub(crate) fn library_roots(paths: Vec<PathBuf>) -> Vec<LibraryRoot> {
|
||||
named_roots(
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
(p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.display().to_string()), p)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Existing folders among `(name, path)`, deduplicated (also through symlinks), in order.
|
||||
pub(crate) fn named_roots(roots: Vec<(String, PathBuf)>) -> Vec<LibraryRoot> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
roots
|
||||
.into_iter()
|
||||
.filter(|(_, p)| p.is_dir() && seen.insert(dunce::canonicalize(p).unwrap_or_else(|_| p.clone())))
|
||||
.map(|(name, p)| LibraryRoot { name, path: p.into() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ jdk
|
||||
|
||||
fn jdk_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, JAVA_MANIFESTS) else { return Vec::new() };
|
||||
cached(root, "jdk", s.languages.java.jdk.clone(), &files, || {
|
||||
let jdk = crate::apply::project_jdk(root, s).or_else(|| {
|
||||
crate::java::pick_jdk(&crate::java::installed_jdks(), crate::java::project_java_version(root)).cloned()
|
||||
});
|
||||
jdk.and_then(|j| jdk_library(&j)).into_iter().collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots.
|
||||
pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option<LibraryGroup> {
|
||||
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
|
||||
let mut roots = named_roots(vec![(name.clone(), jdk.home.clone())]);
|
||||
if let Some(src) = ["lib/src.zip", "src.zip"].iter().map(|r| jdk.home.join(r)).find(|p| p.is_file()) {
|
||||
roots.push(LibraryRoot { name: "src.zip".into(), path: ide_vfs::archive_root(&src).into() });
|
||||
}
|
||||
group(name, "jdk", roots)
|
||||
}
|
||||
|
||||
/// `temurin-21` for `…/temurin-21.jdk/Contents/Home` (macOS bundles) or `…/temurin-21`.
|
||||
pub(crate) fn jdk_name(home: &Path) -> Option<String> {
|
||||
let dir = if home.ends_with("Contents/Home") { home.parent()?.parent()? } else { home };
|
||||
let name = dir.file_name()?.to_string_lossy();
|
||||
Some(name.strip_suffix(".jdk").unwrap_or(&name).to_owned())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ maven / gradle
|
||||
|
||||
fn dependency_groups(root: &Path, s: &Settings, deps: &[BuildDependency]) -> Vec<LibraryGroup> {
|
||||
if deps.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let maven_repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository);
|
||||
let gradle_home = gradle_user_home();
|
||||
let files: Vec<PathBuf> = JAVA_MANIFESTS.iter().map(|n| root.join(n)).collect();
|
||||
let key = format!("{maven_repo:?}\n{gradle_home:?}\n{deps:?}");
|
||||
cached(root, "buildDependencies", key, &files, || {
|
||||
dependency_libraries(deps, maven_repo.as_deref(), gradle_home.as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
/// `~/.m2/repository` (Settings › Maven › Local repository overrides it).
|
||||
fn default_maven_repository() -> Option<PathBuf> {
|
||||
Some(crate::home_dir()?.join(".m2/repository"))
|
||||
}
|
||||
|
||||
/// `$GRADLE_USER_HOME`, else `~/.gradle`.
|
||||
fn gradle_user_home() -> Option<PathBuf> {
|
||||
std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from).or_else(|| Some(crate::home_dir()?.join(".gradle")))
|
||||
}
|
||||
|
||||
/// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a
|
||||
/// local repository, its root being the jar (the `-sources.jar` when downloaded).
|
||||
pub(crate) fn dependency_libraries(
|
||||
deps: &[BuildDependency],
|
||||
maven_repo: Option<&Path>,
|
||||
gradle_home: Option<&Path>,
|
||||
) -> Vec<LibraryGroup> {
|
||||
let mut deps: Vec<&BuildDependency> = deps.iter().collect();
|
||||
deps.sort();
|
||||
deps.dedup();
|
||||
deps.into_iter()
|
||||
.filter_map(|d| {
|
||||
let maven = || maven_repo.and_then(|r| maven_jar(r, d));
|
||||
let gradle = || gradle_home.and_then(|h| gradle_jar(h, d));
|
||||
let jar = if d.gradle { gradle().or_else(maven) } else { maven().or_else(gradle) }?;
|
||||
let tool = if d.gradle { "Gradle" } else { "Maven" };
|
||||
let name = jar.file_name()?.to_string_lossy().into_owned();
|
||||
group(
|
||||
format!("{tool}: {}:{}:{}", d.group, d.artifact, d.version),
|
||||
&tool.to_lowercase(),
|
||||
vec![LibraryRoot { name, path: ide_vfs::archive_root(&jar).into() }],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// No path separators, interpolations or `..` segments (coordinates become paths).
|
||||
fn valid_coordinate(d: &BuildDependency) -> bool {
|
||||
[&d.group, &d.artifact, &d.version]
|
||||
.iter()
|
||||
.all(|p| !p.is_empty() && !p.contains(['/', '\\', '$']) && !p.split('.').any(str::is_empty))
|
||||
}
|
||||
|
||||
/// `<repo>/<group as path>/<artifact>/<version>/<artifact>-<version>(-sources).jar`.
|
||||
pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option<PathBuf> {
|
||||
if !valid_coordinate(d) {
|
||||
return None;
|
||||
}
|
||||
let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version);
|
||||
["-sources.jar", ".jar"]
|
||||
.iter()
|
||||
.map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version)))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// `<gradle home>/caches/modules-2/files-2.1/<group>/<artifact>/<version>/<hash>/<artifact>-<version>(-sources).jar`.
|
||||
pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option<PathBuf> {
|
||||
if !valid_coordinate(d) {
|
||||
return None;
|
||||
}
|
||||
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
|
||||
let mut hashes: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect();
|
||||
hashes.sort();
|
||||
["-sources.jar", ".jar"].iter().find_map(|suffix| {
|
||||
let file = format!("{}-{}{suffix}", d.artifact, d.version);
|
||||
hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ node
|
||||
|
||||
fn node_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, NODE_MANIFESTS) else { return Vec::new() };
|
||||
if !root.join("package.json").is_file() {
|
||||
return Vec::new();
|
||||
}
|
||||
cached(root, "node", s.languages.node.interpreter.clone(), &files, || {
|
||||
let node = tool(crate::apply::node_dir(root, s), "node");
|
||||
node_libraries(root, node.as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
/// `node_modules/<dep>` of the direct dependencies in `package.json`, and the
|
||||
/// Node.js core typings (`@types/node`, from the project or the runtime's global `lib`).
|
||||
pub(crate) fn node_libraries(root: &Path, node: Option<&Path>) -> Vec<LibraryGroup> {
|
||||
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap_or_default();
|
||||
let modules = root.join("node_modules");
|
||||
let deps = named_roots(package_dependencies(&manifest).into_iter().map(|d| (d.clone(), modules.join(d))).collect());
|
||||
let mut core = vec![modules.join("@types/node")];
|
||||
// `<prefix>/bin/node` → `<prefix>/lib/node_modules`.
|
||||
if let Some(prefix) =
|
||||
node.and_then(|n| dunce::canonicalize(n).ok()).and_then(|n| Some(n.parent()?.parent()?.to_path_buf()))
|
||||
{
|
||||
core.push(prefix.join("lib/node_modules/@types/node"));
|
||||
}
|
||||
let mut core = named_roots(core.into_iter().map(|p| ("@types/node".to_owned(), p)).collect());
|
||||
core.truncate(1);
|
||||
[group("Node.js Core", "nodeCore", core), group("node_modules", "nodeModules", deps)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Names of the direct dependencies (all dependency kinds), sorted, deduplicated.
|
||||
pub(crate) fn package_dependencies(package_json: &str) -> Vec<String> {
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(package_json) else { return Vec::new() };
|
||||
let mut names: Vec<String> = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
|
||||
.iter()
|
||||
.filter_map(|k| json.get(k)?.as_object())
|
||||
.flat_map(|o| o.keys().cloned())
|
||||
.filter(|n| !n.is_empty() && !n.contains("..") && !n.starts_with('/'))
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rust
|
||||
|
||||
fn rust_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, CARGO_MANIFESTS) else { return Vec::new() };
|
||||
if !root.join("Cargo.toml").is_file() {
|
||||
return Vec::new();
|
||||
}
|
||||
let r = &s.languages.rust;
|
||||
cached(root, "rust", format!("{}\n{}", r.toolchain, r.stdlib), &files, || {
|
||||
let bin = crate::apply::rust_bin(root, s).map(|(b, _)| b);
|
||||
let mut out = Vec::new();
|
||||
if let Some(rustc) = tool(bin.clone(), "rustc") {
|
||||
let version = query_in(&rustc, &["--version"], Some(root), &[], QUERY_TIMEOUT);
|
||||
let library = crate::apply::setting_path(root, &r.stdlib).or_else(|| {
|
||||
let sysroot = query_in(&rustc, &["--print", "sysroot"], Some(root), &[], QUERY_TIMEOUT)?;
|
||||
Some(PathBuf::from(sysroot).join("lib/rustlib/src/rust/library"))
|
||||
});
|
||||
let name = match version.as_deref().and_then(|v| v.split_whitespace().nth(1)) {
|
||||
Some(v) => format!("Rust {v}"),
|
||||
None => "Rust".into(),
|
||||
};
|
||||
out.extend(library.and_then(|l| group(name, "rust", library_roots(vec![l]))));
|
||||
}
|
||||
if let Some(cargo) = tool(bin, "cargo")
|
||||
&& let Some(json) =
|
||||
query_in(&cargo, &["metadata", "--format-version", "1", "--offline"], Some(root), &[], GRAPH_TIMEOUT)
|
||||
{
|
||||
out.extend(group("Cargo dependencies", "cargo", named_roots(parse_cargo_metadata(&json))));
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// `(name version, source dir)` of the non-workspace packages in `cargo metadata` output, by name.
|
||||
pub(crate) fn parse_cargo_metadata(json: &str) -> Vec<(String, PathBuf)> {
|
||||
#[derive(Deserialize)]
|
||||
struct Package {
|
||||
name: String,
|
||||
version: String,
|
||||
source: Option<String>,
|
||||
manifest_path: PathBuf,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Metadata {
|
||||
packages: Vec<Package>,
|
||||
}
|
||||
let Ok(meta) = serde_json::from_str::<Metadata>(json) else { return Vec::new() };
|
||||
let mut out: Vec<(String, PathBuf)> = meta
|
||||
.packages
|
||||
.into_iter()
|
||||
.filter(|p| p.source.is_some())
|
||||
.filter_map(|p| Some((format!("{} {}", p.name, p.version), p.manifest_path.parent()?.to_path_buf())))
|
||||
.collect();
|
||||
out.sort_by_cached_key(|(name, _)| name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ go
|
||||
|
||||
fn go_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, GO_MANIFESTS) else { return Vec::new() };
|
||||
if !root.join("go.mod").is_file() && !root.join("go.work").is_file() {
|
||||
return Vec::new();
|
||||
}
|
||||
let g = &s.languages.go;
|
||||
cached(root, "go", format!("{}\n{}", g.goroot, g.gopath), &files, || {
|
||||
let goroot = crate::apply::goroot(root, s);
|
||||
let Some(go) = tool(goroot.as_ref().map(|r| r.join("bin")), "go") else { return Vec::new() };
|
||||
let goroot_s = goroot.as_ref().map(|p| p.to_string_lossy().into_owned());
|
||||
let gopath = crate::apply::setting_path(root, &g.gopath).map(|p| p.to_string_lossy().into_owned());
|
||||
// Never download modules or toolchains just to list libraries.
|
||||
let mut env = vec![("GOPROXY", "off"), ("GOTOOLCHAIN", "local")];
|
||||
env.extend(goroot_s.as_deref().map(|v| ("GOROOT", v)));
|
||||
env.extend(gopath.as_deref().map(|v| ("GOPATH", v)));
|
||||
let mut out = Vec::new();
|
||||
if let Some(vars) = query_in(&go, &["env", "GOROOT", "GOVERSION"], Some(root), &env, QUERY_TIMEOUT) {
|
||||
let mut lines = vars.lines().map(str::trim);
|
||||
let (dir, version) = (lines.next().unwrap_or_default(), lines.next().unwrap_or_default());
|
||||
let name = match version.strip_prefix("go") {
|
||||
Some(v) if !v.is_empty() => format!("Go SDK {v}"),
|
||||
_ => "Go SDK".into(),
|
||||
};
|
||||
if !dir.is_empty() {
|
||||
out.extend(group(name, "go", library_roots(vec![Path::new(dir).join("src")])));
|
||||
}
|
||||
}
|
||||
if let Some(text) = query_in(&go, &["list", "-m", "-e", "-json", "all"], Some(root), &env, GRAPH_TIMEOUT) {
|
||||
out.extend(group("Go Modules", "goModules", named_roots(parse_go_modules(&text))));
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// `(path@version, dir)` of the downloaded non-main modules in `go list -m -json` output.
|
||||
pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct Module {
|
||||
path: String,
|
||||
version: Option<String>,
|
||||
dir: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
main: bool,
|
||||
}
|
||||
serde_json::Deserializer::from_str(text)
|
||||
.into_iter::<Module>()
|
||||
.map_while(Result::ok)
|
||||
.filter(|m| !m.main)
|
||||
.filter_map(|m| {
|
||||
let name = match &m.version {
|
||||
Some(v) => format!("{}@{v}", m.path),
|
||||
None => m.path.clone(),
|
||||
};
|
||||
Some((name, m.dir?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -47,6 +47,7 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig {
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,3 +291,173 @@ fn custom_tool_path_replaces_the_command() {
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(cfg.command, "zls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_library_paths_are_parsed_and_deduplicated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stdlib = tmp.path().join("lib/python3.12");
|
||||
let site = stdlib.join("site-packages");
|
||||
std::fs::create_dir_all(&site).unwrap();
|
||||
let json = serde_json::to_string(&[Some(&stdlib), Some(&stdlib), Some(&site), None]).unwrap();
|
||||
let paths = crate::libraries::parse_python_paths(&format!("warning: something\n{json}"));
|
||||
assert_eq!(paths.len(), 3);
|
||||
let roots = crate::libraries::library_roots([paths, vec![tmp.path().join("gone")]].concat());
|
||||
assert_eq!(roots.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["python3.12", "site-packages"]);
|
||||
assert!(crate::libraries::parse_python_paths("not json").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_libraries_without_an_interpreter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(crate::libraries::project_libraries(tmp.path(), &Settings::default(), &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_libraries_list_direct_dependencies_and_core_typings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"dependencies":{"react":"^18","@scope/ui":"1"},"devDependencies":{"vitest":"2","react":"^18"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for dir in ["react", "@scope/ui", "@types/node", "transitive"] {
|
||||
std::fs::create_dir_all(root.join("node_modules").join(dir)).unwrap();
|
||||
}
|
||||
let groups = crate::libraries::node_libraries(root, None);
|
||||
let summary: Vec<(&str, &str, Vec<&str>)> = groups
|
||||
.iter()
|
||||
.map(|g| (g.name.as_str(), g.kind.as_str(), g.roots.iter().map(|r| r.name.as_str()).collect()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
[
|
||||
("Node.js Core", "nodeCore", vec!["@types/node"]),
|
||||
("node_modules", "nodeModules", vec!["@scope/ui", "react"])
|
||||
]
|
||||
);
|
||||
assert_eq!(crate::libraries::package_dependencies("{"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_metadata_lists_registry_packages_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let serde = tmp.path().join("registry/src/index/serde-1.0.200");
|
||||
let json = serde_json::json!({ "packages": [
|
||||
{ "name": "app", "version": "0.1.0", "source": null, "manifest_path": tmp.path().join("Cargo.toml") },
|
||||
{ "name": "serde", "version": "1.0.200", "source": "registry+https://github.com/rust-lang/crates.io-index",
|
||||
"manifest_path": serde.join("Cargo.toml") },
|
||||
]});
|
||||
assert_eq!(crate::libraries::parse_cargo_metadata(&json.to_string()), [("serde 1.0.200".to_owned(), serde)]);
|
||||
assert!(crate::libraries::parse_cargo_metadata("error: no Cargo.toml").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_modules_skip_the_main_module_and_missing_downloads() {
|
||||
let out = r#"{"Path":"example.com/app","Main":true,"Dir":"/p"}
|
||||
{"Path":"golang.org/x/text","Version":"v0.14.0","Dir":"/gopath/pkg/mod/golang.org/x/[email protected]"}
|
||||
{"Path":"example.com/missing","Version":"v1.0.0","Error":{"Err":"module lookup disabled"}}"#;
|
||||
assert_eq!(
|
||||
crate::libraries::parse_go_modules(out),
|
||||
[(
|
||||
"golang.org/x/[email protected]".to_owned(),
|
||||
std::path::PathBuf::from("/gopath/pkg/mod/golang.org/x/[email protected]")
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdk_library_is_the_jdk_home() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("temurin-21.jdk/Contents/Home");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
assert_eq!(crate::libraries::jdk_name(&home).as_deref(), Some("temurin-21"));
|
||||
assert_eq!(crate::libraries::jdk_name(&tmp.path().join("jdk-17")).as_deref(), Some("jdk-17"));
|
||||
let group = crate::libraries::jdk_library(&crate::java::Jdk { major: 21, home: home.clone() }).unwrap();
|
||||
assert_eq!((group.name.as_str(), group.kind.as_str()), ("temurin-21", "jdk"));
|
||||
assert_eq!(group.roots[0].path, ide_core::IpcPath::from(home.clone()));
|
||||
assert_eq!(group.roots.len(), 1, "no sources archive");
|
||||
|
||||
std::fs::create_dir_all(home.join("lib")).unwrap();
|
||||
std::fs::write(home.join("lib/src.zip"), "").unwrap();
|
||||
let group = crate::libraries::jdk_library(&crate::java::Jdk { major: 21, home: home.clone() }).unwrap();
|
||||
assert_eq!(group.roots[1].name, "src.zip");
|
||||
assert_eq!(group.roots[1].path, ide_core::IpcPath::from(ide_vfs::archive_root(&home.join("lib/src.zip"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_dependencies_resolve_to_local_jars() {
|
||||
use crate::libraries::{BuildDependency, dependency_libraries};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (m2, gradle) = (tmp.path().join("m2"), tmp.path().join("gradle"));
|
||||
let touch = |p: std::path::PathBuf| {
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, "").unwrap();
|
||||
p
|
||||
};
|
||||
touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
|
||||
let guava_src = touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
|
||||
let junit = touch(m2.join("junit/junit/4.13/junit-4.13.jar"));
|
||||
let files = gradle.join("caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9");
|
||||
touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
|
||||
let slf4j_src = touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
|
||||
let dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency {
|
||||
gradle,
|
||||
group: g.into(),
|
||||
artifact: a.into(),
|
||||
version: v.into(),
|
||||
};
|
||||
let deps = [
|
||||
dep(false, "junit", "junit", "4.13"),
|
||||
dep(false, "com.google.guava", "guava", "33.0"),
|
||||
dep(false, "com.google.guava", "guava", "33.0"),
|
||||
dep(true, "org.slf4j", "slf4j-api", "2.0.9"),
|
||||
dep(false, "missing", "nope", "1.0"),
|
||||
dep(false, "..", "evil", "1"),
|
||||
];
|
||||
let groups = dependency_libraries(&deps, Some(&m2), Some(&gradle));
|
||||
let shown: Vec<(&str, &str, &str)> =
|
||||
groups.iter().map(|g| (g.name.as_str(), g.kind.as_str(), g.roots[0].name.as_str())).collect();
|
||||
assert_eq!(
|
||||
shown,
|
||||
[
|
||||
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.jar"),
|
||||
("Maven: junit:junit:4.13", "maven", "junit-4.13.jar"),
|
||||
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"),
|
||||
]
|
||||
);
|
||||
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src)));
|
||||
assert_eq!(groups[1].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&junit)));
|
||||
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src)));
|
||||
// A Gradle build also finds jars installed in the Maven local repository (`mavenLocal()`).
|
||||
let groups = dependency_libraries(&[dep(true, "junit", "junit", "4.13")], Some(&m2), Some(&gradle));
|
||||
assert_eq!(groups[0].name, "Gradle: junit:junit:4.13");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_detection_is_cached_until_a_manifest_changes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manifest = tmp.path().join("package.json");
|
||||
std::fs::write(&manifest, "{}").unwrap();
|
||||
let runs = std::cell::Cell::new(0);
|
||||
let detect = || {
|
||||
runs.set(runs.get() + 1);
|
||||
Vec::new()
|
||||
};
|
||||
let files = [manifest.clone()];
|
||||
crate::libraries::cached(tmp.path(), "test", "a".into(), &files, detect);
|
||||
crate::libraries::cached(tmp.path(), "test", "a".into(), &files, detect);
|
||||
assert_eq!(runs.get(), 1);
|
||||
std::fs::write(&manifest, r#"{"dependencies":{}}"#).unwrap();
|
||||
crate::libraries::cached(tmp.path(), "test", "a".into(), &files, detect);
|
||||
assert_eq!(runs.get(), 2);
|
||||
crate::libraries::cached(tmp.path(), "test", "b".into(), &files, detect);
|
||||
assert_eq!(runs.get(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ecosystem_libraries_without_manifests() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("node_modules/react")).unwrap();
|
||||
assert!(crate::libraries::project_libraries(tmp.path(), &Settings::default(), &[]).is_empty());
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
grep-searcher.workspace = true
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use crate::iter::for_each_match;
|
||||
use crate::line::line_match;
|
||||
use crate::mask::FileMask;
|
||||
use crate::pattern::ScopePattern;
|
||||
use crate::query::{Compiled, line_terminator};
|
||||
use crate::{
|
||||
FileMatches, LineMatch, LiteralKind, LiteralRange, Literals, Overlay, PathFilter, SearchContext, SearchScope,
|
||||
@@ -27,6 +28,7 @@ pub(crate) struct Request<'a> {
|
||||
pub scope: &'a SearchScope,
|
||||
pub compiled: Compiled,
|
||||
pub mask: FileMask,
|
||||
pub pattern: Option<ScopePattern>,
|
||||
pub filter: Option<PathFilter<'a>>,
|
||||
pub context: SearchContext,
|
||||
pub literals: Option<Literals<'a>>,
|
||||
@@ -90,6 +92,7 @@ impl Control<'_> {
|
||||
struct Ctx<'a> {
|
||||
root: &'a Path,
|
||||
mask: &'a FileMask,
|
||||
pattern: Option<&'a ScopePattern>,
|
||||
filter: Option<PathFilter<'a>>,
|
||||
context: SearchContext,
|
||||
literals: Option<Literals<'a>>,
|
||||
@@ -104,6 +107,7 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
|
||||
let ctx = Ctx {
|
||||
root: req.root,
|
||||
mask: &req.mask,
|
||||
pattern: req.pattern.as_ref(),
|
||||
filter: req.filter,
|
||||
context: req.context,
|
||||
literals: if req.context == SearchContext::Any { None } else { req.literals },
|
||||
@@ -170,7 +174,8 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
};
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
// Archives (jar, zip) are browsed, not searched (IDEA skips them too).
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && !ide_vfs::is_archive_file(entry.path()) {
|
||||
ctx.search_file(entry.path(), &matcher, &mut searcher);
|
||||
}
|
||||
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
|
||||
@@ -199,7 +204,7 @@ pub(crate) fn roots(root: &Path, scope: &SearchScope) -> IdeResult<Vec<PathBuf>>
|
||||
}
|
||||
};
|
||||
Ok(match scope {
|
||||
SearchScope::Project | SearchScope::FileSet { .. } => vec![dir(Path::new(""))?],
|
||||
SearchScope::Project | SearchScope::FileSet { .. } | SearchScope::Pattern { .. } => vec![dir(Path::new(""))?],
|
||||
SearchScope::Directory { path } => vec![dir(path)?],
|
||||
SearchScope::Files { paths } => {
|
||||
let mut out: Vec<PathBuf> = Vec::with_capacity(paths.len());
|
||||
@@ -231,7 +236,10 @@ pub(crate) fn rel_path(root: &Path, path: &Path) -> String {
|
||||
impl Ctx<'_> {
|
||||
fn search_file(&self, path: &Path, matcher: &RegexMatcher, searcher: &mut Searcher) {
|
||||
let rel = rel_path(self.root, path);
|
||||
if !self.mask.matches(&rel) || self.filter.is_some_and(|keep| !keep(&rel)) {
|
||||
if !self.mask.matches(&rel)
|
||||
|| self.pattern.is_some_and(|p| !p.matches(&rel))
|
||||
|| self.filter.is_some_and(|keep| !keep(&rel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The search context needs the whole text to find comments and string literals.
|
||||
|
||||
@@ -27,7 +27,10 @@ pub fn scope_files(
|
||||
filter: Option<PathFilter<'_>>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<ScopeFile>> {
|
||||
let keep = |rel: &str| mask.matches(rel) && filter.is_none_or(|f| f(rel));
|
||||
let pattern = scope.pattern()?;
|
||||
let keep = |rel: &str| {
|
||||
mask.matches(rel) && pattern.as_ref().is_none_or(|p| p.matches(rel)) && filter.is_none_or(|f| f(rel))
|
||||
};
|
||||
let found = Mutex::new(Vec::new());
|
||||
let mut on_disk = Vec::new();
|
||||
for path in roots(root, scope)? {
|
||||
@@ -63,7 +66,7 @@ pub fn scope_files(
|
||||
return WalkState::Quit;
|
||||
}
|
||||
let Ok(entry) = entry else { return WalkState::Continue };
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && !ide_vfs::is_archive_file(entry.path()) {
|
||||
let rel = rel_path(root, entry.path());
|
||||
if keep(&rel) {
|
||||
found
|
||||
@@ -139,4 +142,15 @@ mod tests {
|
||||
vec!["src/a.rs", "src/new.rs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_scope() {
|
||||
let dir = tree();
|
||||
let none = FileMask::new(&[], &[]).unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let scope = SearchScope::Pattern { pattern: "file:src//*.rs&&!file:src/sub//*".into() };
|
||||
assert_eq!(rels(&scope_files(dir.path(), &scope, &none, None, &cancel).unwrap()), vec!["src/a.rs"]);
|
||||
let bad = SearchScope::Pattern { pattern: "(".into() };
|
||||
assert!(scope_files(dir.path(), &bad, &none, None, &cancel).is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,13 @@ mod files;
|
||||
mod iter;
|
||||
mod line;
|
||||
mod mask;
|
||||
mod pattern;
|
||||
mod query;
|
||||
mod replace;
|
||||
|
||||
pub use files::{ScopeFile, scope_files};
|
||||
pub use mask::FileMask;
|
||||
pub use pattern::ScopePattern;
|
||||
pub use replace::{
|
||||
ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceSummary, ReplaceTarget, apply_edits, preserve_case, preview,
|
||||
replace_files, replacement_edits,
|
||||
@@ -114,6 +116,21 @@ pub enum SearchScope {
|
||||
FileSet {
|
||||
set: ProjectFileSet,
|
||||
},
|
||||
/// A custom scope: the project's files matching a [`ScopePattern`]
|
||||
/// (`file:src//*.ts&&!file:*test*`).
|
||||
Pattern {
|
||||
pattern: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SearchScope {
|
||||
/// The file pattern of a [`SearchScope::Pattern`].
|
||||
pub fn pattern(&self) -> IdeResult<Option<ScopePattern>> {
|
||||
match self {
|
||||
Self::Pattern { pattern } => ScopePattern::parse(pattern).map(Some),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -211,6 +228,7 @@ pub fn search_in_context(
|
||||
scope: &query.scope,
|
||||
compiled: query::compile(query)?,
|
||||
mask,
|
||||
pattern: query.scope.pattern()?,
|
||||
filter,
|
||||
context: query.context,
|
||||
literals,
|
||||
@@ -225,6 +243,7 @@ pub fn search_in_context(
|
||||
/// invalid regex while the user types.
|
||||
pub fn validate_query(query: &SearchQuery) -> IdeResult<()> {
|
||||
FileMask::new(&query.include, &query.exclude)?;
|
||||
query.scope.pattern()?;
|
||||
if !query.pattern.is_empty() {
|
||||
query::compile(query)?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! IDEA-style scope patterns for custom (named) scopes:
|
||||
//! `file:src//*.ts&&!file:*test*`, `file:*.rs||file:docs//*`, `!(file:gen//*)`.
|
||||
//!
|
||||
//! A term is `file:<glob>` (the `file:` prefix and a `[module]` qualifier are
|
||||
//! optional); `//` means "any depth" (`src//*` = `src/**/*`), a trailing `/`
|
||||
//! means everything below. Globs without a `/` match the file name, others the
|
||||
//! project-relative path, like [`crate::FileMask`]. Terms combine with `!`,
|
||||
//! `&&`, `||` (in that precedence) and parentheses.
|
||||
|
||||
use globset::{GlobBuilder, GlobMatcher};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScopePattern(Node);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Node {
|
||||
Glob { glob: GlobMatcher, by_name: bool },
|
||||
Not(Box<Node>),
|
||||
And(Vec<Node>),
|
||||
Or(Vec<Node>),
|
||||
}
|
||||
|
||||
impl ScopePattern {
|
||||
pub fn parse(source: &str) -> IdeResult<Self> {
|
||||
let mut p = Parser { s: source, pos: 0 };
|
||||
p.skip_ws();
|
||||
if p.at_end() {
|
||||
return Err(IdeError::invalid("Empty scope pattern"));
|
||||
}
|
||||
let node = p.or()?;
|
||||
p.skip_ws();
|
||||
if !p.at_end() {
|
||||
return Err(p.error("Unexpected input"));
|
||||
}
|
||||
Ok(Self(node))
|
||||
}
|
||||
|
||||
/// `rel_path` is project-relative and `/`-separated.
|
||||
pub fn matches(&self, rel_path: &str) -> bool {
|
||||
self.0.matches(rel_path, rel_path.rsplit('/').next().unwrap_or(rel_path))
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
fn matches(&self, rel: &str, name: &str) -> bool {
|
||||
match self {
|
||||
Self::Glob { glob, by_name } => glob.is_match(if *by_name { name } else { rel }),
|
||||
Self::Not(n) => !n.matches(rel, name),
|
||||
Self::And(all) => all.iter().all(|n| n.matches(rel, name)),
|
||||
Self::Or(any) => any.iter().any(|n| n.matches(rel, name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a str,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn rest(&self) -> &str {
|
||||
&self.s[self.pos..]
|
||||
}
|
||||
|
||||
fn at_end(&self) -> bool {
|
||||
self.pos >= self.s.len()
|
||||
}
|
||||
|
||||
fn skip_ws(&mut self) {
|
||||
let rest = self.rest();
|
||||
self.pos += rest.len() - rest.trim_start().len();
|
||||
}
|
||||
|
||||
fn eat(&mut self, token: &str) -> bool {
|
||||
self.skip_ws();
|
||||
if self.rest().starts_with(token) {
|
||||
self.pos += token.len();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn error(&self, what: &str) -> IdeError {
|
||||
IdeError::invalid(format!("{what} at column {} of the scope pattern", self.s[..self.pos].chars().count() + 1))
|
||||
}
|
||||
|
||||
fn or(&mut self) -> IdeResult<Node> {
|
||||
let mut items = vec![self.and()?];
|
||||
while self.eat("||") {
|
||||
items.push(self.and()?);
|
||||
}
|
||||
Ok(if items.len() == 1 { items.remove(0) } else { Node::Or(items) })
|
||||
}
|
||||
|
||||
fn and(&mut self) -> IdeResult<Node> {
|
||||
let mut items = vec![self.unary()?];
|
||||
while self.eat("&&") {
|
||||
items.push(self.unary()?);
|
||||
}
|
||||
Ok(if items.len() == 1 { items.remove(0) } else { Node::And(items) })
|
||||
}
|
||||
|
||||
fn unary(&mut self) -> IdeResult<Node> {
|
||||
if self.eat("!") {
|
||||
return Ok(Node::Not(Box::new(self.unary()?)));
|
||||
}
|
||||
if self.eat("(") {
|
||||
let node = self.or()?;
|
||||
if !self.eat(")") {
|
||||
return Err(self.error("Missing `)`"));
|
||||
}
|
||||
return Ok(node);
|
||||
}
|
||||
self.term()
|
||||
}
|
||||
|
||||
fn term(&mut self) -> IdeResult<Node> {
|
||||
self.skip_ws();
|
||||
let rest = self.rest();
|
||||
let end = ["&&", "||", ")"].iter().filter_map(|t| rest.find(t)).min().unwrap_or(rest.len());
|
||||
let raw = rest[..end].trim();
|
||||
if raw.is_empty() {
|
||||
return Err(self.error("Expected a file pattern"));
|
||||
}
|
||||
let glob = strip_prefix(raw);
|
||||
if glob.is_empty() {
|
||||
return Err(self.error("Expected a file pattern"));
|
||||
}
|
||||
let node = glob_node(glob)?;
|
||||
self.pos += end;
|
||||
Ok(node)
|
||||
}
|
||||
}
|
||||
|
||||
/// `file:x`, `file[module]:x` → `x`.
|
||||
fn strip_prefix(term: &str) -> &str {
|
||||
let Some(rest) = term.strip_prefix("file") else { return term };
|
||||
let rest = match rest.strip_prefix('[') {
|
||||
Some(r) => match r.find(']') {
|
||||
Some(i) => &r[i + 1..],
|
||||
None => return term,
|
||||
},
|
||||
None => rest,
|
||||
};
|
||||
rest.strip_prefix(':').map_or(term, str::trim)
|
||||
}
|
||||
|
||||
fn glob_node(pattern: &str) -> IdeResult<Node> {
|
||||
let mut glob = pattern.trim_start_matches("./").replace("//", "/**/");
|
||||
if glob.ends_with('/') {
|
||||
glob.push_str("**");
|
||||
}
|
||||
let by_name = !glob.contains('/');
|
||||
let glob = if by_name { glob } else { glob.trim_start_matches('/').to_owned() };
|
||||
let matcher = GlobBuilder::new(&glob)
|
||||
.literal_separator(true)
|
||||
.backslash_escape(true)
|
||||
.case_insensitive(cfg!(any(target_os = "macos", target_os = "windows")))
|
||||
.build()
|
||||
.map_err(|e| IdeError::invalid(format!("Invalid file pattern `{pattern}`: {}", e.kind())))?
|
||||
.compile_matcher();
|
||||
Ok(Node::Glob { glob: matcher, by_name })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(s: &str) -> ScopePattern {
|
||||
ScopePattern::parse(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_recursive_directory_globs() {
|
||||
let s = p("file:src//*.ts");
|
||||
assert!(s.matches("src/a.ts"));
|
||||
assert!(s.matches("src/x/y/a.ts"));
|
||||
assert!(!s.matches("lib/a.ts"));
|
||||
assert!(!s.matches("src/a.rs"));
|
||||
assert!(p("src/").matches("src/deep/a.rs"));
|
||||
assert!(!p("file:src/*").matches("src/deep/a.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_operators_and_grouping() {
|
||||
let s = p("file:src//*&&!file:*test*");
|
||||
assert!(s.matches("src/main.rs"));
|
||||
assert!(!s.matches("src/main_test.rs"));
|
||||
let s = p("*.md || file[app]:docs//*");
|
||||
assert!(s.matches("README.md"));
|
||||
assert!(s.matches("docs/x/y.txt"));
|
||||
assert!(!s.matches("src/a.rs"));
|
||||
let s = p("!(file:gen//* || *.lock) && *.*");
|
||||
assert!(s.matches("src/a.rs"));
|
||||
assert!(!s.matches("gen/a.rs"));
|
||||
assert!(!s.matches("Cargo.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
for bad in ["", " ", "file:", "a &&", "(a", "a)", "file:a["] {
|
||||
assert!(ScopePattern::parse(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,20 @@ fn directory_scope() {
|
||||
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")]);
|
||||
@@ -621,3 +635,11 @@ fn search_context_filters_comments_and_strings() {
|
||||
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"]);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ide-spell"
|
||||
description = "Spell checking (Proofreading › Typo): word extraction from comments, strings and declared names, Hunspell en_US dictionary, user dictionaries"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
# Pure-Rust Hunspell-compatible checker (MPL-2.0).
|
||||
spellbook = "0.4"
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
@@ -0,0 +1,347 @@
|
||||
en_US Hunspell Dictionary
|
||||
Version 2020.12.07
|
||||
Mon Dec 7 20:14:35 2020 -0500 [5ef55f9]
|
||||
http://wordlist.sourceforge.net
|
||||
|
||||
README file for English Hunspell dictionaries derived from SCOWL.
|
||||
|
||||
These dictionaries are created using the speller/make-hunspell-dict
|
||||
script in SCOWL.
|
||||
|
||||
The following dictionaries are available:
|
||||
|
||||
en_US (American)
|
||||
en_CA (Canadian)
|
||||
en_GB-ise (British with "ise" spelling)
|
||||
en_GB-ize (British with "ize" spelling)
|
||||
en_AU (Australian)
|
||||
|
||||
en_US-large
|
||||
en_CA-large
|
||||
en_GB-large (with both "ise" and "ize" spelling)
|
||||
en_AU-large
|
||||
|
||||
The normal (non-large) dictionaries correspond to SCOWL size 60 and,
|
||||
to encourage consistent spelling, generally only include one spelling
|
||||
variant for a word. The large dictionaries correspond to SCOWL size
|
||||
70 and may include multiple spelling for a word when both variants are
|
||||
considered almost equal. The larger dictionaries however (1) have not
|
||||
been as carefully checked for errors as the normal dictionaries and
|
||||
thus may contain misspelled or invalid words; and (2) contain
|
||||
uncommon, yet valid, words that might cause problems as they are
|
||||
likely to be misspellings of more common words (for example, "ort" and
|
||||
"calender").
|
||||
|
||||
To get an idea of the difference in size, here are 25 random words
|
||||
only found in the large dictionary for American English:
|
||||
|
||||
Bermejo Freyr's Guenevere Hatshepsut Nottinghamshire arrestment
|
||||
crassitudes crural dogwatches errorless fetial flaxseeds godroon
|
||||
incretion jalapeño's kelpie kishkes neuroglias pietisms pullulation
|
||||
stemwinder stenoses syce thalassic zees
|
||||
|
||||
The en_US, en_CA and en_AU are the official dictionaries for Hunspell.
|
||||
The en_GB and large dictionaries are made available on an experimental
|
||||
basis. If you find them useful please send me a quick email at
|
||||
[email protected].
|
||||
|
||||
If none of these dictionaries suite you (for example, maybe you want
|
||||
the normal dictionary that also includes common variants) additional
|
||||
dictionaries can be generated at http://app.aspell.net/create or by
|
||||
modifying speller/make-hunspell-dict in SCOWL. Please do let me know
|
||||
if you end up publishing a customized dictionary.
|
||||
|
||||
If a word is not found in the dictionary or a word is there you think
|
||||
shouldn't be, you can lookup the word up at http://app.aspell.net/lookup
|
||||
to help determine why that is.
|
||||
|
||||
General comments on these list can be sent directly to me at
|
||||
[email protected] or to the wordlist-devel mailing lists
|
||||
(https://lists.sourceforge.net/lists/listinfo/wordlist-devel). If you
|
||||
have specific issues with any of these dictionaries please file a bug
|
||||
report at https://github.com/kevina/wordlist/issues.
|
||||
|
||||
IMPORTANT CHANGES INTRODUCED In 2016.11.20:
|
||||
|
||||
New Australian dictionaries thanks to the work of Benjamin Titze
|
||||
([email protected]).
|
||||
|
||||
IMPORTANT CHANGES INTRODUCED IN 2016.04.24:
|
||||
|
||||
The dictionaries are now in UTF-8 format instead of ISO-8859-1. This
|
||||
was required to handle smart quotes correctly.
|
||||
|
||||
IMPORTANT CHANGES INTRODUCED IN 2016.01.19:
|
||||
|
||||
"SET UTF8" was changes to "SET UTF-8" in the affix file as some
|
||||
versions of Hunspell do not recognize "UTF8".
|
||||
|
||||
ADDITIONAL NOTES:
|
||||
|
||||
The NOSUGGEST flag was added to certain taboo words. While I made an
|
||||
honest attempt to flag the strongest taboo words with the NOSUGGEST
|
||||
flag, I MAKE NO GUARANTEE THAT I FLAGGED EVERY POSSIBLE TABOO WORD.
|
||||
The list was originally derived from Németh László, however I removed
|
||||
some words which, while being considered taboo by some dictionaries,
|
||||
are not really considered swear words in today's society.
|
||||
|
||||
COPYRIGHT, SOURCES, and CREDITS:
|
||||
|
||||
The English dictionaries come directly from SCOWL
|
||||
and is thus under the same copyright of SCOWL. The affix file is
|
||||
a heavily modified version of the original english.aff file which was
|
||||
released as part of Geoff Kuenning's Ispell and as such is covered by
|
||||
his BSD license. Part of SCOWL is also based on Ispell thus the
|
||||
Ispell copyright is included with the SCOWL copyright.
|
||||
|
||||
The collective work is Copyright 2000-2018 by Kevin Atkinson as well
|
||||
as any of the copyrights mentioned below:
|
||||
|
||||
Copyright 2000-2018 by Kevin Atkinson
|
||||
|
||||
Permission to use, copy, modify, distribute and sell these word
|
||||
lists, the associated scripts, the output created from the scripts,
|
||||
and its documentation for any purpose is hereby granted without fee,
|
||||
provided that the above copyright notice appears in all copies and
|
||||
that both that copyright notice and this permission notice appear in
|
||||
supporting documentation. Kevin Atkinson makes no representations
|
||||
about the suitability of this array for any purpose. It is provided
|
||||
"as is" without express or implied warranty.
|
||||
|
||||
Alan Beale <[email protected]> also deserves special credit as he has,
|
||||
in addition to providing the 12Dicts package and being a major
|
||||
contributor to the ENABLE word list, given me an incredible amount of
|
||||
feedback and created a number of special lists (those found in the
|
||||
Supplement) in order to help improve the overall quality of SCOWL.
|
||||
|
||||
The 10 level includes the 1000 most common English words (according to
|
||||
the Moby (TM) Words II [MWords] package), a subset of the 1000 most
|
||||
common words on the Internet (again, according to Moby Words II), and
|
||||
frequently class 16 from Brian Kelk's "UK English Wordlist
|
||||
with Frequency Classification".
|
||||
|
||||
The MWords package was explicitly placed in the public domain:
|
||||
|
||||
The Moby lexicon project is complete and has
|
||||
been place into the public domain. Use, sell,
|
||||
rework, excerpt and use in any way on any platform.
|
||||
|
||||
Placing this material on internal or public servers is
|
||||
also encouraged. The compiler is not aware of any
|
||||
export restrictions so freely distribute world-wide.
|
||||
|
||||
You can verify the public domain status by contacting
|
||||
|
||||
Grady Ward
|
||||
3449 Martha Ct.
|
||||
Arcata, CA 95521-4884
|
||||
|
||||
[email protected]
|
||||
[email protected]
|
||||
|
||||
The "UK English Wordlist With Frequency Classification" is also in the
|
||||
Public Domain:
|
||||
|
||||
Date: Sat, 08 Jul 2000 20:27:21 +0100
|
||||
From: Brian Kelk <[email protected]>
|
||||
|
||||
> I was wondering what the copyright status of your "UK English
|
||||
> Wordlist With Frequency Classification" word list as it seems to
|
||||
> be lacking any copyright notice.
|
||||
|
||||
There were many many sources in total, but any text marked
|
||||
"copyright" was avoided. Locally-written documentation was one
|
||||
source. An earlier version of the list resided in a filespace called
|
||||
PUBLIC on the University mainframe, because it was considered public
|
||||
domain.
|
||||
|
||||
Date: Tue, 11 Jul 2000 19:31:34 +0100
|
||||
|
||||
> So are you saying your word list is also in the public domain?
|
||||
|
||||
That is the intention.
|
||||
|
||||
The 20 level includes frequency classes 7-15 from Brian's word list.
|
||||
|
||||
The 35 level includes frequency classes 2-6 and words appearing in at
|
||||
least 11 of 12 dictionaries as indicated in the 12Dicts package. All
|
||||
words from the 12Dicts package have had likely inflections added via
|
||||
my inflection database.
|
||||
|
||||
The 12Dicts package and Supplement is in the Public Domain.
|
||||
|
||||
The WordNet database, which was used in the creation of the
|
||||
Inflections database, is under the following copyright:
|
||||
|
||||
This software and database is being provided to you, the LICENSEE,
|
||||
by Princeton University under the following license. By obtaining,
|
||||
using and/or copying this software and database, you agree that you
|
||||
have read, understood, and will comply with these terms and
|
||||
conditions.:
|
||||
|
||||
Permission to use, copy, modify and distribute this software and
|
||||
database and its documentation for any purpose and without fee or
|
||||
royalty is hereby granted, provided that you agree to comply with
|
||||
the following copyright notice and statements, including the
|
||||
disclaimer, and that the same appear on ALL copies of the software,
|
||||
database and documentation, including modifications that you make
|
||||
for internal use or for distribution.
|
||||
|
||||
WordNet 1.6 Copyright 1997 by Princeton University. All rights
|
||||
reserved.
|
||||
|
||||
THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON
|
||||
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON
|
||||
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT-
|
||||
ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE
|
||||
LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY
|
||||
THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
|
||||
|
||||
The name of Princeton University or Princeton may not be used in
|
||||
advertising or publicity pertaining to distribution of the software
|
||||
and/or database. Title to copyright in this software, database and
|
||||
any associated documentation shall at all times remain with
|
||||
Princeton University and LICENSEE agrees to preserve same.
|
||||
|
||||
The 40 level includes words from Alan's 3esl list found in version 4.0
|
||||
of his 12dicts package. Like his other stuff the 3esl list is also in the
|
||||
public domain.
|
||||
|
||||
The 50 level includes Brian's frequency class 1, words appearing
|
||||
in at least 5 of 12 of the dictionaries as indicated in the 12Dicts
|
||||
package, and uppercase words in at least 4 of the previous 12
|
||||
dictionaries. A decent number of proper names is also included: The
|
||||
top 1000 male, female, and Last names from the 1990 Census report; a
|
||||
list of names sent to me by Alan Beale; and a few names that I added
|
||||
myself. Finally a small list of abbreviations not commonly found in
|
||||
other word lists is included.
|
||||
|
||||
The name files form the Census report is a government document which I
|
||||
don't think can be copyrighted.
|
||||
|
||||
The file special-jargon.50 uses common.lst and word.lst from the
|
||||
"Unofficial Jargon File Word Lists" which is derived from "The Jargon
|
||||
File". All of which is in the Public Domain. This file also contain
|
||||
a few extra UNIX terms which are found in the file "unix-terms" in the
|
||||
special/ directory.
|
||||
|
||||
The 55 level includes words from Alan's 2of4brif list found in version
|
||||
4.0 of his 12dicts package. Like his other stuff the 2of4brif is also
|
||||
in the public domain.
|
||||
|
||||
The 60 level includes all words appearing in at least 2 of the 12
|
||||
dictionaries as indicated by the 12Dicts package.
|
||||
|
||||
The 70 level includes Brian's frequency class 0 and the 74,550 common
|
||||
dictionary words from the MWords package. The common dictionary words,
|
||||
like those from the 12Dicts package, have had all likely inflections
|
||||
added. The 70 level also included the 5desk list from version 4.0 of
|
||||
the 12Dics package which is in the public domain.
|
||||
|
||||
The 80 level includes the ENABLE word list, all the lists in the
|
||||
ENABLE supplement package (except for ABLE), the "UK Advanced Cryptics
|
||||
Dictionary" (UKACD), the list of signature words from the YAWL package,
|
||||
and the 10,196 places list from the MWords package.
|
||||
|
||||
The ENABLE package, mainted by M\Cooper <[email protected]>,
|
||||
is in the Public Domain:
|
||||
|
||||
The ENABLE master word list, WORD.LST, is herewith formally released
|
||||
into the Public Domain. Anyone is free to use it or distribute it in
|
||||
any manner they see fit. No fee or registration is required for its
|
||||
use nor are "contributions" solicited (if you feel you absolutely
|
||||
must contribute something for your own peace of mind, the authors of
|
||||
the ENABLE list ask that you make a donation on their behalf to your
|
||||
favorite charity). This word list is our gift to the Scrabble
|
||||
community, as an alternate to "official" word lists. Game designers
|
||||
may feel free to incorporate the WORD.LST into their games. Please
|
||||
mention the source and credit us as originators of the list. Note
|
||||
that if you, as a game designer, use the WORD.LST in your product,
|
||||
you may still copyright and protect your product, but you may *not*
|
||||
legally copyright or in any way restrict redistribution of the
|
||||
WORD.LST portion of your product. This *may* under law restrict your
|
||||
rights to restrict your users' rights, but that is only fair.
|
||||
|
||||
UKACD, by J Ross Beresford <[email protected]>, is under the
|
||||
following copyright:
|
||||
|
||||
Copyright (c) J Ross Beresford 1993-1999. All Rights Reserved.
|
||||
|
||||
The following restriction is placed on the use of this publication:
|
||||
if The UK Advanced Cryptics Dictionary is used in a software package
|
||||
or redistributed in any form, the copyright notice must be
|
||||
prominently displayed and the text of this document must be included
|
||||
verbatim.
|
||||
|
||||
There are no other restrictions: I would like to see the list
|
||||
distributed as widely as possible.
|
||||
|
||||
The 95 level includes the 354,984 single words, 256,772 compound
|
||||
words, 4,946 female names and the 3,897 male names, and 21,986 names
|
||||
from the MWords package, ABLE.LST from the ENABLE Supplement, and some
|
||||
additional words found in my part-of-speech database that were not
|
||||
found anywhere else.
|
||||
|
||||
Accent information was taken from UKACD.
|
||||
|
||||
The VarCon package was used to create the American, British, Canadian,
|
||||
and Australian word list. It is under the following copyright:
|
||||
|
||||
Copyright 2000-2016 by Kevin Atkinson
|
||||
|
||||
Permission to use, copy, modify, distribute and sell this array, the
|
||||
associated software, and its documentation for any purpose is hereby
|
||||
granted without fee, provided that the above copyright notice appears
|
||||
in all copies and that both that copyright notice and this permission
|
||||
notice appear in supporting documentation. Kevin Atkinson makes no
|
||||
representations about the suitability of this array for any
|
||||
purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
Copyright 2016 by Benjamin Titze
|
||||
|
||||
Permission to use, copy, modify, distribute and sell this array, the
|
||||
associated software, and its documentation for any purpose is hereby
|
||||
granted without fee, provided that the above copyright notice appears
|
||||
in all copies and that both that copyright notice and this permission
|
||||
notice appear in supporting documentation. Benjamin Titze makes no
|
||||
representations about the suitability of this array for any
|
||||
purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
Since the original words lists come from the Ispell distribution:
|
||||
|
||||
Copyright 1993, Geoff Kuenning, Granada Hills, CA
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. All modifications to the source code must be clearly marked as
|
||||
such. Binary redistributions based on modified source code
|
||||
must be clearly marked as modified versions in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
(clause 4 removed with permission from Geoff Kuenning)
|
||||
5. The name of Geoff Kuenning may not be used to endorse or promote
|
||||
products derived from this software without specific prior
|
||||
written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
|
||||
Build Date: Mon Dec 7 20:19:27 EST 2020
|
||||
Wordlist Command: mk-list --accents=strip en_US 60
|
||||
@@ -0,0 +1,205 @@
|
||||
SET UTF-8
|
||||
TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ'
|
||||
ICONV 1
|
||||
ICONV ’ '
|
||||
NOSUGGEST !
|
||||
|
||||
# ordinal numbers
|
||||
COMPOUNDMIN 1
|
||||
# only in compounds: 1th, 2th, 3th
|
||||
ONLYINCOMPOUND c
|
||||
# compound rules:
|
||||
# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.)
|
||||
# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.)
|
||||
COMPOUNDRULE 2
|
||||
COMPOUNDRULE n*1t
|
||||
COMPOUNDRULE n*mp
|
||||
WORDCHARS 0123456789
|
||||
|
||||
PFX A Y 1
|
||||
PFX A 0 re .
|
||||
|
||||
PFX I Y 1
|
||||
PFX I 0 in .
|
||||
|
||||
PFX U Y 1
|
||||
PFX U 0 un .
|
||||
|
||||
PFX C Y 1
|
||||
PFX C 0 de .
|
||||
|
||||
PFX E Y 1
|
||||
PFX E 0 dis .
|
||||
|
||||
PFX F Y 1
|
||||
PFX F 0 con .
|
||||
|
||||
PFX K Y 1
|
||||
PFX K 0 pro .
|
||||
|
||||
SFX V N 2
|
||||
SFX V e ive e
|
||||
SFX V 0 ive [^e]
|
||||
|
||||
SFX N Y 3
|
||||
SFX N e ion e
|
||||
SFX N y ication y
|
||||
SFX N 0 en [^ey]
|
||||
|
||||
SFX X Y 3
|
||||
SFX X e ions e
|
||||
SFX X y ications y
|
||||
SFX X 0 ens [^ey]
|
||||
|
||||
SFX H N 2
|
||||
SFX H y ieth y
|
||||
SFX H 0 th [^y]
|
||||
|
||||
SFX Y Y 1
|
||||
SFX Y 0 ly .
|
||||
|
||||
SFX G Y 2
|
||||
SFX G e ing e
|
||||
SFX G 0 ing [^e]
|
||||
|
||||
SFX J Y 2
|
||||
SFX J e ings e
|
||||
SFX J 0 ings [^e]
|
||||
|
||||
SFX D Y 4
|
||||
SFX D 0 d e
|
||||
SFX D y ied [^aeiou]y
|
||||
SFX D 0 ed [^ey]
|
||||
SFX D 0 ed [aeiou]y
|
||||
|
||||
SFX T N 4
|
||||
SFX T 0 st e
|
||||
SFX T y iest [^aeiou]y
|
||||
SFX T 0 est [aeiou]y
|
||||
SFX T 0 est [^ey]
|
||||
|
||||
SFX R Y 4
|
||||
SFX R 0 r e
|
||||
SFX R y ier [^aeiou]y
|
||||
SFX R 0 er [aeiou]y
|
||||
SFX R 0 er [^ey]
|
||||
|
||||
SFX Z Y 4
|
||||
SFX Z 0 rs e
|
||||
SFX Z y iers [^aeiou]y
|
||||
SFX Z 0 ers [aeiou]y
|
||||
SFX Z 0 ers [^ey]
|
||||
|
||||
SFX S Y 4
|
||||
SFX S y ies [^aeiou]y
|
||||
SFX S 0 s [aeiou]y
|
||||
SFX S 0 es [sxzh]
|
||||
SFX S 0 s [^sxzhy]
|
||||
|
||||
SFX P Y 3
|
||||
SFX P y iness [^aeiou]y
|
||||
SFX P 0 ness [aeiou]y
|
||||
SFX P 0 ness [^y]
|
||||
|
||||
SFX M Y 1
|
||||
SFX M 0 's .
|
||||
|
||||
SFX B Y 3
|
||||
SFX B 0 able [^aeiou]
|
||||
SFX B 0 able ee
|
||||
SFX B e able [^aeiou]e
|
||||
|
||||
SFX L Y 1
|
||||
SFX L 0 ment .
|
||||
|
||||
REP 90
|
||||
REP a ei
|
||||
REP ei a
|
||||
REP a ey
|
||||
REP ey a
|
||||
REP ai ie
|
||||
REP ie ai
|
||||
REP alot a_lot
|
||||
REP are air
|
||||
REP are ear
|
||||
REP are eir
|
||||
REP air are
|
||||
REP air ere
|
||||
REP ere air
|
||||
REP ere ear
|
||||
REP ere eir
|
||||
REP ear are
|
||||
REP ear air
|
||||
REP ear ere
|
||||
REP eir are
|
||||
REP eir ere
|
||||
REP ch te
|
||||
REP te ch
|
||||
REP ch ti
|
||||
REP ti ch
|
||||
REP ch tu
|
||||
REP tu ch
|
||||
REP ch s
|
||||
REP s ch
|
||||
REP ch k
|
||||
REP k ch
|
||||
REP f ph
|
||||
REP ph f
|
||||
REP gh f
|
||||
REP f gh
|
||||
REP i igh
|
||||
REP igh i
|
||||
REP i uy
|
||||
REP uy i
|
||||
REP i ee
|
||||
REP ee i
|
||||
REP j di
|
||||
REP di j
|
||||
REP j gg
|
||||
REP gg j
|
||||
REP j ge
|
||||
REP ge j
|
||||
REP s ti
|
||||
REP ti s
|
||||
REP s ci
|
||||
REP ci s
|
||||
REP k cc
|
||||
REP cc k
|
||||
REP k qu
|
||||
REP qu k
|
||||
REP kw qu
|
||||
REP o eau
|
||||
REP eau o
|
||||
REP o ew
|
||||
REP ew o
|
||||
REP oo ew
|
||||
REP ew oo
|
||||
REP ew ui
|
||||
REP ui ew
|
||||
REP oo ui
|
||||
REP ui oo
|
||||
REP ew u
|
||||
REP u ew
|
||||
REP oo u
|
||||
REP u oo
|
||||
REP u oe
|
||||
REP oe u
|
||||
REP u ieu
|
||||
REP ieu u
|
||||
REP ue ew
|
||||
REP ew ue
|
||||
REP uff ough
|
||||
REP oo ieu
|
||||
REP ieu oo
|
||||
REP ier ear
|
||||
REP ear ier
|
||||
REP ear air
|
||||
REP air ear
|
||||
REP w qu
|
||||
REP qu w
|
||||
REP z ss
|
||||
REP ss z
|
||||
REP shun tion
|
||||
REP shun sion
|
||||
REP shun cion
|
||||
REP size cise
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,463 @@
|
||||
# Programming terms the en_US dictionary lacks (IDEA bundles a similar "software terms" list).
|
||||
# One lowercase word per line; checked case-insensitively.
|
||||
abi
|
||||
accessor
|
||||
accessors
|
||||
addr
|
||||
admin
|
||||
aggregator
|
||||
alloc
|
||||
allocator
|
||||
amd
|
||||
analytics
|
||||
api
|
||||
apis
|
||||
app
|
||||
apps
|
||||
arg
|
||||
argc
|
||||
args
|
||||
argv
|
||||
arity
|
||||
arm
|
||||
async
|
||||
atomics
|
||||
attr
|
||||
attrs
|
||||
auth
|
||||
authn
|
||||
authz
|
||||
autocomplete
|
||||
autoload
|
||||
backend
|
||||
backends
|
||||
backoff
|
||||
backtrace
|
||||
bitfield
|
||||
bitflags
|
||||
bitmask
|
||||
bitset
|
||||
bitwise
|
||||
blob
|
||||
blobs
|
||||
bool
|
||||
boolean
|
||||
booleans
|
||||
borrowck
|
||||
bundler
|
||||
buf
|
||||
bufs
|
||||
builtin
|
||||
builtins
|
||||
bytecode
|
||||
cacheable
|
||||
callback
|
||||
callbacks
|
||||
callee
|
||||
callees
|
||||
callsite
|
||||
cfg
|
||||
changelog
|
||||
changeset
|
||||
charset
|
||||
checkbox
|
||||
checksum
|
||||
chmod
|
||||
chown
|
||||
cli
|
||||
codebase
|
||||
codec
|
||||
codegen
|
||||
codepoint
|
||||
codepoints
|
||||
color
|
||||
colors
|
||||
colorize
|
||||
combinator
|
||||
combinators
|
||||
commandline
|
||||
config
|
||||
configs
|
||||
configurable
|
||||
const
|
||||
consts
|
||||
constexpr
|
||||
coroutine
|
||||
coroutines
|
||||
cpu
|
||||
cpus
|
||||
crate
|
||||
crates
|
||||
css
|
||||
csv
|
||||
ctor
|
||||
ctx
|
||||
ctrl
|
||||
cwd
|
||||
daemon
|
||||
dao
|
||||
datagram
|
||||
dataset
|
||||
datasource
|
||||
datatype
|
||||
datetime
|
||||
db
|
||||
dbg
|
||||
dealloc
|
||||
debounce
|
||||
debounced
|
||||
debouncer
|
||||
decl
|
||||
decls
|
||||
decrement
|
||||
dedup
|
||||
dedupe
|
||||
deduplicate
|
||||
deinit
|
||||
del
|
||||
delim
|
||||
deps
|
||||
deque
|
||||
deref
|
||||
dereference
|
||||
deserialization
|
||||
deserialize
|
||||
deserialized
|
||||
deserializer
|
||||
desc
|
||||
dest
|
||||
dev
|
||||
devs
|
||||
dict
|
||||
dicts
|
||||
diff
|
||||
diffs
|
||||
dir
|
||||
dirs
|
||||
dirname
|
||||
dll
|
||||
dns
|
||||
docstring
|
||||
docstrings
|
||||
dom
|
||||
dtor
|
||||
dto
|
||||
dyn
|
||||
elem
|
||||
elems
|
||||
enum
|
||||
enums
|
||||
env
|
||||
envs
|
||||
eof
|
||||
eol
|
||||
eq
|
||||
errno
|
||||
esc
|
||||
eval
|
||||
exe
|
||||
expr
|
||||
exprs
|
||||
ext
|
||||
failover
|
||||
fallback
|
||||
fallthrough
|
||||
favicon
|
||||
fd
|
||||
fds
|
||||
fifo
|
||||
filename
|
||||
filenames
|
||||
filepath
|
||||
filesystem
|
||||
filesystems
|
||||
filetype
|
||||
fixme
|
||||
fmt
|
||||
fn
|
||||
fns
|
||||
foo
|
||||
bar
|
||||
baz
|
||||
qux
|
||||
frontend
|
||||
fs
|
||||
func
|
||||
funcs
|
||||
gc
|
||||
getter
|
||||
getters
|
||||
gettext
|
||||
github
|
||||
gitignore
|
||||
glob
|
||||
globals
|
||||
globbing
|
||||
goroutine
|
||||
goroutines
|
||||
gpu
|
||||
grpc
|
||||
gui
|
||||
gzip
|
||||
hardcode
|
||||
hardcoded
|
||||
hashable
|
||||
hashmap
|
||||
hashset
|
||||
hasher
|
||||
heapify
|
||||
hex
|
||||
hostname
|
||||
href
|
||||
html
|
||||
http
|
||||
https
|
||||
hunspell
|
||||
iframe
|
||||
ident
|
||||
idx
|
||||
impl
|
||||
impls
|
||||
init
|
||||
inits
|
||||
initializer
|
||||
inline
|
||||
inlined
|
||||
inlining
|
||||
int
|
||||
ints
|
||||
io
|
||||
iter
|
||||
iterable
|
||||
iterables
|
||||
iters
|
||||
javadoc
|
||||
jit
|
||||
js
|
||||
json
|
||||
jsx
|
||||
jwt
|
||||
kotlin
|
||||
kwargs
|
||||
lambda
|
||||
lambdas
|
||||
len
|
||||
lexer
|
||||
lhs
|
||||
lib
|
||||
libc
|
||||
libs
|
||||
linter
|
||||
localhost
|
||||
lookahead
|
||||
lookbehind
|
||||
lookup
|
||||
lookups
|
||||
lsp
|
||||
lvalue
|
||||
malloc
|
||||
markdown
|
||||
memcpy
|
||||
memoize
|
||||
memoized
|
||||
metadata
|
||||
middleware
|
||||
minified
|
||||
minify
|
||||
mixin
|
||||
mixins
|
||||
mmap
|
||||
modal
|
||||
monorepo
|
||||
msg
|
||||
msgs
|
||||
multiline
|
||||
mut
|
||||
mutex
|
||||
mutexes
|
||||
namespace
|
||||
namespaced
|
||||
namespaces
|
||||
nav
|
||||
nginx
|
||||
noop
|
||||
npm
|
||||
nullable
|
||||
num
|
||||
nums
|
||||
oauth
|
||||
obj
|
||||
objs
|
||||
offscreen
|
||||
ok
|
||||
opcode
|
||||
opcodes
|
||||
os
|
||||
param
|
||||
params
|
||||
parsable
|
||||
parser
|
||||
parsers
|
||||
passthrough
|
||||
pathname
|
||||
pid
|
||||
pids
|
||||
pixmap
|
||||
placeholder
|
||||
plugin
|
||||
plugins
|
||||
png
|
||||
popup
|
||||
popups
|
||||
postfix
|
||||
pre
|
||||
precompute
|
||||
precomputed
|
||||
prefetch
|
||||
preload
|
||||
prepend
|
||||
prepended
|
||||
printf
|
||||
println
|
||||
proc
|
||||
procs
|
||||
protobuf
|
||||
proxied
|
||||
ptr
|
||||
ptrs
|
||||
pubsub
|
||||
py
|
||||
quickfix
|
||||
rect
|
||||
rects
|
||||
redis
|
||||
refactor
|
||||
refactored
|
||||
refactoring
|
||||
refactorings
|
||||
regex
|
||||
regexes
|
||||
regexp
|
||||
renderer
|
||||
repl
|
||||
repo
|
||||
repos
|
||||
req
|
||||
res
|
||||
resize
|
||||
resizable
|
||||
resolver
|
||||
resp
|
||||
rhs
|
||||
rpc
|
||||
runnable
|
||||
runtime
|
||||
runtimes
|
||||
rustc
|
||||
rustfmt
|
||||
rvalue
|
||||
scalability
|
||||
schemas
|
||||
scrollbar
|
||||
sdk
|
||||
sdks
|
||||
semver
|
||||
serializable
|
||||
serialization
|
||||
serializer
|
||||
setter
|
||||
setters
|
||||
sha
|
||||
shader
|
||||
signup
|
||||
sizeof
|
||||
smtp
|
||||
sql
|
||||
sqlite
|
||||
src
|
||||
ssh
|
||||
ssl
|
||||
stacktrace
|
||||
stderr
|
||||
stdin
|
||||
stdio
|
||||
stdlib
|
||||
stdout
|
||||
str
|
||||
strs
|
||||
struct
|
||||
structs
|
||||
subclass
|
||||
subclasses
|
||||
subcommand
|
||||
subcommands
|
||||
subdirectory
|
||||
subdirectories
|
||||
submodule
|
||||
submodules
|
||||
subprocess
|
||||
subtree
|
||||
svg
|
||||
symlink
|
||||
symlinks
|
||||
sync
|
||||
syscall
|
||||
syscalls
|
||||
tcp
|
||||
templating
|
||||
termination
|
||||
timestamp
|
||||
timestamps
|
||||
tls
|
||||
tmp
|
||||
todo
|
||||
todos
|
||||
tokenize
|
||||
tokenized
|
||||
tokenizer
|
||||
toml
|
||||
toolchain
|
||||
toolchains
|
||||
toolbar
|
||||
tooltip
|
||||
tooltips
|
||||
tsx
|
||||
tuple
|
||||
tuples
|
||||
txt
|
||||
typeof
|
||||
ui
|
||||
uid
|
||||
uint
|
||||
unary
|
||||
unicode
|
||||
unix
|
||||
unmount
|
||||
unregister
|
||||
unset
|
||||
unsubscribe
|
||||
untracked
|
||||
uri
|
||||
uris
|
||||
url
|
||||
urls
|
||||
usize
|
||||
utf
|
||||
util
|
||||
utils
|
||||
uuid
|
||||
val
|
||||
vals
|
||||
var
|
||||
vars
|
||||
vec
|
||||
vecs
|
||||
viewport
|
||||
vm
|
||||
whitespace
|
||||
wildcard
|
||||
workspace
|
||||
workspaces
|
||||
xml
|
||||
yaml
|
||||
yml
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Spell checking for IDEA's "Proofreading › Typo" inspection: words of
|
||||
//! comments, string literals and declared names (regions come from the
|
||||
//! syntax layer), checked against the bundled en_US Hunspell dictionary, a
|
||||
//! list of programming terms and the user's accepted words and dictionaries.
|
||||
|
||||
mod words;
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub use words::{MIN_WORD_CHARS, WordSpan, words};
|
||||
|
||||
/// Where a region's words come from; strings and comments understand escapes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RegionKind {
|
||||
Comment,
|
||||
String,
|
||||
/// A declared identifier (`camelCase` / `snake_case` split).
|
||||
Name,
|
||||
/// Plain text / Markdown prose.
|
||||
Prose,
|
||||
}
|
||||
|
||||
/// A byte range of the text to check.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Region {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub kind: RegionKind,
|
||||
}
|
||||
|
||||
/// A misspelt word.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct Typo {
|
||||
/// UTF-16 range of the word.
|
||||
pub range: Range,
|
||||
pub word: String,
|
||||
}
|
||||
|
||||
/// At most this many typos are reported per text.
|
||||
pub const MAX_TYPOS: usize = 2000;
|
||||
|
||||
fn english() -> &'static spellbook::Dictionary {
|
||||
static DICT: OnceLock<spellbook::Dictionary> = OnceLock::new();
|
||||
DICT.get_or_init(|| {
|
||||
let aff = include_str!("dict/en_US.aff");
|
||||
let dic = include_str!("dict/en_US.dic");
|
||||
spellbook::Dictionary::new(aff, dic).expect("bundled en_US dictionary parses")
|
||||
})
|
||||
}
|
||||
|
||||
fn software_terms() -> &'static HashSet<&'static str> {
|
||||
static TERMS: OnceLock<HashSet<&'static str>> = OnceLock::new();
|
||||
TERMS.get_or_init(|| {
|
||||
include_str!("dict/software.txt")
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Words of a custom dictionary file: a plain word list or a Hunspell `.dic`
|
||||
/// (leading count line and `/FLAGS` ignored). Cached by modification time.
|
||||
fn dictionary_file(path: &Path) -> Arc<HashSet<String>> {
|
||||
type Cache = HashMap<PathBuf, (Option<SystemTime>, Arc<HashSet<String>>)>;
|
||||
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
|
||||
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok();
|
||||
let mut cache = CACHE.get_or_init(Default::default).lock();
|
||||
if let Some((at, words)) = cache.get(path)
|
||||
&& *at == modified
|
||||
{
|
||||
return words.clone();
|
||||
}
|
||||
let words: HashSet<String> = match std::fs::read_to_string(path) {
|
||||
Ok(text) => parse_word_list(&text),
|
||||
Err(e) => {
|
||||
tracing::debug!("custom dictionary {}: {e}", path.display());
|
||||
HashSet::new()
|
||||
}
|
||||
};
|
||||
let words = Arc::new(words);
|
||||
cache.insert(path.to_path_buf(), (modified, words.clone()));
|
||||
words
|
||||
}
|
||||
|
||||
/// Lowercased words of a word list / Hunspell `.dic` text.
|
||||
pub fn parse_word_list(text: &str) -> HashSet<String> {
|
||||
let mut lines = text.lines().map(str::trim).peekable();
|
||||
if lines.peek().is_some_and(|l| !l.is_empty() && l.chars().all(|c| c.is_ascii_digit())) {
|
||||
lines.next();
|
||||
}
|
||||
lines
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.map(|l| l.split('/').next().unwrap_or(l).trim().to_lowercase())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The dictionary plus the user's accepted words and custom dictionaries.
|
||||
#[derive(Default)]
|
||||
pub struct Speller {
|
||||
accepted: HashSet<String>,
|
||||
custom: Vec<Arc<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl Speller {
|
||||
pub fn new<S: AsRef<str>>(accepted: impl IntoIterator<Item = S>, dictionaries: &[PathBuf]) -> Self {
|
||||
Self {
|
||||
accepted: accepted.into_iter().map(|w| w.as_ref().trim().to_lowercase()).collect(),
|
||||
custom: dictionaries.iter().map(|p| dictionary_file(p)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `word` is spelt correctly. Case is forgiven the way identifiers
|
||||
/// need it: `url` passes for `URL`, `paris` for `Paris`.
|
||||
pub fn is_correct(&self, word: &str) -> bool {
|
||||
let lower = word.to_lowercase();
|
||||
if self.accepted.contains(&lower)
|
||||
|| software_terms().contains(lower.as_str())
|
||||
|| self.custom.iter().any(|d| d.contains(&lower))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let dict = english();
|
||||
if dict.check(word) || dict.check(&lower) {
|
||||
return true;
|
||||
}
|
||||
let mut chars = lower.chars();
|
||||
let capitalized: String = chars.next().into_iter().flat_map(char::to_uppercase).chain(chars).collect();
|
||||
dict.check(&capitalized) || dict.check(&lower.to_uppercase())
|
||||
}
|
||||
|
||||
/// Replacements for `word`, best first, in the word's case style.
|
||||
pub fn suggest(&self, word: &str, limit: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
english().suggest(word, &mut out);
|
||||
if out.is_empty() && word.chars().next().is_some_and(char::is_uppercase) {
|
||||
english().suggest(&word.to_lowercase(), &mut out);
|
||||
out = out.into_iter().map(|s| capitalize(&s)).collect();
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
out.retain(|s| s != word && seen.insert(s.clone()));
|
||||
out.truncate(limit);
|
||||
out
|
||||
}
|
||||
|
||||
/// Misspelt words of `regions` (document order) in `text`; `None` when
|
||||
/// `cancelled` fires.
|
||||
pub fn check(&self, text: &str, regions: &[Region], cancelled: &dyn Fn() -> bool) -> Option<Vec<Typo>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut verdicts: HashMap<&str, bool> = HashMap::new();
|
||||
let mut bad: Vec<WordSpan> = Vec::new();
|
||||
for (i, r) in regions.iter().enumerate() {
|
||||
if i % 64 == 0 && cancelled() {
|
||||
return None;
|
||||
}
|
||||
spans.clear();
|
||||
words(text, r.start, r.end, r.kind, &mut spans);
|
||||
for w in &spans {
|
||||
let word = &text[w.start..w.end];
|
||||
let ok = *verdicts.entry(word).or_insert_with(|| self.is_correct(word));
|
||||
if !ok {
|
||||
bad.push(*w);
|
||||
}
|
||||
}
|
||||
if bad.len() >= MAX_TYPOS {
|
||||
bad.truncate(MAX_TYPOS);
|
||||
break;
|
||||
}
|
||||
}
|
||||
bad.sort_by_key(|w| w.start);
|
||||
bad.dedup();
|
||||
let mut index = Utf16Walker::new(text);
|
||||
Some(
|
||||
bad.into_iter()
|
||||
.map(|w| Typo {
|
||||
range: Range::new(index.pos(w.start), index.pos(w.end)),
|
||||
word: text[w.start..w.end].to_string(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars.next().into_iter().flat_map(char::to_uppercase).chain(chars).collect()
|
||||
}
|
||||
|
||||
/// Regions of a plain-text / Markdown document: everything but fenced code
|
||||
/// blocks and inline code spans.
|
||||
pub fn prose_regions(text: &str) -> Vec<Region> {
|
||||
let mut out = Vec::new();
|
||||
let mut fence: Option<&str> = None;
|
||||
let mut offset = 0;
|
||||
for line in text.split_inclusive('\n') {
|
||||
let start = offset;
|
||||
offset += line.len();
|
||||
let trimmed = line.trim_start();
|
||||
let marker = ["```", "~~~"].into_iter().find(|m| trimmed.starts_with(m));
|
||||
match (fence, marker) {
|
||||
(Some(open), Some(m)) if m == open => {
|
||||
fence = None;
|
||||
continue;
|
||||
}
|
||||
(Some(_), _) => continue,
|
||||
(None, Some(m)) => {
|
||||
fence = Some(m);
|
||||
continue;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
// Text between backtick pairs is code.
|
||||
let mut in_code = false;
|
||||
let mut seg = start;
|
||||
for (i, c) in line.char_indices() {
|
||||
if c == '`' {
|
||||
if !in_code && seg < start + i {
|
||||
out.push(Region { start: seg, end: start + i, kind: RegionKind::Prose });
|
||||
}
|
||||
in_code = !in_code;
|
||||
seg = start + i + 1;
|
||||
}
|
||||
}
|
||||
if !in_code && seg < offset {
|
||||
out.push(Region { start: seg, end: offset, kind: RegionKind::Prose });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte offset → line / UTF-16 column, for increasing offsets.
|
||||
struct Utf16Walker<'a> {
|
||||
text: &'a str,
|
||||
byte: usize,
|
||||
pos: Pos,
|
||||
}
|
||||
|
||||
impl<'a> Utf16Walker<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self { text, byte: 0, pos: Pos::new(0, 0) }
|
||||
}
|
||||
|
||||
fn pos(&mut self, target: usize) -> Pos {
|
||||
if target < self.byte {
|
||||
*self = Self::new(self.text);
|
||||
}
|
||||
for c in self.text[self.byte..target].chars() {
|
||||
if c == '\n' {
|
||||
self.pos = Pos::new(self.pos.line + 1, 0);
|
||||
} else {
|
||||
self.pos.col += c.len_utf16() as u32;
|
||||
}
|
||||
}
|
||||
self.byte = target;
|
||||
self.pos
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn whole(text: &str, kind: RegionKind) -> Vec<Region> {
|
||||
vec![Region { start: 0, end: text.len(), kind }]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_misspellings_only() {
|
||||
let s = Speller::default();
|
||||
let text = "// Recieve the mesage from the server's backend";
|
||||
let typos = s.check(text, &whole(text, RegionKind::Comment), &|| false).unwrap();
|
||||
let words: Vec<_> = typos.iter().map(|t| t.word.as_str()).collect();
|
||||
assert_eq!(words, ["Recieve", "mesage"]);
|
||||
assert_eq!(typos[0].range, Range::new(Pos::new(0, 3), Pos::new(0, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_forgive_case_and_terms() {
|
||||
let s = Speller::default();
|
||||
for ok in ["url", "Paris", "json", "parser", "println"] {
|
||||
assert!(s.is_correct(ok), "{ok}");
|
||||
}
|
||||
assert!(!s.is_correct("lenght"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_words_and_custom_dictionaries() {
|
||||
let dir = std::env::temp_dir().join(format!("ide-spell-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dic = dir.join("team.dic");
|
||||
std::fs::write(&dic, "2\nfrobnicate/S\nQuuxify\n").unwrap();
|
||||
let s = Speller::new(["Openidea"], std::slice::from_ref(&dic));
|
||||
assert!(s.is_correct("openidea"));
|
||||
assert!(s.is_correct("frobnicate"));
|
||||
assert!(s.is_correct("quuxify"));
|
||||
assert!(!s.is_correct("zorblax"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_ranges() {
|
||||
let s = Speller::default();
|
||||
let text = "// 中文 é\n// wrng";
|
||||
let typos = s.check(text, &whole(text, RegionKind::Comment), &|| false).unwrap();
|
||||
assert_eq!(typos.len(), 1);
|
||||
assert_eq!(typos[0].range, Range::new(Pos::new(1, 3), Pos::new(1, 7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions() {
|
||||
let s = Speller::default();
|
||||
let out = s.suggest("mesage", 5);
|
||||
assert!(out.iter().any(|w| w == "message"), "{out:?}");
|
||||
assert!(s.suggest("Recieve", 5).iter().any(|w| w == "Receive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_skips_code() {
|
||||
let text = "Some prose `codez` here\n```\nfn wrng() {}\n```\nmore\n";
|
||||
let regions = prose_regions(text);
|
||||
let spans: Vec<_> = regions.iter().map(|r| &text[r.start..r.end]).collect();
|
||||
assert_eq!(spans, ["Some prose ", " here\n", "more\n"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation() {
|
||||
let s = Speller::default();
|
||||
assert!(s.check("x", &whole("x", RegionKind::Comment), &|| true).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Words worth spell checking in a region of source text, IDEA style:
|
||||
//! identifiers split at `camelCase` / `snake_case` boundaries; URLs, e-mail
|
||||
//! addresses, escapes, hex, acronyms, short tokens and tokens with digits skipped.
|
||||
|
||||
use crate::RegionKind;
|
||||
|
||||
/// Shorter words are never checked.
|
||||
pub const MIN_WORD_CHARS: usize = 3;
|
||||
|
||||
/// Byte range of a word in the text the region came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct WordSpan {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
/// Words of `text[start..end]` in order, as absolute byte ranges.
|
||||
pub fn words(text: &str, start: usize, end: usize, kind: RegionKind, out: &mut Vec<WordSpan>) {
|
||||
let Some(slice) = text.get(start..end) else { return };
|
||||
let escapes = !matches!(kind, RegionKind::Name);
|
||||
let mut chunk_start = None;
|
||||
for (i, c) in slice.char_indices().chain(std::iter::once((slice.len(), ' '))) {
|
||||
if c.is_whitespace() {
|
||||
if let Some(s) = chunk_start.take() {
|
||||
chunk(slice, s, i, escapes, start, out);
|
||||
}
|
||||
} else if chunk_start.is_none() {
|
||||
chunk_start = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// URLs, e-mail addresses and the like are not prose.
|
||||
fn skip_chunk(chunk: &str) -> bool {
|
||||
if chunk.contains("://") || chunk.starts_with("www.") {
|
||||
return true;
|
||||
}
|
||||
match chunk.find('@') {
|
||||
Some(at) => chunk[at + 1..].contains('.') && at > 0,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(slice: &str, from: usize, to: usize, escapes: bool, base: usize, out: &mut Vec<WordSpan>) {
|
||||
let text = &slice[from..to];
|
||||
if skip_chunk(text) {
|
||||
return;
|
||||
}
|
||||
let mut run: Option<usize> = None;
|
||||
let mut chars = text.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let word_char = c.is_alphanumeric() || c == '_' || c == '\'';
|
||||
if word_char {
|
||||
run.get_or_insert(i);
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = run.take() {
|
||||
token(text, s, i, base + from, out);
|
||||
}
|
||||
// `\n`, `\t`, `é`: the escape letter is not part of the next word.
|
||||
if escapes && c == '\\' {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
if let Some(s) = run {
|
||||
token(text, s, text.len(), base + from, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// One alphanumeric run (`parseHTMLDoc`, `max_len`, `don't`).
|
||||
fn token(text: &str, mut s: usize, mut e: usize, base: usize, out: &mut Vec<WordSpan>) {
|
||||
let trim = |c: char| c == '\'' || c == '_';
|
||||
while s < e && text[s..e].starts_with(trim) {
|
||||
s += 1;
|
||||
}
|
||||
while e > s && text[s..e].ends_with(trim) {
|
||||
e -= 1;
|
||||
}
|
||||
let tok = &text[s..e];
|
||||
if tok.is_empty() || tok.chars().any(|c| c.is_ascii_digit()) {
|
||||
return;
|
||||
}
|
||||
if tok.len() >= 6 && tok.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return;
|
||||
}
|
||||
for (ps, pe) in pieces(tok) {
|
||||
let w = &tok[ps..pe];
|
||||
let w = w.trim_matches('\'');
|
||||
if w.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ws = s + ps + tok[ps..pe].find(w).unwrap_or(0);
|
||||
if checkable(w) {
|
||||
out.push(WordSpan { start: base + ws, end: base + ws + w.len() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `snake_case` and `camelCase` / `HTTPServer` pieces of a token.
|
||||
fn pieces(tok: &str) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 0;
|
||||
for part in tok.split('_') {
|
||||
let chars: Vec<(usize, char)> = part.char_indices().collect();
|
||||
let mut start = 0;
|
||||
for k in 1..chars.len() {
|
||||
let (i, c) = chars[k];
|
||||
let prev = chars[k - 1].1;
|
||||
let next_lower = chars.get(k + 1).is_some_and(|&(_, n)| n.is_lowercase());
|
||||
let boundary =
|
||||
(prev.is_lowercase() && c.is_uppercase()) || (prev.is_uppercase() && c.is_uppercase() && next_lower);
|
||||
if boundary {
|
||||
out.push((offset + start, offset + i));
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
if !part.is_empty() {
|
||||
out.push((offset + start, offset + part.len()));
|
||||
}
|
||||
offset += part.len() + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Long enough, not an acronym, and in a script the bundled dictionary covers.
|
||||
fn checkable(w: &str) -> bool {
|
||||
let letters = w.chars().filter(|c| c.is_alphabetic()).count();
|
||||
if letters < MIN_WORD_CHARS {
|
||||
return false;
|
||||
}
|
||||
if !w.chars().any(|c| c.is_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
// Latin letters only (through Latin Extended-B): CJK, Cyrillic, … are not checked.
|
||||
w.chars().all(|c| c == '\'' || (c.is_alphabetic() && (c as u32) < 0x250))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn split(text: &str, kind: RegionKind) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
words(text, 0, text.len(), kind, &mut out);
|
||||
out.into_iter().map(|w| &text[w.start..w.end]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_identifiers() {
|
||||
assert_eq!(split("parseHTMLDocument", RegionKind::Name), ["parse", "Document"]);
|
||||
assert_eq!(split("max_line_length", RegionKind::Name), ["max", "line", "length"]);
|
||||
assert_eq!(split("getUserName", RegionKind::Name), ["get", "User", "Name"]);
|
||||
assert_eq!(split("XMLHttpRequest", RegionKind::Name), ["Http", "Request"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_noise() {
|
||||
assert_eq!(
|
||||
split("see https://exmple.com/foo or mail [email protected], id 0xdeadbeef v2beta", RegionKind::Comment),
|
||||
["see", "mail"]
|
||||
);
|
||||
assert_eq!(split("deadbeefcafe NASA ok", RegionKind::Comment), Vec::<&str>::new());
|
||||
assert_eq!(split("\"line\\nbreak\\twith tab\"", RegionKind::String), ["line", "break", "with", "tab"]);
|
||||
assert_eq!(split("// 中文注释 naïve", RegionKind::Comment), ["naïve"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_apostrophes_inside_words() {
|
||||
assert_eq!(split("'don't' shouldn't", RegionKind::Comment), ["don't", "shouldn't"]);
|
||||
}
|
||||
}
|
||||
+116
-12
@@ -1,7 +1,7 @@
|
||||
//! Commit history (topological walk) and per-commit changed files via gitoxide.
|
||||
|
||||
use crate::repo::{gx, resolve, to_string};
|
||||
use crate::{ChangeKind, ChangedFile, Commit, LogQuery, Repo};
|
||||
use crate::{ChangeKind, ChangedFile, Commit, LogOrder, LogQuery, Repo};
|
||||
use gix::ObjectId;
|
||||
use gix::bstr::ByteSlice;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -72,6 +72,7 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let limit = if q.limit == 0 { usize::MAX } else { q.limit as usize };
|
||||
let mut to_skip = q.skip as usize;
|
||||
|
||||
let path_walk = path_filter.is_some();
|
||||
type Walk<'a> = Box<dyn Iterator<Item = IdeResult<(ObjectId, Vec<ObjectId>)>> + 'a>;
|
||||
let walk: Walk<'_> = if let Some(range) = range {
|
||||
let ids = range_ids(r, range, path_filter.as_deref())?;
|
||||
@@ -81,29 +82,64 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
Ok((id, commit.parent_ids().map(|p| p.detach()).collect()))
|
||||
}))
|
||||
} else if let Some(rel) = path_filter {
|
||||
Box::new(PathWalk::new(&repo, tips, rel)?)
|
||||
Box::new(PathWalk::new(&repo, tips, rel, q.first_parent)?)
|
||||
} else if repo.is_shallow().unwrap_or(false) {
|
||||
// Topological order needs complete history; shallow clones fall back to date order.
|
||||
let walk = repo
|
||||
.rev_walk(tips)
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default()))
|
||||
.all()
|
||||
.map_err(gx)?;
|
||||
let mut walk = repo.rev_walk(tips).sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default()));
|
||||
if q.first_parent {
|
||||
walk = walk.first_parent_only();
|
||||
}
|
||||
let walk = walk.all().map_err(gx)?;
|
||||
Box::new(walk.map(|info| info.map(|i| (i.id, i.parent_ids.to_vec())).map_err(gx)))
|
||||
} else {
|
||||
use gix::traverse::commit::{Parents, topo};
|
||||
let graph = repo.commit_graph_if_enabled().ok().flatten();
|
||||
let topo = gix::traverse::commit::topo::Builder::from_iters(&repo.objects, tips, None::<Vec<ObjectId>>)
|
||||
.sorting(gix::traverse::commit::topo::Sorting::TopoOrder)
|
||||
let sorting = match q.order {
|
||||
LogOrder::Topo => topo::Sorting::TopoOrder,
|
||||
LogOrder::Date => topo::Sorting::DateOrder,
|
||||
};
|
||||
let topo = topo::Builder::from_iters(&repo.objects, tips, None::<Vec<ObjectId>>)
|
||||
.sorting(sorting)
|
||||
.parents(if q.first_parent { Parents::First } else { Parents::All })
|
||||
.with_commit_graph(graph)
|
||||
.build()
|
||||
.map_err(gx)?;
|
||||
Box::new(topo.map(|info| info.map(|i| (i.id, i.parent_ids.to_vec())).map_err(gx)))
|
||||
};
|
||||
|
||||
let shape = Shape {
|
||||
no_merges: q.no_merges,
|
||||
first_parent: q.first_parent,
|
||||
since: q.since_ms.map(|ms| (ms / 1000.0).floor() as i64),
|
||||
until: q.until_ms.map(|ms| (ms / 1000.0).ceil() as i64),
|
||||
};
|
||||
// Walks in (roughly) commit time order can stop once they are well past `since`.
|
||||
let time_ordered = range.is_none() && (q.order == LogOrder::Date || path_walk);
|
||||
let mut older_run = 0usize;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for item in walk {
|
||||
let (id, parents) = item?;
|
||||
let (id, mut parents) = item?;
|
||||
if q.first_parent {
|
||||
parents.truncate(1);
|
||||
}
|
||||
let commit = repo.find_commit(id).map_err(gx)?;
|
||||
let commit_time = commit.time().map(|t| t.seconds).unwrap_or(0);
|
||||
if shape.is_active() {
|
||||
let merge = commit.parent_ids().nth(1).is_some();
|
||||
if shape.since.is_some_and(|s| commit_time < s) {
|
||||
older_run += 1;
|
||||
if time_ordered && older_run >= OLDER_RUN_LIMIT {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
older_run = 0;
|
||||
if !shape.shows(merge, commit_time) {
|
||||
continue;
|
||||
}
|
||||
parents = shape.rewrite_parents(&repo, &parents)?;
|
||||
}
|
||||
let sig = commit.author().map_err(gx)?;
|
||||
let name = to_string(sig.name.trim().as_bstr());
|
||||
let email = to_string(sig.email.trim().as_bstr());
|
||||
@@ -136,6 +172,7 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
summary,
|
||||
message: message.trim_end().to_string(),
|
||||
refs: decorations.get(&id).cloned().unwrap_or_default(),
|
||||
commit_time_ms: commit_time as f64 * 1000.0,
|
||||
});
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
@@ -144,6 +181,66 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Consecutive commits older than `since` after which a time-ordered walk stops.
|
||||
const OLDER_RUN_LIMIT: usize = 64;
|
||||
|
||||
/// Which commits the Log graph shows independently of the detail filters (author, text):
|
||||
/// `--no-merges` and the date range. Hidden commits are skipped and the parents of shown
|
||||
/// ones rewritten past them, like git's parent rewriting.
|
||||
struct Shape {
|
||||
no_merges: bool,
|
||||
first_parent: bool,
|
||||
/// Commit time bounds in seconds; `since` inclusive, `until` exclusive.
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
}
|
||||
|
||||
/// Upper bound of commits visited while rewriting one commit's parents.
|
||||
const REWRITE_LIMIT: usize = 4096;
|
||||
|
||||
impl Shape {
|
||||
fn is_active(&self) -> bool {
|
||||
self.no_merges || self.since.is_some() || self.until.is_some()
|
||||
}
|
||||
|
||||
fn shows(&self, merge: bool, time: i64) -> bool {
|
||||
!(self.no_merges && merge) && self.since.is_none_or(|s| time >= s) && self.until.is_none_or(|u| time < u)
|
||||
}
|
||||
|
||||
/// `parents` with each hidden one replaced by its nearest shown ancestors; parents
|
||||
/// older than `since` are dropped (their history is not shown).
|
||||
fn rewrite_parents(&self, repo: &gix::Repository, parents: &[ObjectId]) -> IdeResult<Vec<ObjectId>> {
|
||||
let mut out: Vec<ObjectId> = Vec::new();
|
||||
let mut seen: HashSet<ObjectId> = HashSet::new();
|
||||
let mut stack: Vec<ObjectId> = parents.iter().rev().copied().collect();
|
||||
let mut visited = 0usize;
|
||||
while let Some(p) = stack.pop() {
|
||||
if !seen.insert(p) || !repo.has_object(p) {
|
||||
continue;
|
||||
}
|
||||
visited += 1;
|
||||
if visited > REWRITE_LIMIT {
|
||||
break;
|
||||
}
|
||||
let commit = repo.find_commit(p).map_err(gx)?;
|
||||
let time = commit.time().map(|t| t.seconds).unwrap_or(0);
|
||||
if self.since.is_some_and(|s| time < s) {
|
||||
continue;
|
||||
}
|
||||
let ids: Vec<ObjectId> = commit.parent_ids().map(|i| i.detach()).collect();
|
||||
if self.shows(ids.len() > 1, time) {
|
||||
if !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let next = if self.first_parent { &ids[..ids.len().min(1)] } else { &ids[..] };
|
||||
stack.extend(next.iter().rev());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit ids of a revision range (`A..B`, `A...B`, `^A B`…), topologically ordered, via
|
||||
/// `git rev-list` so every rev syntax git understands (`@{u}`, `HEAD~3`) works.
|
||||
fn range_ids(r: &Repo, range: &str, path: Option<&str>) -> IdeResult<Vec<ObjectId>> {
|
||||
@@ -234,10 +331,11 @@ struct PathWalk<'r> {
|
||||
seen: HashSet<ObjectId>,
|
||||
memo: HashMap<ObjectId, Option<ObjectId>>,
|
||||
seq: u64,
|
||||
first_parent: bool,
|
||||
}
|
||||
|
||||
impl<'r> PathWalk<'r> {
|
||||
fn new(repo: &'r gix::Repository, tips: Vec<ObjectId>, rel: String) -> IdeResult<Self> {
|
||||
fn new(repo: &'r gix::Repository, tips: Vec<ObjectId>, rel: String, first_parent: bool) -> IdeResult<Self> {
|
||||
let mut walk = PathWalk {
|
||||
repo,
|
||||
rel,
|
||||
@@ -245,6 +343,7 @@ impl<'r> PathWalk<'r> {
|
||||
seen: Default::default(),
|
||||
memo: Default::default(),
|
||||
seq: 0,
|
||||
first_parent,
|
||||
};
|
||||
for tip in tips {
|
||||
walk.push(tip)?;
|
||||
@@ -268,7 +367,12 @@ impl<'r> PathWalk<'r> {
|
||||
let commit = self.repo.find_commit(id).map_err(gx)?;
|
||||
let parents: Vec<ObjectId> = commit.parent_ids().map(|p| p.detach()).collect();
|
||||
// Parents cut off by a shallow boundary are treated as absent.
|
||||
let present: Vec<ObjectId> = parents.iter().copied().filter(|p| self.repo.has_object(p)).collect();
|
||||
let present: Vec<ObjectId> = parents
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| self.repo.has_object(p))
|
||||
.take(if self.first_parent { 1 } else { usize::MAX })
|
||||
.collect();
|
||||
let mine = path_entry(self.repo, id, &self.rel, &mut self.memo)?;
|
||||
let mut same_parent = None;
|
||||
for p in &present {
|
||||
|
||||
@@ -88,6 +88,20 @@ pub struct Commit {
|
||||
pub summary: String,
|
||||
pub message: String,
|
||||
pub refs: Vec<String>,
|
||||
/// Committer time (Log › View Options › Commit Timestamp).
|
||||
#[serde(default)]
|
||||
pub commit_time_ms: f64,
|
||||
}
|
||||
|
||||
/// Order of the Git Log (Graph Options › Sort).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LogOrder {
|
||||
/// `--topo-order`: lines of history are not intermixed.
|
||||
#[default]
|
||||
Topo,
|
||||
/// `--date-order`: commit time, but never a parent before its children.
|
||||
Date,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
@@ -115,6 +129,21 @@ pub struct LogQuery {
|
||||
/// More author needles next to `author`; a commit matches any of them (Log User filter).
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub order: LogOrder,
|
||||
/// Follow only the first parent of merges (`--first-parent`).
|
||||
#[serde(default)]
|
||||
pub first_parent: bool,
|
||||
/// Leave out merge commits (`--no-merges`); their children link to their parents.
|
||||
#[serde(default)]
|
||||
pub no_merges: bool,
|
||||
/// Committer time bounds (epoch ms; `since` inclusive, `until` exclusive). Parents
|
||||
/// outside the range are rewritten to their nearest shown ancestors (or dropped when
|
||||
/// older), so the graph stays connected.
|
||||
#[serde(default)]
|
||||
pub since_ms: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub until_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
@@ -397,6 +426,7 @@ pub mod changelists;
|
||||
mod cli;
|
||||
mod diff;
|
||||
mod history;
|
||||
mod manage;
|
||||
mod ops;
|
||||
mod patch;
|
||||
mod refs;
|
||||
@@ -409,6 +439,7 @@ mod write;
|
||||
|
||||
pub use cli::{detected_git_executable, git_version, set_git_executable};
|
||||
pub use diff::build_partial_patch;
|
||||
pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree};
|
||||
pub use remotes::Remote;
|
||||
pub use roots::Repos;
|
||||
|
||||
@@ -420,6 +451,11 @@ pub fn clone(url: &str, dest: &Path, cancel: &CancellationToken, on_output: &dyn
|
||||
cli::run_streaming(parent, &["clone", "--progress", "--", url, &dest], cancel, on_output)
|
||||
}
|
||||
|
||||
/// Creates an empty repository in `dir` (`git init`).
|
||||
pub fn init(dir: &Path) -> IdeResult<()> {
|
||||
cli::run(dir, &["init", "-q"], None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Line hunks of `new_text` against `old_text` (CRLF-normalised, Histogram diff), in the
|
||||
/// order [`build_partial_patch`] indexes them.
|
||||
pub fn diff_hunks(old_text: &str, new_text: &str) -> Vec<LineHunk> {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Git menu dialogs: Merge / Rebase / Pull with options, Manage Remotes and Worktrees.
|
||||
|
||||
use crate::Repo;
|
||||
use crate::cli::{self, run};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
/// Merge dialog › Modify options (shared by the Pull dialog).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MergeOptions {
|
||||
#[serde(default)]
|
||||
pub no_ff: bool,
|
||||
#[serde(default)]
|
||||
pub ff_only: bool,
|
||||
#[serde(default)]
|
||||
pub squash: bool,
|
||||
#[serde(default)]
|
||||
pub no_commit: bool,
|
||||
/// `-m`: message of the merge commit (Merge dialog only).
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Rebase dialog options.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RebaseOptions {
|
||||
/// `--onto <newbase>`: replay `upstream..HEAD` onto this revision instead.
|
||||
#[serde(default)]
|
||||
pub onto: Option<String>,
|
||||
#[serde(default)]
|
||||
pub rebase_merges: bool,
|
||||
#[serde(default)]
|
||||
pub keep_empty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Worktree {
|
||||
pub path: ide_core::IpcPath,
|
||||
/// Checked-out commit; `None` for a bare main repository.
|
||||
pub head: Option<String>,
|
||||
/// Short branch name; `None` when HEAD is detached.
|
||||
pub branch: Option<String>,
|
||||
/// The main working tree (cannot be removed).
|
||||
pub main: bool,
|
||||
pub locked: bool,
|
||||
pub prunable: bool,
|
||||
}
|
||||
|
||||
/// Manage Remotes edits.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum RemoteEdit {
|
||||
Add {
|
||||
name: String,
|
||||
url: String,
|
||||
},
|
||||
Remove {
|
||||
name: String,
|
||||
},
|
||||
/// Renames `name` to `new_name` (when different) and sets its URL.
|
||||
Edit {
|
||||
name: String,
|
||||
new_name: String,
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn arg(value: &str, what: &str) -> IdeResult<String> {
|
||||
let v = value.trim();
|
||||
if v.is_empty() || v.starts_with('-') {
|
||||
return Err(IdeError::invalid(format!("invalid {what} '{value}'")));
|
||||
}
|
||||
Ok(v.to_string())
|
||||
}
|
||||
|
||||
fn merge_flags(o: &MergeOptions) -> IdeResult<Vec<&'static str>> {
|
||||
if o.ff_only && (o.no_ff || o.squash) {
|
||||
return Err(IdeError::invalid("--ff-only cannot be combined with --no-ff or --squash"));
|
||||
}
|
||||
if o.no_ff && o.squash {
|
||||
return Err(IdeError::invalid("--no-ff cannot be combined with --squash"));
|
||||
}
|
||||
let mut flags = Vec::new();
|
||||
if o.no_ff {
|
||||
flags.push("--no-ff");
|
||||
}
|
||||
if o.ff_only {
|
||||
flags.push("--ff-only");
|
||||
}
|
||||
if o.squash {
|
||||
flags.push("--squash");
|
||||
}
|
||||
if o.no_commit {
|
||||
flags.push("--no-commit");
|
||||
}
|
||||
Ok(flags)
|
||||
}
|
||||
|
||||
/// `git merge` arguments for the Merge dialog (local changes are stashed and restored).
|
||||
pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult<Vec<String>> {
|
||||
if branches.is_empty() {
|
||||
return Err(IdeError::invalid("no branch to merge"));
|
||||
}
|
||||
let mut args: Vec<String> = vec!["merge".into(), "--autostash".into()];
|
||||
args.extend(merge_flags(o)?.into_iter().map(String::from));
|
||||
match o.message.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
|
||||
Some(m) => args.extend(["-m".into(), m.to_string()]),
|
||||
None => args.push("--no-edit".into()),
|
||||
}
|
||||
for b in branches {
|
||||
args.push(arg(b, "branch")?);
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// `git rebase` arguments for the Rebase dialog.
|
||||
pub(crate) fn rebase_args(upstream: &str, o: &RebaseOptions) -> IdeResult<Vec<String>> {
|
||||
let mut args: Vec<String> = vec!["rebase".into(), "--autostash".into()];
|
||||
if o.rebase_merges {
|
||||
args.push("--rebase-merges".into());
|
||||
}
|
||||
if o.keep_empty {
|
||||
args.push("--keep-empty".into());
|
||||
}
|
||||
if let Some(onto) = o.onto.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
args.extend(["--onto".into(), arg(onto, "revision")?]);
|
||||
}
|
||||
args.push(arg(upstream, "revision")?);
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// `git pull` arguments for the Pull dialog: `remote` and its `branches` (none = the
|
||||
/// upstream's configured merge branch).
|
||||
pub(crate) fn pull_args(remote: &str, branches: &[String], rebase: bool, o: &MergeOptions) -> IdeResult<Vec<String>> {
|
||||
let mut args: Vec<String> = vec!["pull".into(), "--progress".into(), "--autostash".into()];
|
||||
if rebase {
|
||||
if o.no_ff || o.ff_only || o.squash || o.no_commit {
|
||||
return Err(IdeError::invalid("--rebase cannot be combined with merge options"));
|
||||
}
|
||||
args.push("--rebase".into());
|
||||
} else {
|
||||
args.push("--no-rebase".into());
|
||||
args.extend(merge_flags(o)?.into_iter().map(String::from));
|
||||
}
|
||||
args.push(arg(remote, "remote")?);
|
||||
for b in branches {
|
||||
args.push(arg(b, "branch")?);
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Parses `git worktree list --porcelain`.
|
||||
pub(crate) fn parse_worktrees(text: &str) -> Vec<Worktree> {
|
||||
let mut out: Vec<Worktree> = Vec::new();
|
||||
for block in text.split("\n\n") {
|
||||
let mut wt: Option<Worktree> = None;
|
||||
let mut bare = false;
|
||||
for line in block.lines() {
|
||||
let (key, value) = line.split_once(' ').unwrap_or((line, ""));
|
||||
match key {
|
||||
"worktree" => {
|
||||
wt = Some(Worktree {
|
||||
path: value.into(),
|
||||
head: None,
|
||||
branch: None,
|
||||
main: out.is_empty(),
|
||||
locked: false,
|
||||
prunable: false,
|
||||
})
|
||||
}
|
||||
"HEAD" => {
|
||||
if let Some(w) = wt.as_mut() {
|
||||
w.head = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
"branch" => {
|
||||
if let Some(w) = wt.as_mut() {
|
||||
w.branch = Some(value.strip_prefix("refs/heads/").unwrap_or(value).to_string());
|
||||
}
|
||||
}
|
||||
"locked" => {
|
||||
if let Some(w) = wt.as_mut() {
|
||||
w.locked = true;
|
||||
}
|
||||
}
|
||||
"prunable" => {
|
||||
if let Some(w) = wt.as_mut() {
|
||||
w.prunable = true;
|
||||
}
|
||||
}
|
||||
"bare" => bare = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(mut w) = wt {
|
||||
if bare {
|
||||
w.head = None;
|
||||
}
|
||||
out.push(w);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
/// Merges `branches` into the current branch (Git › Merge…).
|
||||
pub fn merge_with(&self, branches: &[String], options: &MergeOptions) -> IdeResult<()> {
|
||||
let args = merge_args(branches, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Rebases the current branch onto `upstream` (or `upstream..HEAD` onto `options.onto`).
|
||||
pub fn rebase_with(&self, upstream: &str, options: &RebaseOptions) -> IdeResult<()> {
|
||||
let args = rebase_args(upstream, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Git › Pull…: pulls `branches` of `remote` into the current branch.
|
||||
pub fn pull_from(
|
||||
&self,
|
||||
remote: &str,
|
||||
branches: &[String],
|
||||
rebase: bool,
|
||||
options: &MergeOptions,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let args = pull_args(remote, branches, rebase, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
cli::run_streaming(self.root(), &args, cancel, on_output)
|
||||
}
|
||||
|
||||
/// Manage Remotes: adds, removes or edits a remote.
|
||||
pub fn edit_remote(&self, edit: &RemoteEdit) -> IdeResult<()> {
|
||||
match edit {
|
||||
RemoteEdit::Add { name, url } => {
|
||||
let (name, url) = (arg(name, "remote name")?, arg(url, "URL")?);
|
||||
run(self.root(), &["remote", "add", "--", &name, &url], None)?;
|
||||
}
|
||||
RemoteEdit::Remove { name } => {
|
||||
let name = arg(name, "remote name")?;
|
||||
run(self.root(), &["remote", "remove", &name], None)?;
|
||||
}
|
||||
RemoteEdit::Edit { name, new_name, url } => {
|
||||
let (name, new_name, url) =
|
||||
(arg(name, "remote name")?, arg(new_name, "remote name")?, arg(url, "URL")?);
|
||||
if name != new_name {
|
||||
run(self.root(), &["remote", "rename", &name, &new_name], None)?;
|
||||
}
|
||||
run(self.root(), &["remote", "set-url", "--", &new_name, &url], None)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Working trees of the repository, the main one first.
|
||||
pub fn worktrees(&self) -> IdeResult<Vec<Worktree>> {
|
||||
let out = run(self.root(), &["worktree", "list", "--porcelain"], None)?;
|
||||
Ok(parse_worktrees(&out.stdout_str()))
|
||||
}
|
||||
|
||||
/// Git › New Worktree…: checks out `commitish` at `path`, on a new branch `new_branch`
|
||||
/// when given (otherwise `commitish` must be a branch not checked out elsewhere).
|
||||
pub fn add_worktree(&self, path: &Path, commitish: &str, new_branch: Option<&str>) -> IdeResult<()> {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let commitish = arg(commitish, "revision")?;
|
||||
let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
|
||||
if let Some(b) = new_branch.map(str::trim).filter(|b| !b.is_empty()) {
|
||||
args.extend(["-b".into(), arg(b, "branch name")?]);
|
||||
}
|
||||
args.extend(["--".into(), path, commitish]);
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn remove_worktree(&self, path: &Path, force: bool) -> IdeResult<()> {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let mut args = vec!["worktree", "remove"];
|
||||
if force {
|
||||
args.push("--force");
|
||||
}
|
||||
args.extend(["--", &path]);
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_args_follow_the_options() {
|
||||
let o = MergeOptions { no_ff: true, message: Some("Merge it".into()), ..Default::default() };
|
||||
assert_eq!(
|
||||
merge_args(&["dev".into()], &o).unwrap(),
|
||||
["merge", "--autostash", "--no-ff", "-m", "Merge it", "dev"]
|
||||
);
|
||||
assert_eq!(
|
||||
merge_args(&["a".into(), "b".into()], &MergeOptions::default()).unwrap(),
|
||||
["merge", "--autostash", "--no-edit", "a", "b"]
|
||||
);
|
||||
let bad = MergeOptions { ff_only: true, squash: true, ..Default::default() };
|
||||
assert!(merge_args(&["dev".into()], &bad).is_err());
|
||||
assert!(merge_args(&["--evil".into()], &MergeOptions::default()).is_err());
|
||||
assert!(merge_args(&[], &MergeOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_and_pull_args() {
|
||||
let o = RebaseOptions { onto: Some("main".into()), rebase_merges: true, keep_empty: false };
|
||||
assert_eq!(
|
||||
rebase_args("feature~3", &o).unwrap(),
|
||||
["rebase", "--autostash", "--rebase-merges", "--onto", "main", "feature~3"]
|
||||
);
|
||||
assert_eq!(
|
||||
pull_args("origin", &["main".into()], false, &MergeOptions { ff_only: true, ..Default::default() })
|
||||
.unwrap(),
|
||||
["pull", "--progress", "--autostash", "--no-rebase", "--ff-only", "origin", "main"]
|
||||
);
|
||||
assert_eq!(
|
||||
pull_args("origin", &[], true, &MergeOptions::default()).unwrap(),
|
||||
["pull", "--progress", "--autostash", "--rebase", "origin"]
|
||||
);
|
||||
assert!(pull_args("origin", &[], true, &MergeOptions { squash: true, ..Default::default() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktree_list() {
|
||||
let text = "worktree /repo\nHEAD 1111\nbranch refs/heads/main\n\nworktree /wt/fix\nHEAD 2222\ndetached\nlocked\n\nworktree /wt/gone\nHEAD 3333\nbranch refs/heads/old\nprunable gitdir file points to non-existent location\n";
|
||||
let wts = parse_worktrees(text);
|
||||
assert_eq!(wts.len(), 3);
|
||||
assert!(wts[0].main && wts[0].branch.as_deref() == Some("main"));
|
||||
assert!(!wts[1].main && wts[1].branch.is_none() && wts[1].locked);
|
||||
assert!(wts[2].prunable && wts[2].head.as_deref() == Some("3333"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Git Log graph options (order, first parent, no merges, date range) and the Git menu
|
||||
//! dialogs' operations (merge / rebase with options, remotes, worktrees).
|
||||
|
||||
use ide_vcs::*;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn git(dir: &Path, args: &[&str], time: Option<i64>) -> String {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "user.name=Test", "-c", "[email protected]"])
|
||||
.args(["-c", "commit.gpgsign=false", "-c", "core.hooksPath=.git/hooks"])
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0");
|
||||
if let Some(t) = time {
|
||||
let date = format!("@{t} +0000");
|
||||
cmd.env("GIT_AUTHOR_DATE", &date).env("GIT_COMMITTER_DATE", &date);
|
||||
}
|
||||
let out = cmd.output().expect("git runs");
|
||||
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn commit(dir: &Path, file: &str, msg: &str, time: i64) -> String {
|
||||
std::fs::write(dir.join(file), msg).unwrap();
|
||||
git(dir, &["add", "-A"], None);
|
||||
git(dir, &["commit", "-q", "-m", msg], Some(time));
|
||||
git(dir, &["rev-parse", "HEAD"], None)
|
||||
}
|
||||
|
||||
struct History {
|
||||
_tmp: tempfile::TempDir,
|
||||
repo: Repo,
|
||||
c1: String,
|
||||
s1: String,
|
||||
c2: String,
|
||||
m: String,
|
||||
c3: String,
|
||||
}
|
||||
|
||||
/// c1 ← s1 (side) ; c1 ← c2 ← m (merges s1) ← c3, committed at 1000…5000 s.
|
||||
fn history() -> History {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().to_path_buf();
|
||||
git(&dir, &["init", "-q"], None);
|
||||
let c1 = commit(&dir, "a.txt", "c1", 1000);
|
||||
git(&dir, &["checkout", "-q", "-b", "side"], None);
|
||||
let s1 = commit(&dir, "s.txt", "s1", 2000);
|
||||
git(&dir, &["checkout", "-q", "main"], None);
|
||||
let c2 = commit(&dir, "b.txt", "c2", 3000);
|
||||
git(&dir, &["merge", "-q", "--no-ff", "-m", "merge side", "side"], Some(4000));
|
||||
let m = git(&dir, &["rev-parse", "HEAD"], None);
|
||||
let c3 = commit(&dir, "c.txt", "c3", 5000);
|
||||
let repo = Repo::discover(&dir).unwrap().expect("repository");
|
||||
History { _tmp: tmp, repo, c1, s1, c2, m, c3 }
|
||||
}
|
||||
|
||||
fn log(h: &History, q: LogQuery) -> Vec<Commit> {
|
||||
h.repo.log(&LogQuery { branches: vec!["main".into()], ..q }).unwrap()
|
||||
}
|
||||
|
||||
fn ids(commits: &[Commit]) -> Vec<String> {
|
||||
commits.iter().map(|c| c.id.clone()).collect()
|
||||
}
|
||||
|
||||
fn parents_of(commits: &[Commit], id: &str) -> Vec<String> {
|
||||
commits.iter().find(|c| c.id == id).unwrap().parents.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_order_follows_commit_time_and_reports_it() {
|
||||
let h = history();
|
||||
let out = log(&h, LogQuery { order: LogOrder::Date, ..Default::default() });
|
||||
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.s1.clone(), h.c1.clone()]);
|
||||
assert_eq!(out[0].commit_time_ms, 5_000_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_merges_links_children_to_the_merge_parents() {
|
||||
let h = history();
|
||||
let out = log(&h, LogQuery { no_merges: true, ..Default::default() });
|
||||
assert!(!ids(&out).contains(&h.m));
|
||||
assert_eq!(out.len(), 4);
|
||||
assert_eq!(parents_of(&out, &h.c3), vec![h.c2.clone(), h.s1.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_parent_skips_merged_branches() {
|
||||
let h = history();
|
||||
let out = log(&h, LogQuery { first_parent: true, ..Default::default() });
|
||||
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.c1.clone()]);
|
||||
assert_eq!(parents_of(&out, &h.m), vec![h.c2.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_range_keeps_the_graph_connected() {
|
||||
let h = history();
|
||||
let since = log(&h, LogQuery { since_ms: Some(2_500_000.0), ..Default::default() });
|
||||
assert_eq!(ids(&since), vec![h.c3.clone(), h.m.clone(), h.c2.clone()]);
|
||||
// Older parents are not shown, so they are dropped.
|
||||
assert_eq!(parents_of(&since, &h.m), vec![h.c2.clone()]);
|
||||
assert!(parents_of(&since, &h.c2).is_empty());
|
||||
|
||||
let window = log(&h, LogQuery { since_ms: Some(1_500_000.0), until_ms: Some(4_500_000.0), ..Default::default() });
|
||||
let mut got = ids(&window);
|
||||
got.sort();
|
||||
let mut want = vec![h.m.clone(), h.c2.clone(), h.s1.clone()];
|
||||
want.sort();
|
||||
assert_eq!(got, want);
|
||||
|
||||
// A commit newer than `until` is skipped and its child links past it.
|
||||
let until = log(&h, LogQuery { until_ms: Some(3_500_000.0), order: LogOrder::Date, ..Default::default() });
|
||||
assert_eq!(ids(&until), vec![h.c2.clone(), h.s1.clone(), h.c1.clone()]);
|
||||
let combined = log(&h, LogQuery { until_ms: Some(4_500_000.0), no_merges: true, ..Default::default() });
|
||||
assert!(!ids(&combined).contains(&h.m) && !ids(&combined).contains(&h.c3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_with_options_and_rebase_onto() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"], None);
|
||||
let base = commit(dir, "a.txt", "base", 1000);
|
||||
git(dir, &["checkout", "-q", "-b", "feature"], None);
|
||||
let f1 = commit(dir, "f.txt", "f1", 2000);
|
||||
git(dir, &["checkout", "-q", "main"], None);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
|
||||
let ff_only = MergeOptions { ff_only: true, squash: true, ..Default::default() };
|
||||
assert!(repo.merge_with(&["feature".into()], &ff_only).is_err());
|
||||
let no_ff = MergeOptions { no_ff: true, message: Some("Merge feature now".into()), ..Default::default() };
|
||||
repo.merge_with(&["feature".into()], &no_ff).unwrap();
|
||||
assert_eq!(git(dir, &["log", "-1", "--format=%P %s"], None), format!("{base} {f1} Merge feature now"));
|
||||
|
||||
git(dir, &["checkout", "-q", "-b", "topic", &base], None);
|
||||
commit(dir, "t.txt", "topic", 3000);
|
||||
repo.rebase_with("main", &RebaseOptions::default()).unwrap();
|
||||
assert_eq!(git(dir, &["rev-list", "--count", "main..topic"], None), "1");
|
||||
assert_eq!(git(dir, &["merge-base", "main", "topic"], None), git(dir, &["rev-parse", "main"], None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remotes_and_worktrees_are_managed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("work");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
git(&dir, &["init", "-q"], None);
|
||||
commit(&dir, "a.txt", "base", 1000);
|
||||
let repo = Repo::discover(&dir).unwrap().unwrap();
|
||||
|
||||
repo.edit_remote(&RemoteEdit::Add { name: "origin".into(), url: "https://example.com/a.git".into() }).unwrap();
|
||||
repo.edit_remote(&RemoteEdit::Edit {
|
||||
name: "origin".into(),
|
||||
new_name: "upstream".into(),
|
||||
url: "https://example.com/b.git".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repo.remotes().unwrap(),
|
||||
vec![Remote { name: "upstream".into(), url: "https://example.com/b.git".into() }]
|
||||
);
|
||||
assert!(repo.edit_remote(&RemoteEdit::Add { name: "--bad".into(), url: "x".into() }).is_err());
|
||||
repo.edit_remote(&RemoteEdit::Remove { name: "upstream".into() }).unwrap();
|
||||
assert!(repo.remotes().unwrap().is_empty());
|
||||
|
||||
let wt = tmp.path().join("work-fix");
|
||||
repo.add_worktree(&wt, "main", Some("fix")).unwrap();
|
||||
let list = repo.worktrees().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert!(list[0].main && list[0].branch.as_deref() == Some("main"));
|
||||
assert_eq!(list[1].branch.as_deref(), Some("fix"));
|
||||
assert!(wt.join("a.txt").exists());
|
||||
repo.remove_worktree(&wt, false).unwrap();
|
||||
assert_eq!(repo.worktrees().unwrap().len(), 1);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ chardetng.workspace = true
|
||||
trash.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
zip = { version = "=2.4.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Read-only browsing of zip archives (jar, zip, war…), IDEA's `jar://` file
|
||||
//! system: `<archive>!` is the archive's root and `<archive>!/<entry>` an entry
|
||||
//! inside it. Listing and reading go through a small cache of parsed central
|
||||
//! directories, refreshed when the archive changes on disk.
|
||||
|
||||
use crate::{EntryKind, FileEntry, mtime_ms, natural_cmp};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// File extensions browsed as zip archives.
|
||||
pub const ARCHIVE_EXTENSIONS: &[&str] = &["jar", "zip", "war", "ear", "aar", "srcjar"];
|
||||
|
||||
/// Separator between the archive and the entry path (`/jdk/lib/src.zip!/java.base/…`).
|
||||
pub const ARCHIVE_SEPARATOR: char = '!';
|
||||
|
||||
/// Parsed archives kept open.
|
||||
const CACHE_SIZE: usize = 8;
|
||||
|
||||
/// Whether `path` names a file browsed as an archive (by extension).
|
||||
pub fn is_archive_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| ARCHIVE_EXTENSIONS.iter().any(|a| a.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// `(archive, entry)` for `<archive>!` (entry `""`) or `<archive>!/<entry>`;
|
||||
/// `None` for ordinary paths. The entry is `/`-separated without leading or trailing `/`.
|
||||
pub fn split_archive_path(path: &Path) -> Option<(PathBuf, String)> {
|
||||
let s = path.to_str()?;
|
||||
for (i, _) in s.match_indices(ARCHIVE_SEPARATOR) {
|
||||
let rest = &s[i + 1..];
|
||||
if !(rest.is_empty() || rest.starts_with(['/', '\\'])) {
|
||||
continue;
|
||||
}
|
||||
let archive = Path::new(&s[..i]);
|
||||
if is_archive_file(archive) {
|
||||
let entry = rest.replace('\\', "/");
|
||||
return Some((archive.to_path_buf(), entry.trim_matches('/').to_owned()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `path` lies inside an archive (`<archive>!…`).
|
||||
pub fn is_archive_path(path: &Path) -> bool {
|
||||
split_archive_path(path).is_some()
|
||||
}
|
||||
|
||||
/// The root of `archive` as a browsable folder: `<archive>!`.
|
||||
pub fn archive_root(archive: &Path) -> PathBuf {
|
||||
let mut s = archive.as_os_str().to_owned();
|
||||
s.push("!");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// `<archive>!/<entry>` (the root for an empty entry).
|
||||
pub fn archive_entry_path(archive: &Path, entry: &str) -> PathBuf {
|
||||
let entry = entry.trim_matches('/');
|
||||
if entry.is_empty() {
|
||||
return archive_root(archive);
|
||||
}
|
||||
let mut s = archive.as_os_str().to_owned();
|
||||
s.push("!/");
|
||||
s.push(entry);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
struct Node {
|
||||
dir: bool,
|
||||
size: u64,
|
||||
/// Index in the zip's central directory (`None` for implicit directories).
|
||||
index: Option<usize>,
|
||||
children: Vec<String>,
|
||||
}
|
||||
|
||||
struct Archive {
|
||||
stamp: (Option<SystemTime>, u64),
|
||||
mtime_ms: f64,
|
||||
/// Entry path (`""` = root) → node.
|
||||
nodes: HashMap<String, Node>,
|
||||
zip: Mutex<zip::ZipArchive<File>>,
|
||||
}
|
||||
|
||||
/// Most recently used first.
|
||||
type Cache = Vec<(PathBuf, Arc<Archive>)>;
|
||||
|
||||
static CACHE: LazyLock<Mutex<Cache>> = LazyLock::new(Default::default);
|
||||
|
||||
fn zip_error(archive: &Path, e: zip::result::ZipError) -> IdeError {
|
||||
match e {
|
||||
zip::result::ZipError::Io(e) => e.into(),
|
||||
zip::result::ZipError::FileNotFound => IdeError::not_found(format!("entry not found in {}", archive.display())),
|
||||
e => IdeError::invalid(format!("{}: {e}", archive.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalises a zip entry name: `/`-separated, no leading `/` or `./`, no trailing `/`.
|
||||
fn clean_name(name: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = name.split(['/', '\\']).filter(|p| !p.is_empty() && *p != ".").collect();
|
||||
(!parts.is_empty() && !parts.contains(&"..")).then(|| parts.join("/"))
|
||||
}
|
||||
|
||||
fn parent_of(name: &str) -> (&str, &str) {
|
||||
match name.rfind('/') {
|
||||
Some(i) => (&name[..i], &name[i + 1..]),
|
||||
None => ("", name),
|
||||
}
|
||||
}
|
||||
|
||||
impl Archive {
|
||||
fn open(path: &Path, stamp: (Option<SystemTime>, u64), mtime_ms: f64) -> IdeResult<Self> {
|
||||
let mut zip = zip::ZipArchive::new(File::open(path)?).map_err(|e| zip_error(path, e))?;
|
||||
let mut nodes: HashMap<String, Node> = HashMap::new();
|
||||
nodes.insert(String::new(), Node { dir: true, size: 0, index: None, children: Vec::new() });
|
||||
for i in 0..zip.len() {
|
||||
let Ok(file) = zip.by_index_raw(i) else { continue };
|
||||
let Some(name) = clean_name(file.name()) else { continue };
|
||||
let (dir, size) = (file.is_dir(), file.size());
|
||||
drop(file);
|
||||
let mut parent = parent_of(&name).0;
|
||||
while !nodes.contains_key(parent) {
|
||||
nodes.insert(parent.to_owned(), Node { dir: true, size: 0, index: None, children: Vec::new() });
|
||||
parent = parent_of(parent).0;
|
||||
}
|
||||
let node = nodes.entry(name).or_insert(Node { dir, size: 0, index: None, children: Vec::new() });
|
||||
node.dir |= dir;
|
||||
node.index = Some(i);
|
||||
node.size = if node.dir { 0 } else { size };
|
||||
}
|
||||
let links: Vec<(String, String)> = nodes
|
||||
.keys()
|
||||
.filter(|k| !k.is_empty())
|
||||
.map(|k| {
|
||||
let (parent, base) = parent_of(k);
|
||||
(parent.to_owned(), base.to_owned())
|
||||
})
|
||||
.collect();
|
||||
for (parent, base) in links {
|
||||
if let Some(p) = nodes.get_mut(&parent) {
|
||||
p.dir = true;
|
||||
p.children.push(base);
|
||||
}
|
||||
}
|
||||
Ok(Self { stamp, mtime_ms, nodes, zip: Mutex::new(zip) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed archive at `path`, from the cache unless it changed on disk.
|
||||
fn archive(path: &Path) -> IdeResult<Arc<Archive>> {
|
||||
let meta = std::fs::metadata(path)?;
|
||||
if !meta.is_file() {
|
||||
return Err(IdeError::invalid(format!("{} is not an archive", path.display())));
|
||||
}
|
||||
let stamp = (meta.modified().ok(), meta.len());
|
||||
{
|
||||
let mut cache = CACHE.lock();
|
||||
if let Some(i) = cache.iter().position(|(p, a)| p == path && a.stamp == stamp) {
|
||||
let hit = cache.remove(i);
|
||||
let a = hit.1.clone();
|
||||
cache.insert(0, hit);
|
||||
return Ok(a);
|
||||
}
|
||||
}
|
||||
let parsed = Arc::new(Archive::open(path, stamp, mtime_ms(&meta))?);
|
||||
let mut cache = CACHE.lock();
|
||||
cache.retain(|(p, _)| p != path);
|
||||
cache.insert(0, (path.to_path_buf(), parsed.clone()));
|
||||
cache.truncate(CACHE_SIZE);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Lists one folder of an archive (`dir` = `""` for its root): directories
|
||||
/// first, then files, in natural order. Entries are never ignored or excluded.
|
||||
pub fn read_archive_dir(archive_path: &Path, dir: &str) -> IdeResult<Vec<FileEntry>> {
|
||||
let a = archive(archive_path)?;
|
||||
let dir = dir.trim_matches('/');
|
||||
let node = a
|
||||
.nodes
|
||||
.get(dir)
|
||||
.ok_or_else(|| IdeError::not_found(format!("{dir} not found in {}", archive_path.display())))?;
|
||||
if !node.dir {
|
||||
return Err(IdeError::invalid(format!("{dir} is not a directory")));
|
||||
}
|
||||
let mut entries: Vec<FileEntry> = node
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
let full = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") };
|
||||
let child = a.nodes.get(&full)?;
|
||||
Some(FileEntry {
|
||||
name: name.clone(),
|
||||
path: archive_entry_path(archive_path, &full).into(),
|
||||
kind: if child.dir { EntryKind::Dir } else { EntryKind::File },
|
||||
size: child.size,
|
||||
mtime_ms: a.mtime_ms,
|
||||
ignored: false,
|
||||
excluded: false,
|
||||
hidden: name.starts_with('.'),
|
||||
target_kind: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
let (da, db) = (a.kind == EntryKind::Dir, b.kind == EntryKind::Dir);
|
||||
db.cmp(&da).then_with(|| natural_cmp(&a.name, &b.name))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Uncompressed size of an archive entry.
|
||||
pub(crate) fn entry_size(archive_path: &Path, entry: &str) -> IdeResult<(u64, f64)> {
|
||||
let a = archive(archive_path)?;
|
||||
let node = a
|
||||
.nodes
|
||||
.get(entry)
|
||||
.ok_or_else(|| IdeError::not_found(format!("{entry} not found in {}", archive_path.display())))?;
|
||||
if node.dir {
|
||||
return Err(IdeError::invalid(format!("{entry} is a directory")));
|
||||
}
|
||||
Ok((node.size, a.mtime_ms))
|
||||
}
|
||||
|
||||
/// The contents of a file inside an archive, and the archive's mtime (ms).
|
||||
pub fn read_archive_entry(archive_path: &Path, entry: &str) -> IdeResult<(Vec<u8>, f64)> {
|
||||
let a = archive(archive_path)?;
|
||||
let index = match a.nodes.get(entry) {
|
||||
Some(Node { dir: false, index: Some(i), .. }) => *i,
|
||||
Some(_) => return Err(IdeError::invalid(format!("{entry} is a directory"))),
|
||||
None => return Err(IdeError::not_found(format!("{entry} not found in {}", archive_path.display()))),
|
||||
};
|
||||
let mut zip = a.zip.lock();
|
||||
let mut file = zip.by_index(index).map_err(|e| zip_error(archive_path, e))?;
|
||||
let mut bytes = Vec::with_capacity(file.size().min(crate::MAX_OPEN_SIZE) as usize);
|
||||
file.read_to_end(&mut bytes)?;
|
||||
Ok((bytes, a.mtime_ms))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// Writes a zip with `files` (`name` ending in `/` = explicit directory entry).
|
||||
pub(crate) fn write_zip(path: &Path, files: &[(&str, &str)]) {
|
||||
let mut w = zip::ZipWriter::new(File::create(path).unwrap());
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
for (name, text) in files {
|
||||
if name.ends_with('/') {
|
||||
w.add_directory(*name, opts).unwrap();
|
||||
} else {
|
||||
w.start_file(*name, opts).unwrap();
|
||||
w.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
w.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_archive_paths() {
|
||||
assert_eq!(split_archive_path(Path::new("/a/b.txt")), None);
|
||||
assert_eq!(split_archive_path(Path::new("/a/x!/y")), None, "not an archive extension");
|
||||
assert_eq!(split_archive_path(Path::new("/a/s.zip!")), Some((PathBuf::from("/a/s.zip"), String::new())));
|
||||
assert_eq!(
|
||||
split_archive_path(Path::new("/a/lib.JAR!/org/x/A.java")),
|
||||
Some((PathBuf::from("/a/lib.JAR"), "org/x/A.java".into()))
|
||||
);
|
||||
assert_eq!(split_archive_path(Path::new("/a/s.zip!/dir/")), Some((PathBuf::from("/a/s.zip"), "dir".into())));
|
||||
assert_eq!(archive_entry_path(Path::new("/a/s.zip"), "x/y.java"), PathBuf::from("/a/s.zip!/x/y.java"));
|
||||
assert_eq!(archive_entry_path(Path::new("/a/s.zip"), ""), PathBuf::from("/a/s.zip!"));
|
||||
assert!(is_archive_path(Path::new("/a/s.zip!/x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_reads_entries_with_implicit_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let zip = tmp.path().join("src.zip");
|
||||
write_zip(
|
||||
&zip,
|
||||
&[
|
||||
("java.base/java/lang/String.java", "class String {}"),
|
||||
("java.base/java/lang/Object.java", "class Object {}"),
|
||||
("java.base/java/util/", ""),
|
||||
("java.base/module-info.java", "module java.base {}"),
|
||||
("README", "hi"),
|
||||
("../evil", "x"),
|
||||
],
|
||||
);
|
||||
let names = |dir: &str| -> Vec<(String, EntryKind)> {
|
||||
read_archive_dir(&zip, dir).unwrap().into_iter().map(|e| (e.name, e.kind)).collect()
|
||||
};
|
||||
assert_eq!(names(""), vec![("java.base".into(), EntryKind::Dir), ("README".into(), EntryKind::File)]);
|
||||
assert_eq!(
|
||||
names("java.base"),
|
||||
vec![("java".into(), EntryKind::Dir), ("module-info.java".into(), EntryKind::File)]
|
||||
);
|
||||
assert_eq!(names("java.base/java"), vec![("lang".into(), EntryKind::Dir), ("util".into(), EntryKind::Dir)]);
|
||||
let lang = read_archive_dir(&zip, "java.base/java/lang").unwrap();
|
||||
assert_eq!(lang.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Object.java", "String.java"]);
|
||||
assert_eq!(*lang[1].path, *archive_entry_path(&zip, "java.base/java/lang/String.java"));
|
||||
assert!(names("java.base/java/util").is_empty());
|
||||
assert_eq!(read_archive_entry(&zip, "java.base/java/lang/String.java").unwrap().0, b"class String {}");
|
||||
assert_eq!(read_archive_entry(&zip, "missing").unwrap_err().kind, ide_core::error::ErrorKind::NotFound);
|
||||
assert!(read_archive_entry(&zip, "java.base").is_err());
|
||||
assert!(read_archive_dir(&zip, "README").is_err());
|
||||
|
||||
// A rewritten archive is re-read.
|
||||
write_zip(&zip, &[("only.txt", "1")]);
|
||||
let f = File::options().write(true).open(&zip).unwrap();
|
||||
f.set_modified(SystemTime::now() + std::time::Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(names(""), vec![("only.txt".into(), EntryKind::File)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dir_and_load_text_go_through_archives() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let jar = tmp.path().join("lib-sources.jar");
|
||||
write_zip(&jar, &[("org/x/A.java", "class A {}\n"), ("org/x/B.class", "\0\0bin")]);
|
||||
let root = crate::read_dir(&archive_root(&jar), &[]).unwrap();
|
||||
assert_eq!(root.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["org"]);
|
||||
assert_eq!(crate::read_dir(&jar, &[]).unwrap().len(), 1, "the archive file itself lists its root");
|
||||
let a = crate::load_text(&archive_entry_path(&jar, "org/x/A.java")).unwrap();
|
||||
assert_eq!((a.text.as_str(), a.encoding.as_str(), a.binary), ("class A {}\n", "utf-8", false));
|
||||
assert!(crate::load_text(&archive_entry_path(&jar, "org/x/B.class")).unwrap().binary);
|
||||
let gbk = crate::load_text_as(&archive_entry_path(&jar, "org/x/A.java"), "gbk").unwrap();
|
||||
assert_eq!(gbk.encoding, "gbk");
|
||||
let probe = crate::probe(&archive_entry_path(&jar, "org/x/B.class")).unwrap();
|
||||
assert!(probe.binary && probe.size == 5);
|
||||
let range = crate::read_range(&archive_entry_path(&jar, "org/x/A.java"), 6, 3).unwrap();
|
||||
assert_eq!((range.offset, range.data.as_str(), range.total), (6, crate::base64(b"A {").as_str(), 11));
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,24 @@ pub struct ByteRange {
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Entry bytes of an archive path (`<archive>!/<entry>`), `None` for ordinary paths.
|
||||
fn archive_bytes(path: &Path, limit: u64) -> IdeResult<Option<Vec<u8>>> {
|
||||
let Some((archive, entry)) = crate::split_archive_path(path) else { return Ok(None) };
|
||||
let (size, _) = crate::archive::entry_size(&archive, &entry)?;
|
||||
if size > limit {
|
||||
return Err(IdeError::invalid(format!("{} is too large ({} MiB)", path.display(), size / (1024 * 1024))));
|
||||
}
|
||||
Ok(Some(crate::read_archive_entry(&archive, &entry)?.0))
|
||||
}
|
||||
|
||||
/// Reads only the head of the file to classify it.
|
||||
pub fn probe(path: &Path) -> IdeResult<FileProbe> {
|
||||
if let Some((archive, entry)) = crate::split_archive_path(path) {
|
||||
let (size, _) = crate::archive::entry_size(&archive, &entry)?;
|
||||
let head =
|
||||
if size > crate::MAX_OPEN_SIZE { Vec::new() } else { crate::read_archive_entry(&archive, &entry)?.0 };
|
||||
return Ok(FileProbe { size, binary: looks_binary(&head), too_large: size > READ_ONLY_THRESHOLD });
|
||||
}
|
||||
let meta = fs::metadata(path)?;
|
||||
if meta.is_dir() {
|
||||
return Err(IdeError::invalid(format!("{} is a directory", path.display())));
|
||||
@@ -49,6 +65,12 @@ pub fn probe(path: &Path) -> IdeResult<FileProbe> {
|
||||
|
||||
/// Reads up to `len` bytes (capped at [`MAX_RANGE_LEN`]) from `offset`.
|
||||
pub fn read_range(path: &Path, offset: u64, len: u32) -> IdeResult<ByteRange> {
|
||||
if let Some(bytes) = archive_bytes(path, crate::MAX_OPEN_SIZE)? {
|
||||
let total = bytes.len() as u64;
|
||||
let offset = offset.min(total);
|
||||
let end = (offset + u64::from(len.min(MAX_RANGE_LEN))).min(total);
|
||||
return Ok(ByteRange { offset, data: base64(&bytes[offset as usize..end as usize]), total });
|
||||
}
|
||||
let mut file = File::open(path)?;
|
||||
let total = file.metadata()?.len();
|
||||
let offset = offset.min(total);
|
||||
@@ -79,6 +101,9 @@ pub fn image_mime(path: &Path) -> Option<&'static str> {
|
||||
pub fn read_data_url(path: &Path) -> IdeResult<String> {
|
||||
let mime =
|
||||
image_mime(path).ok_or_else(|| IdeError::invalid(format!("{} is not a supported image", path.display())))?;
|
||||
if let Some(bytes) = archive_bytes(path, MAX_IMAGE_SIZE)? {
|
||||
return Ok(format!("data:{mime};base64,{}", base64(&bytes)));
|
||||
}
|
||||
let meta = fs::metadata(path)?;
|
||||
if meta.len() > MAX_IMAGE_SIZE {
|
||||
return Err(IdeError::invalid(format!(
|
||||
|
||||
@@ -94,7 +94,34 @@ pub(crate) fn decode(bytes: &[u8]) -> (String, &'static Encoding, bool, bool) {
|
||||
(text.into_owned(), enc, false, false)
|
||||
}
|
||||
|
||||
/// Bytes of a file inside an archive (size-checked) and the archive's mtime.
|
||||
fn archive_bytes(path: &Path) -> IdeResult<Option<(Vec<u8>, f64)>> {
|
||||
let Some((archive, entry)) = crate::split_archive_path(path) else { return Ok(None) };
|
||||
let (size, _) = crate::archive::entry_size(&archive, &entry)?;
|
||||
if size > MAX_OPEN_SIZE {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is too large to open ({} MiB)",
|
||||
path.display(),
|
||||
size / (1024 * 1024)
|
||||
)));
|
||||
}
|
||||
crate::read_archive_entry(&archive, &entry).map(Some)
|
||||
}
|
||||
|
||||
pub(crate) fn load_text(path: &Path) -> IdeResult<LoadedText> {
|
||||
if let Some((bytes, mtime_ms)) = archive_bytes(path)? {
|
||||
let len = bytes.len() as u64;
|
||||
let (text, enc, has_bom, binary) = decode_owned(bytes);
|
||||
return Ok(LoadedText {
|
||||
text,
|
||||
encoding: encoding_label(enc),
|
||||
has_bom,
|
||||
binary,
|
||||
large: len > LARGE_FILE_THRESHOLD,
|
||||
too_large: len > READ_ONLY_THRESHOLD,
|
||||
mtime_ms,
|
||||
});
|
||||
}
|
||||
let meta = fs::metadata(path)?;
|
||||
if meta.is_dir() {
|
||||
return Err(IdeError::invalid(format!("{} is a directory", path.display())));
|
||||
@@ -126,7 +153,10 @@ pub(crate) fn load_text_as(path: &Path, encoding: &str) -> IdeResult<LoadedText>
|
||||
let enc = Encoding::for_label(encoding.trim().as_bytes())
|
||||
.ok_or_else(|| IdeError::invalid(format!("unknown encoding {encoding:?}")))?;
|
||||
let mut loaded = load_text(path)?;
|
||||
let bytes = fs::read(path)?;
|
||||
let bytes = match archive_bytes(path)? {
|
||||
Some((bytes, _)) => bytes,
|
||||
None => fs::read(path)?,
|
||||
};
|
||||
let (body, has_bom) = match Encoding::for_bom(&bytes) {
|
||||
Some((bom_enc, len)) if bom_enc == enc => (&bytes[len..], true),
|
||||
_ => (&bytes[..], false),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! File system access for the IDE. The UI never touches the disk directly.
|
||||
|
||||
mod archive;
|
||||
mod bytes;
|
||||
mod encoding;
|
||||
mod ops;
|
||||
@@ -14,6 +15,10 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use archive::{
|
||||
ARCHIVE_EXTENSIONS, archive_entry_path, archive_root, is_archive_file, is_archive_path, read_archive_dir,
|
||||
read_archive_entry, split_archive_path,
|
||||
};
|
||||
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
|
||||
use encoding::SNIFF_LEN;
|
||||
pub use encoding::{LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, encode, write_atomic};
|
||||
@@ -100,7 +105,15 @@ pub fn natural_cmp(a: &str, b: &str) -> Ordering {
|
||||
/// Symlinks are reported as [`EntryKind::Symlink`] but sorted (and sized) by
|
||||
/// what they point to. `excluded` reflects `excludes` (relative to the enclosing
|
||||
/// repository root, else to `dir`); `ignored` also the applicable `.gitignore`/`.ignore` files.
|
||||
///
|
||||
/// Inside an archive (`<archive>!/<entry>`, or the archive file itself) lists its entries.
|
||||
pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult<Vec<FileEntry>> {
|
||||
if let Some((archive, entry)) = split_archive_path(dir) {
|
||||
return read_archive_dir(&archive, &entry);
|
||||
}
|
||||
if is_archive_file(dir) && fs::metadata(dir).is_ok_and(|m| m.is_file()) {
|
||||
return read_archive_dir(dir, "");
|
||||
}
|
||||
if !fs::metadata(dir)?.is_dir() {
|
||||
return Err(IdeError::invalid(format!("{} is not a directory", dir.display())));
|
||||
}
|
||||
|
||||
+33
-12
@@ -12,7 +12,7 @@ use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
|
||||
use notify_debouncer_full::notify::{self, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -47,8 +47,11 @@ type OnChange = Box<dyn Fn(&FsBatch) + Send + Sync>;
|
||||
/// no callback runs after `drop` returns.
|
||||
pub struct Watcher {
|
||||
inner: Inner,
|
||||
excludes: SharedExcludes,
|
||||
}
|
||||
|
||||
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
|
||||
|
||||
enum Inner {
|
||||
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
|
||||
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
|
||||
@@ -68,6 +71,7 @@ impl Watcher {
|
||||
) -> IdeResult<Self> {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let filter = EventFilter::new(root.clone(), &excludes);
|
||||
let shared = filter.excludes.clone();
|
||||
let emit = move |batch: &FsBatch| {
|
||||
sink.emit(IdeEvent::FsChanged {
|
||||
created: batch.created.iter().map(Into::into).collect(),
|
||||
@@ -76,10 +80,18 @@ impl Watcher {
|
||||
});
|
||||
on_change(batch);
|
||||
};
|
||||
match mode {
|
||||
let inner = match mode {
|
||||
WatchMode::Recursive => Self::start_recursive(root, filter, emit),
|
||||
WatchMode::PerDirectory => Self::start_per_directory(root, filter, emit),
|
||||
}
|
||||
}?;
|
||||
Ok(Self { inner, excludes: shared })
|
||||
}
|
||||
|
||||
/// Replaces the exclude globs (Settings › Project Structure / File Types
|
||||
/// changed): later events below newly excluded folders are dropped. In
|
||||
/// per-directory mode, folders no longer excluded are watched once they change.
|
||||
pub fn set_excludes(&self, excludes: &[String]) {
|
||||
*self.excludes.write() = ExcludeMatcher::new(excludes);
|
||||
}
|
||||
|
||||
/// Number of OS-level watches (1 in recursive mode).
|
||||
@@ -94,7 +106,7 @@ impl Watcher {
|
||||
root: PathBuf,
|
||||
filter: EventFilter,
|
||||
emit: impl Fn(&FsBatch) + Send + 'static,
|
||||
) -> IdeResult<Self> {
|
||||
) -> IdeResult<Inner> {
|
||||
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
|
||||
// which made opening large projects block for seconds. Renames arrive as remove + create.
|
||||
let handler = move |result: DebounceEventResult| match result {
|
||||
@@ -116,14 +128,14 @@ impl Watcher {
|
||||
debouncer
|
||||
.watch(&root, RecursiveMode::Recursive)
|
||||
.map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
|
||||
Ok(Self { inner: Inner::Recursive(debouncer) })
|
||||
Ok(Inner::Recursive(debouncer))
|
||||
}
|
||||
|
||||
fn start_per_directory(
|
||||
root: PathBuf,
|
||||
filter: EventFilter,
|
||||
emit: impl Fn(&FsBatch) + Send + 'static,
|
||||
) -> IdeResult<Self> {
|
||||
) -> IdeResult<Inner> {
|
||||
let state = Arc::new(DirState {
|
||||
filter,
|
||||
watcher: Mutex::new(None),
|
||||
@@ -160,7 +172,7 @@ impl Watcher {
|
||||
.spawn(move || state.run(emit))
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
|
||||
};
|
||||
Ok(Self { inner: Inner::PerDirectory { state, thread: Some(thread) } })
|
||||
Ok(Inner::PerDirectory { state, thread: Some(thread) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,13 +371,17 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
|
||||
|
||||
pub(crate) struct EventFilter {
|
||||
root: PathBuf,
|
||||
excludes: ExcludeMatcher,
|
||||
excludes: SharedExcludes,
|
||||
gitignore: GitignoreStack,
|
||||
}
|
||||
|
||||
impl EventFilter {
|
||||
pub fn new(root: PathBuf, excludes: &[String]) -> Self {
|
||||
Self { gitignore: GitignoreStack::new(&root), excludes: ExcludeMatcher::new(excludes), root }
|
||||
Self {
|
||||
gitignore: GitignoreStack::new(&root),
|
||||
excludes: Arc::new(RwLock::new(ExcludeMatcher::new(excludes))),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some(true)` for interesting VCS metadata, `Some(false)` for other `.git`
|
||||
@@ -405,13 +421,15 @@ impl EventFilter {
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
!self.excludes.is_excluded(rel) && !self.gitignore.is_ignored(dir, true)
|
||||
!self.excludes.read().is_excluded(rel) && !self.gitignore.is_ignored(dir, true)
|
||||
}
|
||||
|
||||
/// Whether a path found by scanning a new directory would be reported.
|
||||
fn is_reported(&self, path: &Path, is_dir: bool) -> bool {
|
||||
let Ok(rel) = path.strip_prefix(&self.root) else { return false };
|
||||
Self::git_internal(rel).is_none() && !self.excludes.is_excluded(rel) && !self.gitignore.is_ignored(path, is_dir)
|
||||
Self::git_internal(rel).is_none()
|
||||
&& !self.excludes.read().is_excluded(rel)
|
||||
&& !self.gitignore.is_ignored(path, is_dir)
|
||||
}
|
||||
|
||||
fn classify(&self, path: &Path, change: Change, out: &mut BTreeMap<PathBuf, Change>) {
|
||||
@@ -507,7 +525,7 @@ impl EventFilter {
|
||||
(c, _) => c,
|
||||
};
|
||||
if let Ok(rel) = path.strip_prefix(&self.root) {
|
||||
if self.excludes.is_excluded(rel) {
|
||||
if self.excludes.read().is_excluded(rel) {
|
||||
continue;
|
||||
}
|
||||
let is_dir = meta.as_ref().is_some_and(|m| m.is_dir());
|
||||
@@ -654,6 +672,9 @@ mod tests {
|
||||
assert!(w(".git") && w(".git/refs") && w(".git/refs/heads") && w(".git/info"));
|
||||
assert!(!w(".git/objects") && !w(".git/objects/ab") && !w(".git/logs"));
|
||||
assert!(!filter.should_watch_dir(Path::new("/elsewhere")));
|
||||
// Excludes are replaced in place (Watcher::set_excludes).
|
||||
*filter.excludes.write() = ExcludeMatcher::new(&["gen".into()]);
|
||||
assert!(w("node_modules") && !w("src/gen"));
|
||||
}
|
||||
|
||||
fn watcher_state(w: &Watcher) -> &DirState {
|
||||
|
||||
@@ -23,3 +23,4 @@ globset.workspace = true
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
zip = { version = "=2.4.2", default-features = false, features = ["deflate"] }
|
||||
@@ -17,7 +17,8 @@ fn main() {
|
||||
std::fs::write(&path, &text).expect("write");
|
||||
drop(text);
|
||||
|
||||
let docs = Documents::new(Arc::new(NullSink), Box::new(|_| LanguageId::plain_text()));
|
||||
let docs =
|
||||
Documents::new(Arc::new(NullSink), Box::new(|_: &std::path::Path, _: Option<&str>| LanguageId::plain_text()));
|
||||
let rss0 = resident_bytes().unwrap_or(0);
|
||||
let t = Instant::now();
|
||||
let info = docs.open(&path).expect("open");
|
||||
|
||||
@@ -46,6 +46,9 @@ pub struct TextSnapshot {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Language of a file from its path and first line (hashbangs).
|
||||
pub type DetectLanguage = dyn Fn(&Path, Option<&str>) -> LanguageId + Send + Sync;
|
||||
|
||||
/// Registry of open documents keyed by path, with ref-counting so split
|
||||
/// editors on the same file share one buffer.
|
||||
///
|
||||
@@ -55,7 +58,7 @@ pub struct Documents {
|
||||
registry: Mutex<Registry>,
|
||||
observers: RwLock<Vec<Arc<dyn DocumentObserver>>>,
|
||||
sink: SharedSink,
|
||||
detect_language: Box<dyn Fn(&Path) -> LanguageId + Send + Sync>,
|
||||
detect_language: Box<DetectLanguage>,
|
||||
editorconfig: EditorConfig,
|
||||
defaults: RwLock<FileDefaults>,
|
||||
}
|
||||
@@ -148,7 +151,7 @@ fn read_only_error(path: &Path) -> IdeError {
|
||||
}
|
||||
|
||||
impl Documents {
|
||||
pub fn new(sink: SharedSink, detect_language: Box<dyn Fn(&Path) -> LanguageId + Send + Sync>) -> Self {
|
||||
pub fn new(sink: SharedSink, detect_language: Box<DetectLanguage>) -> Self {
|
||||
Self {
|
||||
registry: Mutex::default(),
|
||||
observers: RwLock::default(),
|
||||
@@ -238,13 +241,19 @@ impl Documents {
|
||||
}
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(&absolute(path)))?;
|
||||
let language = (self.detect_language)(path);
|
||||
let meta = std::fs::metadata(path).ok();
|
||||
let writable = meta.as_ref().map(|m| !m.permissions().readonly()).unwrap_or(true);
|
||||
let language = (self.detect_language)(path, loaded.text.lines().next());
|
||||
// Archive entries (`<jar>!/<entry>`) are read-only and kept from language
|
||||
// servers and Local History, which only know real files.
|
||||
let in_archive = ide_vfs::is_archive_path(path);
|
||||
let meta = if in_archive { None } else { std::fs::metadata(path).ok() };
|
||||
let writable = !in_archive && meta.as_ref().map(|m| !m.permissions().readonly()).unwrap_or(true);
|
||||
let empty_file = meta.as_ref().is_some_and(|m| m.len() == 0);
|
||||
let observed = !(loaded.binary || loaded.too_large);
|
||||
let editorconfig =
|
||||
if loaded.binary || !defaults.editorconfig { None } else { self.editorconfig.resolve(&absolute(path)) };
|
||||
let observed = !(loaded.binary || loaded.too_large || in_archive);
|
||||
let editorconfig = if loaded.binary || in_archive || !defaults.editorconfig {
|
||||
None
|
||||
} else {
|
||||
self.editorconfig.resolve(&absolute(path))
|
||||
};
|
||||
let mut doc = Document::new(&loaded.text);
|
||||
let (mut encoding, mut has_bom) = (loaded.encoding, loaded.has_bom);
|
||||
// An empty file has nothing to detect: Code Style / File Encodings defaults apply.
|
||||
@@ -577,6 +586,37 @@ impl Documents {
|
||||
self.registry.lock().docs.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Detects the language of every open file again (file type associations
|
||||
/// changed). A document whose language changes is reopened for the
|
||||
/// observers, so it moves to the matching language servers and syntax tree,
|
||||
/// and reported as [`IdeEvent::DocLanguage`]. Returns the changed documents.
|
||||
pub fn redetect_languages(&self) -> Vec<DocId> {
|
||||
let slots: Vec<(DocId, Slot)> = self.registry.lock().docs.iter().map(|(id, e)| (*id, e.slot.clone())).collect();
|
||||
let mut changed = Vec::new();
|
||||
for (id, slot) in slots {
|
||||
let mut st = slot.lock();
|
||||
if st.closed || st.uri.is_some() {
|
||||
continue;
|
||||
}
|
||||
let text = st.doc.text();
|
||||
let language = (self.detect_language)(&st.path, text.lines().next());
|
||||
if language == st.language {
|
||||
continue;
|
||||
}
|
||||
st.language = language.clone();
|
||||
if st.observed {
|
||||
let info = Self::info_with_text(id, &st, text);
|
||||
for o in self.observers() {
|
||||
o.closed(id, &st.path);
|
||||
o.opened(&info);
|
||||
}
|
||||
}
|
||||
self.sink.emit(IdeEvent::DocLanguage { doc: id, language });
|
||||
changed.push(id);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Changes the line ending used on save; marks the document dirty.
|
||||
pub fn set_line_ending(&self, doc: DocId, le: LineEnding) -> IdeResult<()> {
|
||||
self.with_doc(doc, |st| {
|
||||
|
||||
@@ -241,7 +241,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
fn documents() -> Documents {
|
||||
Documents::new(Arc::new(|_| {}), Box::new(|_: &Path| LanguageId::new("plaintext")))
|
||||
Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")))
|
||||
}
|
||||
|
||||
fn apply(documents: &Documents) -> impl Fn(&WorkspaceEdit) -> IdeResult<()> + '_ {
|
||||
|
||||
@@ -14,7 +14,7 @@ pub mod roots;
|
||||
pub mod settings;
|
||||
pub mod text_refs;
|
||||
|
||||
pub use documents::{DocumentInfo, Documents, TextSnapshot};
|
||||
pub use documents::{DetectLanguage, DocumentInfo, Documents, TextSnapshot};
|
||||
pub use editorconfig::{EditorConfig, EditorConfigProps};
|
||||
pub use file_defaults::FileDefaults;
|
||||
pub use file_moves::{FileMove, FileMoves, UndoOutcome};
|
||||
|
||||
@@ -62,6 +62,20 @@ impl RecentProjects {
|
||||
self.persist(&items)
|
||||
}
|
||||
|
||||
/// Shows `path` as `name` (File › Rename Project…); `None` restores its folder name.
|
||||
pub fn rename(&self, path: &Path, name: Option<&str>) -> IdeResult<()> {
|
||||
let mut items = self.items.lock();
|
||||
let Some(item) = items.iter_mut().find(|p| p.path == path) else { return Ok(()) };
|
||||
let name = name.map(str::to_owned).unwrap_or_else(|| {
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string())
|
||||
});
|
||||
if item.name == name {
|
||||
return Ok(());
|
||||
}
|
||||
item.name = name;
|
||||
self.persist(&items)
|
||||
}
|
||||
|
||||
pub fn remove(&self, path: &Path) -> IdeResult<()> {
|
||||
let mut items = self.items.lock();
|
||||
let before = items.len();
|
||||
@@ -116,4 +130,17 @@ mod tests {
|
||||
std::fs::write(cfg.join("recent.json"), "not json").unwrap();
|
||||
assert!(RecentProjects::load(&cfg).list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_shows_a_custom_name_until_reset() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let recent = RecentProjects::load(tmp.path());
|
||||
recent.touch(Path::new("/p/app"), None).unwrap();
|
||||
recent.rename(Path::new("/p/app"), Some("Shop Backend")).unwrap();
|
||||
recent.rename(Path::new("/p/unknown"), Some("x")).unwrap();
|
||||
assert_eq!(RecentProjects::load(tmp.path()).list()[0].name, "Shop Backend");
|
||||
recent.rename(Path::new("/p/app"), None).unwrap();
|
||||
assert_eq!(recent.list()[0].name, "app");
|
||||
assert_eq!(recent.list().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use ide_core::settings::Settings;
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use parking_lot::RwLock;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use toml_edit::{DocumentMut, Item, Table, TableLike};
|
||||
|
||||
@@ -230,6 +231,52 @@ impl SettingsStore {
|
||||
*self.effective.write() = settings.clone();
|
||||
settings
|
||||
}
|
||||
|
||||
/// File › Manage IDE Settings › Export Settings…: the global layer plus
|
||||
/// frontend-held IDE state (`storage`: keymaps' companions such as live
|
||||
/// templates, macros) as one JSON file.
|
||||
pub fn export_bundle(&self, path: &Path, storage: BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let settings = match std::fs::read_to_string(&self.global_file) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let bundle = SettingsBundle { format: BUNDLE_FORMAT.into(), version: BUNDLE_VERSION, settings, storage };
|
||||
let json = serde_json::to_vec_pretty(&bundle).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(path, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import Settings…: replaces the global layer with the bundle's (validated
|
||||
/// first) and returns the new effective settings and the bundle's `storage`.
|
||||
pub fn import_bundle(&self, path: &Path) -> IdeResult<(Settings, BTreeMap<String, String>)> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let bundle: SettingsBundle = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| IdeError::invalid(format!("not an OpenIDEA settings file: {e}")))?;
|
||||
if bundle.format != BUNDLE_FORMAT || bundle.version > BUNDLE_VERSION {
|
||||
return Err(IdeError::invalid("not an OpenIDEA settings file, or one from a newer version"));
|
||||
}
|
||||
let layer = parse_layer(&bundle.settings).map_err(|e| IdeError::invalid(format!("invalid settings: {e}")))?;
|
||||
try_merge(&mut defaults_json(), layer).map_err(|e| IdeError::invalid(format!("invalid settings: {e}")))?;
|
||||
if let Some(dir) = self.global_file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
ide_vfs::write_atomic(&self.global_file, bundle.settings.as_bytes())?;
|
||||
Ok((self.reload(), bundle.storage))
|
||||
}
|
||||
}
|
||||
|
||||
const BUNDLE_FORMAT: &str = "openidea-settings";
|
||||
const BUNDLE_VERSION: u32 = 1;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct SettingsBundle {
|
||||
format: String,
|
||||
version: u32,
|
||||
/// The global `settings.toml`, verbatim (comments included).
|
||||
settings: String,
|
||||
#[serde(default)]
|
||||
storage: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -320,4 +367,68 @@ mod tests {
|
||||
let reloaded = SettingsStore::load(&cfg, Some(&proj)).effective();
|
||||
assert_eq!(reloaded, store.effective());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_profiles_live_in_their_layers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = tmp.path().join("cfg");
|
||||
let proj = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
let store = SettingsStore::load(&cfg, Some(&proj));
|
||||
store.set(SettingsScope::Global, "inspections.default", json!({"lsp:ruff:F401": {"enabled": false}})).unwrap();
|
||||
store.set(SettingsScope::Project, "inspections.project", json!({"lsp:ruff": {"severity": "typo"}})).unwrap();
|
||||
let s = store.set(SettingsScope::Project, "inspections.profile", json!("project")).unwrap();
|
||||
assert_eq!(s.inspections.profile, "project");
|
||||
assert_eq!(s.inspections.default["lsp:ruff:F401"].enabled, Some(false));
|
||||
assert_eq!(s.inspections.project["lsp:ruff"].severity, Some(ide_core::settings::InspectionLevel::Typo));
|
||||
assert!(store.set(SettingsScope::Project, "inspections.project", json!({"x": {"severity": "loud"}})).is_err());
|
||||
let global = std::fs::read_to_string(cfg.join("settings.toml")).unwrap();
|
||||
assert!(!global.contains("profile"), "{global}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_bundle_round_trips_and_rejects_invalid_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (a, b) = (tmp.path().join("a"), tmp.path().join("b"));
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
let source = SettingsStore::load(&a, None);
|
||||
source.set(SettingsScope::Global, "editor.fontSize", json!(17)).unwrap();
|
||||
source.set(SettingsScope::Global, "general.powerSaveMode", json!(true)).unwrap();
|
||||
let file = tmp.path().join("settings.json");
|
||||
let storage = BTreeMap::from([("openidea.macros.v1".to_owned(), "[]".to_owned())]);
|
||||
source.export_bundle(&file, storage.clone()).unwrap();
|
||||
|
||||
let target = SettingsStore::load(&b, None);
|
||||
let (settings, imported) = target.import_bundle(&file).unwrap();
|
||||
assert_eq!(settings.editor.font_size, 17.0);
|
||||
assert!(settings.general.power_save_mode);
|
||||
assert_eq!(imported, storage);
|
||||
assert_eq!(target.effective(), source.effective());
|
||||
|
||||
let bad = tmp.path().join("bad.json");
|
||||
std::fs::write(&bad, r#"{"format":"openidea-settings","version":1,"settings":"[editor]\nfontSize = \"x\"\n"}"#)
|
||||
.unwrap();
|
||||
assert!(target.import_bundle(&bad).is_err());
|
||||
std::fs::write(&bad, r#"{"format":"other","version":1,"settings":""}"#).unwrap();
|
||||
assert!(target.import_bundle(&bad).is_err());
|
||||
assert_eq!(target.effective().editor.font_size, 17.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_toolbar_can_be_set_and_reset() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::load(tmp.path(), None);
|
||||
let s = store
|
||||
.set(
|
||||
SettingsScope::Global,
|
||||
"appearance.mainToolbar",
|
||||
json!({"left": ["project"], "right": [], "hidden": ["vcs.branch"]}),
|
||||
)
|
||||
.unwrap();
|
||||
let toolbar = s.appearance.main_toolbar.unwrap();
|
||||
assert_eq!(toolbar.left, ["project"]);
|
||||
assert_eq!(toolbar.hidden, ["vcs.branch"]);
|
||||
let s = store.set(SettingsScope::Global, "appearance.mainToolbar", Json::Null).unwrap();
|
||||
assert_eq!(s.appearance.main_toolbar, None);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ fn fixture() -> Fixture {
|
||||
};
|
||||
let docs = Documents::new(
|
||||
sink,
|
||||
Box::new(|p: &Path| match p.extension().and_then(|e| e.to_str()) {
|
||||
Box::new(|p: &Path, _: Option<&str>| match p.extension().and_then(|e| e.to_str()) {
|
||||
Some("rs") => LanguageId::new("rust"),
|
||||
_ => LanguageId::plain_text(),
|
||||
}),
|
||||
@@ -61,6 +61,51 @@ fn change(from: u32, to: u32, insert: &str) -> ChangeSet {
|
||||
ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redetect_reopens_documents_whose_language_changed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let foo_is_python = Arc::new(Mutex::new(false));
|
||||
let docs = {
|
||||
let foo_is_python = foo_is_python.clone();
|
||||
Documents::new(
|
||||
sink,
|
||||
Box::new(move |p: &Path, first: Option<&str>| {
|
||||
if p.extension().is_some_and(|e| e == "foo") && *foo_is_python.lock() {
|
||||
LanguageId::new("python")
|
||||
} else if first.is_some_and(|l| l.starts_with("#!")) {
|
||||
LanguageId::new("shellscript")
|
||||
} else {
|
||||
LanguageId::plain_text()
|
||||
}
|
||||
}),
|
||||
)
|
||||
};
|
||||
let rec = Arc::new(Recorder::default());
|
||||
docs.add_observer(rec.clone());
|
||||
let foo = tmp.path().join("a.foo");
|
||||
let script = tmp.path().join("run");
|
||||
fs::write(&foo, "x = 1\n").unwrap();
|
||||
fs::write(&script, "#!/bin/sh\necho\n").unwrap();
|
||||
let a = docs.open(&foo).unwrap();
|
||||
assert_eq!(a.language.as_str(), "plaintext");
|
||||
assert_eq!(docs.open(&script).unwrap().language.as_str(), "shellscript", "the first line is detected");
|
||||
|
||||
assert!(docs.redetect_languages().is_empty());
|
||||
*foo_is_python.lock() = true;
|
||||
assert_eq!(docs.redetect_languages(), vec![a.id]);
|
||||
assert_eq!(docs.snapshot(a.id).unwrap().language.as_str(), "python");
|
||||
let log = rec.log.lock().clone();
|
||||
assert_eq!(log[log.len() - 2..], ["close".to_owned(), "open v0 python".to_owned()]);
|
||||
assert!(events.lock().iter().any(
|
||||
|e| matches!(e, IdeEvent::DocLanguage { doc, language } if *doc == a.id && language.as_str() == "python")
|
||||
));
|
||||
}
|
||||
|
||||
fn dirty_events(events: &[IdeEvent]) -> Vec<bool> {
|
||||
events.iter().filter_map(|e| if let IdeEvent::DocDirty { dirty, .. } = e { Some(*dirty) } else { None }).collect()
|
||||
}
|
||||
@@ -450,3 +495,20 @@ fn toggling_the_writable_bit_updates_the_open_document() {
|
||||
assert!(f.docs.set_writable(&other, false).unwrap());
|
||||
assert!(fs::metadata(&other).unwrap().permissions().readonly());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_entries_open_read_only_without_observers() {
|
||||
use std::io::Write;
|
||||
let f = fixture();
|
||||
let jar = f.root.join("lib-sources.jar");
|
||||
let mut w = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
|
||||
w.start_file("org/x/Main.rs", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(b"fn main() {}\n").unwrap();
|
||||
w.finish().unwrap();
|
||||
let path = ide_vfs::archive_entry_path(&jar, "org/x/Main.rs");
|
||||
let info = f.docs.open(&path).unwrap();
|
||||
assert_eq!((info.text.as_str(), info.language.as_str(), info.read_only), ("fn main() {}\n", "rust", true));
|
||||
assert_eq!(*info.path, *path);
|
||||
assert!(f.rec.log.lock().is_empty(), "language servers / history never see archive entries");
|
||||
f.docs.close(info.id);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ ide-profiler.workspace = true
|
||||
ide-http.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-web.workspace = true
|
||||
ide-spell.workspace = true
|
||||
ide-tools = { path = "../crates/ide-tools" }
|
||||
ide-plugin = { path = "../crates/ide-plugin" }
|
||||
ide-docker = { path = "../crates/ide-docker" }
|
||||
|
||||
@@ -189,7 +189,7 @@ pub async fn analysis_locate_duplicates(
|
||||
) -> IdeResult<DuplicatesReport> {
|
||||
let project = state.project(window.label())?;
|
||||
let cancel = state.begin_request(request);
|
||||
let languages = state.languages.clone();
|
||||
let languages = project.languages.clone();
|
||||
let syntax = state.syntax();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
@@ -256,7 +256,7 @@ pub async fn structural_search(
|
||||
) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
let cancel = state.begin_request(request);
|
||||
let languages = state.languages.clone();
|
||||
let languages = project.languages.clone();
|
||||
let syntax = state.syntax();
|
||||
let events = on_event.clone();
|
||||
let result = blocking(move || {
|
||||
|
||||
@@ -69,14 +69,33 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
|
||||
}
|
||||
|
||||
fn project_info(project: &crate::state::Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
name: project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
},
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings: project.settings.effective(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Rename Project…: the project's display name (title bar, recent
|
||||
/// projects); an empty `name` restores the folder name. Stored in the project's settings.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<ProjectInfo> {
|
||||
let project = state.project(window.label())?;
|
||||
let name = name.trim();
|
||||
let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) };
|
||||
project.settings.set(SettingsScope::Project, "general.projectName", value)?;
|
||||
state.recent.rename(&project.root, (!name.is_empty()).then_some(name))?;
|
||||
Ok(project_info(&project))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn close_project(window: Window, state: State<'_, AppState>) {
|
||||
@@ -156,6 +175,15 @@ pub fn settings_set(
|
||||
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
|
||||
}
|
||||
}
|
||||
// File type associations re-detect open documents (off the command thread).
|
||||
if key == "files" || key.starts_with("files.fileType") {
|
||||
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.apply_file_types(&project.settings.effective());
|
||||
}
|
||||
});
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
|
||||
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
|
||||
@@ -165,6 +193,16 @@ pub fn settings_set(
|
||||
}
|
||||
}
|
||||
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.refresh_index_scope();
|
||||
}
|
||||
});
|
||||
}
|
||||
// A project value concerns only that project's window (and its settings window).
|
||||
let event = ide_core::IdeEvent::SettingsChanged { key };
|
||||
if project_scope {
|
||||
@@ -182,6 +220,48 @@ pub fn settings_set(
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// File › Manage IDE Settings › Export Settings…: the global settings plus the
|
||||
/// frontend's IDE-level `storage` (live / file templates, macros) to `path`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn settings_export(
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
storage: std::collections::BTreeMap<String, String>,
|
||||
) -> IdeResult<()> {
|
||||
state.global_settings.export_bundle(&path, storage)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingsImport {
|
||||
/// Effective settings of the calling window after the import.
|
||||
pub settings: Settings,
|
||||
/// IDE state the frontend keeps (written back to its storage).
|
||||
pub storage: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Import Settings…: replaces the global settings with the file's; every window re-reads them.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn settings_import(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<SettingsImport> {
|
||||
let (_, storage) = state.global_settings.import_bundle(&path)?;
|
||||
for project in state.projects() {
|
||||
let effective = project.settings.reload();
|
||||
project
|
||||
.documents
|
||||
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
|
||||
let lsp = project.lsp.clone();
|
||||
tauri::async_runtime::spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() });
|
||||
let settings = match state.project(window.label()) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
Ok(SettingsImport { settings, storage })
|
||||
}
|
||||
|
||||
/// Effective source / test / resource / excluded roots of the window's project:
|
||||
/// auto-detected from build files, overridden by Settings › Project Structure.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Run with Coverage: launch plans and coverage reports.
|
||||
|
||||
use crate::state::AppState;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
|
||||
use ide_run::RunConfig;
|
||||
use tauri::{State, Window};
|
||||
@@ -37,3 +37,12 @@ pub async fn coverage_prepare(
|
||||
pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
/// with paths mapped into this project.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn coverage_import(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<CoverageSuite> {
|
||||
let root = state.project(window.label())?.root.clone();
|
||||
blocking(move || ide_coverage::import(&path, &root)).await
|
||||
}
|
||||
@@ -102,22 +102,32 @@ pub fn doc_set_line_ending(
|
||||
state.project(window.label())?.documents.set_line_ending(doc, line_ending)
|
||||
}
|
||||
|
||||
/// Structure view: LSP document symbols when available, tree-sitter otherwise.
|
||||
/// Structure view: LSP document symbols when available (with tree-sitter
|
||||
/// visibility), tree-sitter otherwise.
|
||||
/// Tree-sitter answers come from the per-document tree cache (incremental
|
||||
/// re-parse); large documents get no syntax structure.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_outline(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = state.project(window.label())?;
|
||||
if let Ok(Some(items)) = project.lsp.document_symbols(doc).await {
|
||||
return Ok(items);
|
||||
}
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.outline(doc, &snap.language, snap.version, &snap.text))
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
Err(e) => return lsp.ok_or(e),
|
||||
};
|
||||
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
|
||||
Ok(match lsp {
|
||||
// Server symbols carry no visibility; borrow it from the syntax tree.
|
||||
Some(mut items) => {
|
||||
if !snap.large {
|
||||
ide_language::merge_visibility(&mut items, &syntax());
|
||||
}
|
||||
items
|
||||
}
|
||||
None if snap.large => Vec::new(),
|
||||
None => syntax(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use crate::state::AppState;
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_docker::{ComposeOp, ContainerOp, DockerEvent, DockerLogLine, DockerObject, DockerSnapshot, PruneTarget};
|
||||
use ide_docker::{
|
||||
ComposeOp, ContainerOp, DockerEndpoint, DockerEvent, DockerLogLine, DockerObject, DockerRegistry, DockerSettings,
|
||||
DockerSnapshot, PruneTarget, RegistryAuth,
|
||||
};
|
||||
use tauri::State;
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
@@ -25,35 +28,42 @@ async fn cancellable<T: Send + 'static>(
|
||||
/// Availability plus containers, images, volumes and networks.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_snapshot() -> IdeResult<DockerSnapshot> {
|
||||
blocking(|| Ok(ide_docker::snapshot())).await
|
||||
pub async fn docker_snapshot(endpoint: DockerEndpoint) -> IdeResult<DockerSnapshot> {
|
||||
blocking(move || Ok(ide_docker::snapshot(&endpoint))).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_container_op(ids: Vec<String>, op: ContainerOp) -> IdeResult<()> {
|
||||
blocking(move || ide_docker::container_op(&ids, op)).await
|
||||
pub async fn docker_container_op(endpoint: DockerEndpoint, ids: Vec<String>, op: ContainerOp) -> IdeResult<()> {
|
||||
blocking(move || ide_docker::container_op(&endpoint, &ids, op)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_remove(object: DockerObject, ids: Vec<String>, force: bool) -> IdeResult<()> {
|
||||
blocking(move || ide_docker::remove(object, &ids, force)).await
|
||||
pub async fn docker_remove(
|
||||
endpoint: DockerEndpoint,
|
||||
object: DockerObject,
|
||||
ids: Vec<String>,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
blocking(move || ide_docker::remove(&endpoint, object, &ids, force)).await
|
||||
}
|
||||
|
||||
/// Pretty-printed `docker inspect` JSON.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_inspect(object: DockerObject, id: String) -> IdeResult<String> {
|
||||
blocking(move || ide_docker::inspect(object, &id)).await
|
||||
pub async fn docker_inspect(endpoint: DockerEndpoint, object: DockerObject, id: String) -> IdeResult<String> {
|
||||
blocking(move || ide_docker::inspect(&endpoint, object, &id)).await
|
||||
}
|
||||
|
||||
/// Streams a container's log lines until it stops or the request is cancelled.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn docker_logs(
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
endpoint: DockerEndpoint,
|
||||
id: String,
|
||||
tail: u32,
|
||||
follow: bool,
|
||||
@@ -61,7 +71,7 @@ pub async fn docker_logs(
|
||||
on_line: Channel<DockerLogLine>,
|
||||
) -> IdeResult<()> {
|
||||
cancellable(&state, request, move |cancel| {
|
||||
ide_docker::logs(&id, tail, follow, timestamps, cancel, &|line| {
|
||||
ide_docker::logs(&endpoint, &id, tail, follow, timestamps, cancel, &|line| {
|
||||
let _ = on_line.send(line);
|
||||
})
|
||||
})
|
||||
@@ -74,26 +84,31 @@ pub async fn docker_logs(
|
||||
pub async fn docker_events(
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
endpoint: DockerEndpoint,
|
||||
on_event: Channel<DockerEvent>,
|
||||
) -> IdeResult<()> {
|
||||
cancellable(&state, request, move |cancel| {
|
||||
ide_docker::watch_events(cancel, &|e| {
|
||||
ide_docker::watch_events(&endpoint, cancel, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// `docker pull`, logging in first when a saved registry with a password serves the image.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_pull(
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
endpoint: DockerEndpoint,
|
||||
reference: String,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let data_dir = state.data_dir.clone();
|
||||
cancellable(&state, request, move |cancel| {
|
||||
ide_docker::pull(&reference, cancel, &|line| {
|
||||
let auth = ide_docker::auth_for(&data_dir, &reference);
|
||||
ide_docker::pull(&endpoint, &reference, auth.as_ref(), cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
@@ -105,11 +120,12 @@ pub async fn docker_pull(
|
||||
pub async fn docker_prune(
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
endpoint: DockerEndpoint,
|
||||
target: PruneTarget,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
cancellable(&state, request, move |cancel| {
|
||||
ide_docker::prune(target, cancel, &|line| {
|
||||
ide_docker::prune(&endpoint, target, cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
@@ -123,6 +139,7 @@ pub async fn docker_prune(
|
||||
pub async fn docker_compose_op(
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
endpoint: DockerEndpoint,
|
||||
project: String,
|
||||
files: Vec<String>,
|
||||
working_dir: Option<String>,
|
||||
@@ -130,9 +147,65 @@ pub async fn docker_compose_op(
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
cancellable(&state, request, move |cancel| {
|
||||
ide_docker::compose_op(&project, &files, working_dir.as_deref(), op, cancel, &|line| {
|
||||
ide_docker::compose_op(&endpoint, &project, &files, working_dir.as_deref(), op, cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Docker connections and registries (app-wide).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_settings(state: State<'_, AppState>) -> IdeResult<DockerSettings> {
|
||||
let data_dir = state.data_dir.clone();
|
||||
blocking(move || Ok(ide_docker::load_settings(&data_dir))).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_save_settings(state: State<'_, AppState>, settings: DockerSettings) -> IdeResult<()> {
|
||||
let data_dir = state.data_dir.clone();
|
||||
blocking(move || ide_docker::save_settings(&data_dir, &settings)).await
|
||||
}
|
||||
|
||||
/// Saves (`None`: forgets) a registry's password.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_set_registry_password(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
let data_dir = state.data_dir.clone();
|
||||
blocking(move || ide_docker::set_registry_password(&data_dir, &id, password.as_deref())).await
|
||||
}
|
||||
|
||||
/// Test login to `registry` (`password`, else the saved one) through `endpoint`'s daemon.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_registry_login(
|
||||
state: State<'_, AppState>,
|
||||
endpoint: DockerEndpoint,
|
||||
registry: DockerRegistry,
|
||||
password: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let data_dir = state.data_dir.clone();
|
||||
blocking(move || {
|
||||
let password = password
|
||||
.or_else(|| ide_docker::registry_password(&data_dir, ®istry.id))
|
||||
.ok_or_else(|| IdeError::invalid("enter the registry password"))?;
|
||||
let auth = RegistryAuth { address: registry.address, username: registry.username, password };
|
||||
ide_docker::login(&endpoint, &auth)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Environment for a Docker run configuration's command line: selects `endpoint`'s
|
||||
/// daemon and puts the docker executable on `PATH`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn docker_run_env(endpoint: DockerEndpoint) -> IdeResult<std::collections::BTreeMap<String, String>> {
|
||||
endpoint.validate()?;
|
||||
blocking(move || Ok(ide_docker::run_env(&endpoint))).await
|
||||
}
|
||||
@@ -26,6 +26,24 @@ pub async fn lsp_completion(
|
||||
result
|
||||
}
|
||||
|
||||
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_inline_completion(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
automatic: bool,
|
||||
) -> IdeResult<Vec<InlineCompletionItem>> {
|
||||
let lsp = project!(window, state).lsp.clone();
|
||||
let cancel = state.begin_request(request);
|
||||
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
|
||||
state.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_resolve_completion(
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod refactor;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
pub mod spell;
|
||||
pub mod syntax;
|
||||
pub mod term;
|
||||
pub mod tools;
|
||||
@@ -51,6 +52,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::settings_get,
|
||||
app::settings_get_global,
|
||||
app::settings_set,
|
||||
app::settings_export,
|
||||
app::settings_import,
|
||||
app::project_rename,
|
||||
app::project_roots,
|
||||
app::cancel_request,
|
||||
app::log_frontend,
|
||||
@@ -108,11 +112,14 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
nav::fs_resolve_reference,
|
||||
nav::path_completions,
|
||||
nav::web_unresolved_references,
|
||||
spell::spell_check,
|
||||
spell::spell_suggest,
|
||||
search::search_start,
|
||||
search::replace_in_files,
|
||||
search::replace_preview,
|
||||
search::search_validate,
|
||||
lsp::lsp_completion,
|
||||
lsp::lsp_inline_completion,
|
||||
lsp::lsp_resolve_completion,
|
||||
lsp::lsp_hover,
|
||||
lsp::lsp_signature_help,
|
||||
@@ -170,12 +177,20 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
vcs::vcs_diff_hunks,
|
||||
vcs::vcs_stage_hunks,
|
||||
vcs::vcs_clone,
|
||||
vcs::vcs_init,
|
||||
vcs::vcs_git_version,
|
||||
vcs::vcs_detected_git,
|
||||
vcs::vcs_config_get,
|
||||
vcs::vcs_operation,
|
||||
vcs::vcs_create_tag,
|
||||
vcs::vcs_tags,
|
||||
vcs::vcs_merge_with,
|
||||
vcs::vcs_rebase_with,
|
||||
vcs::vcs_remotes,
|
||||
vcs::vcs_edit_remote,
|
||||
vcs::vcs_worktrees,
|
||||
vcs::vcs_add_worktree,
|
||||
vcs::vcs_remove_worktree,
|
||||
vcs::vcs_diff_with_worktree,
|
||||
vcs::vcs_diff_revisions,
|
||||
vcs::vcs_create_patch,
|
||||
@@ -199,11 +214,15 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
term::term_available_shells,
|
||||
run::run_configs,
|
||||
run::run_save_configs,
|
||||
run::run_templates,
|
||||
run::run_save_templates,
|
||||
run::run_system_env,
|
||||
run::run_start,
|
||||
run::run_stop,
|
||||
run::run_stdin,
|
||||
run::run_list,
|
||||
run::run_close_stdin,
|
||||
run::run_resize,
|
||||
run::run_test_reports,
|
||||
build::build_model,
|
||||
build::build_sync,
|
||||
@@ -231,6 +250,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tools::tools_uninstall,
|
||||
sdk::sdk_detect,
|
||||
sdk::sdk_python_effective,
|
||||
sdk::sdk_project_libraries,
|
||||
sdk::sdk_locate_tools,
|
||||
sdk::sdk_python_create_venv,
|
||||
sdk::sdk_python_packages,
|
||||
@@ -248,6 +268,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window::menu_configure,
|
||||
window::menu_refresh,
|
||||
window::menu_model,
|
||||
window::menu_set_disabled,
|
||||
window::app_log_location,
|
||||
window::app_build_info,
|
||||
window::app_check_update,
|
||||
@@ -268,6 +289,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
debug::debug_redefine_classes,
|
||||
coverage::coverage_prepare,
|
||||
coverage::coverage_collect,
|
||||
coverage::coverage_import,
|
||||
web::web_browsers,
|
||||
web::web_url,
|
||||
web::web_open,
|
||||
@@ -299,6 +321,11 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
docker::docker_pull,
|
||||
docker::docker_prune,
|
||||
docker::docker_compose_op,
|
||||
docker::docker_settings,
|
||||
docker::docker_save_settings,
|
||||
docker::docker_set_registry_password,
|
||||
docker::docker_registry_login,
|
||||
docker::docker_run_env,
|
||||
github::gh_availability,
|
||||
github::gh_pr_list,
|
||||
github::gh_pr_view,
|
||||
|
||||
@@ -26,6 +26,28 @@ pub fn run_save_configs(window: Window, state: State<'_, AppState>, configs: Vec
|
||||
ide_run::save_user_configs(&root, &configs)
|
||||
}
|
||||
|
||||
/// Configuration templates: per-type defaults applied to new configurations.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<RunConfig>> {
|
||||
let root = state.project(window.label())?.root.clone();
|
||||
ide_run::load_templates(&root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec<RunConfig>) -> IdeResult<()> {
|
||||
let root = state.project(window.label())?.root.clone();
|
||||
ide_run::save_templates(&root, &templates)
|
||||
}
|
||||
|
||||
/// The environment runs inherit when "Include system environment variables" is on.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_system_env() -> std::collections::BTreeMap<String, String> {
|
||||
ide_run::system_env()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_start(
|
||||
@@ -69,6 +91,13 @@ pub fn run_stdin(state: State<'_, AppState>, id: RunId, data: String) -> IdeResu
|
||||
state.processes.write_stdin(id, &data)
|
||||
}
|
||||
|
||||
/// Terminal size of a run started with "Emulate terminal in output console".
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_resize(state: State<'_, AppState>, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
state.processes.resize(id, cols, rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn run_list(state: State<'_, AppState>) -> Vec<RunInfo> {
|
||||
|
||||
@@ -28,6 +28,33 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sdk_project_libraries(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
let project = state.project(window.label())?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let model = crate::commands::build::load(&root, &cache, &settings);
|
||||
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
|
||||
.into_iter()
|
||||
.map(|c| ide_sdk::libraries::BuildDependency {
|
||||
gradle: c.gradle,
|
||||
group: c.group_id,
|
||||
artifact: c.artifact_id,
|
||||
version: c.version,
|
||||
})
|
||||
.collect();
|
||||
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Executables found for "run this file", and where single files are compiled to.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -40,7 +40,14 @@ pub async fn search_start(
|
||||
let cancel = state.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
let languages = state.languages.clone();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
|
||||
project.content_roots()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let languages = project.languages.clone();
|
||||
let syntax = state.syntax();
|
||||
let events = on_event.clone();
|
||||
let summary = tauri::async_runtime::spawn_blocking(move || {
|
||||
@@ -55,17 +62,29 @@ pub async fn search_start(
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
ide_search::search_in_context(
|
||||
&root,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&cancel,
|
||||
&|file| {
|
||||
let _ = events.send(SearchEvent::File { file });
|
||||
},
|
||||
)
|
||||
let mut total = ide_search::SearchSummary::default();
|
||||
for dir in std::iter::once(&root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&cancel,
|
||||
&|file| {
|
||||
let _ = events.send(SearchEvent::File { file });
|
||||
},
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok::<_, ide_core::IdeError>(total)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
|
||||
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user