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:
l0ng-ai committed 2026-10-25 21:24:00 +08:00
1 parent 294198a727
commit 1e00ea5ee2
426 files changed
+85241 -2886

No files matched your search

Generated
+29
View File
@@ -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"
+1
View File
@@ -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"] }
+9
View File
@@ -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`
+1
View File
@@ -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
+100
View File
@@ -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]
+89
View File
@@ -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"]);
}
}
+67 -2
View File
@@ -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,
}
}
}
+5
View File
@@ -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,
+105
View File
@@ -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());
}
}
+1
View File
@@ -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;
+74 -1
View File
@@ -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());
}
}
+20
View File
@@ -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.
+173
View File
@@ -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);
}
}
+2
View File
@@ -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};
+1
View File
@@ -288,6 +288,7 @@ mod tests {
is_test: true,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
+224 -15
View File
@@ -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
View File
@@ -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(&current) {
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())
}
+335
View File
@@ -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");
}
}
+14 -2
View File
@@ -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') \
+1 -1
View File
@@ -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),
+21 -1
View File
@@ -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(())
}
+3
View File
@@ -15,3 +15,6 @@ which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+402
View File
@@ -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, &registry.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
View File
@@ -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(&current).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)
}
+3
View File
@@ -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.
+18 -1
View File
@@ -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;
+4 -4
View File
@@ -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
View File
@@ -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()]));
}
}
+29 -7
View File
@@ -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)
}
}
+70
View File
@@ -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");
}
+74 -22
View File
@@ -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}");
}
+18 -3
View File
@@ -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(),
}
}
}
+244 -49
View File
@@ -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(&reg, "a/x.foo"), "python");
assert_eq!(detect(&reg, "x.FOO"), "python");
assert_eq!(detect(&reg, "Pipfile.lock"), "python");
assert_eq!(detect(&reg, "build_dev.cfg"), "python");
assert_eq!(detect(&reg, "setup.cfg"), "ini");
assert_eq!(detect(&reg, "server.log"), "python", "a user extension overrides a built-in one");
assert_eq!(detect(&reg, "Jenkinsfile"), "shellscript");
assert_eq!(detect(&reg, "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(&reg, "main.rs"), "rust");
assert_eq!(detect(&reg, "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(&reg, "/proj/notes.txt"), "markdown");
assert_eq!(detect(&reg, "/other/notes.txt"), "plaintext");
assert_eq!(detect(&reg, "/abs/run"), "python");
assert_eq!(detect(&reg, "/proj/bad.txt"), "plaintext");
}
#[test]
+68 -1
View File
@@ -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;
}
}
}
}
+8 -1
View File
@@ -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))) {
+116 -2
View File
@@ -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());
+107 -1
View File
@@ -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"]);
}
+2
View File
@@ -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",
+117
View File
@@ -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)
}
+34
View File
@@ -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) };
+15
View File
@@ -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 {
+36
View File
@@ -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())]);
+1
View File
@@ -233,6 +233,7 @@ mod tests {
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
+1
View File
@@ -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
View File
@@ -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<_>>())
}
+1
View File
@@ -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(),
}
}
+159
View File
@@ -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
View File
@@ -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
View File
@@ -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::*;
+1
View File
@@ -261,6 +261,7 @@ mod tests {
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
+57
View File
@@ -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);
}
+106
View File
@@ -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\""));
}
+1
View File
@@ -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
+3 -3
View File
@@ -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)
}
+17 -1
View File
@@ -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())
+1
View File
@@ -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;
+469
View File
@@ -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()
}
+171
View File
@@ -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());
}
+1
View File
@@ -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
+11 -3
View File
@@ -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.
+16 -2
View File
@@ -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());
}
}
+19
View File
@@ -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)?;
}
+207
View File
@@ -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:?}");
}
}
}
+22
View File
@@ -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"]);
}
+15
View File
@@ -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
+347
View File
@@ -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
+205
View File
@@ -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
+463
View File
@@ -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
+338
View File
@@ -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());
}
}
+171
View File
@@ -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
View File
@@ -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 {
+36
View File
@@ -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> {
+343
View File
@@ -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"));
}
}
+175
View File
@@ -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);
}
+1
View File
@@ -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
+337
View File
@@ -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));
}
}
+25
View File
@@ -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!(
+31 -1
View File
@@ -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),
+13
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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"] }
+2 -1
View File
@@ -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");
+48 -8
View File
@@ -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| {
+1 -1
View File
@@ -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<()> + '_ {
+1 -1
View File
@@ -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};
+27
View File
@@ -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);
}
}
+111
View File
@@ -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);
}
}
+63 -1
View File
@@ -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);
}
+1
View File
@@ -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" }
+2 -2
View File
@@ -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 || {
+82 -2
View File
@@ -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]
+10 -1
View File
@@ -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
}
+19 -9
View File
@@ -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
}
+87 -14
View File
@@ -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, &registry.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
}
+18
View File
@@ -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(
+27
View File
@@ -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,
+29
View File
@@ -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> {
+27
View File
@@ -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")]
+31 -12
View File
@@ -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