From 313f63eb71ebc721ac2c3bc3781698780b83de66 Mon Sep 17 00:00:00 2001 From: Dom Porada Date: Sun, 20 Sep 2026 19:09:14 +0000 Subject: [PATCH] Ignore invalid file patterns individually (#64525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Objective Prevent one invalid file pattern from discarding valid patterns or causing settings loading to panic. The pre-existing exclusion-matcher bug was reported during review of #64492 (part of #64399). ## Solution Ignore invalid entries individually and log their errors in `file_scan_exclusions`, `file_scan_inclusions`, `file_types`, `hidden_files`, `private_files`, and `read_only_files`. Preserve valid inherited entries without changing `"..."`, replacement, or empty list behavior. Valid brace inclusion patterns remain usable, including when an alternative spans multiple directories. ## Testing Reproduced matcher loss and inclusion and file type panic paths before fixing them. Targeted tests pass on macOS for mixed, inherited, replacement, empty, and all-invalid lists, including inherited `.git` exclusions, private file protections, and inclusion rescans with brace patterns. Windows and Linux execution remains untested. ## Self-Review Checklist: - [x] I’ve reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed invalid file patterns discarding valid exclusions, hidden file rules, or read-only rules, or causing inclusion settings to panic. Also fixed invalid private file patterns discarding valid protections and invalid file type patterns causing settings loading to panic. --------- Co-authored-by: Kirill Bulatov --- crates/language/src/language_settings.rs | 70 +++++- crates/settings/src/vscode_import.rs | 47 ++-- crates/util/src/paths.rs | 101 ++++++++- crates/worktree/src/worktree_settings.rs | 126 ++++++++--- .../integration/worktree_settings_tests.rs | 213 +++++++++++++++++- .../tests/integration/worktree_tests.rs | 53 ++++- docs/src/migrate/vs-code.md | 18 +- 7 files changed, 561 insertions(+), 67 deletions(-) diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 95c65f562d..d415eeb9e9 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -974,15 +974,27 @@ impl settings::Settings for AllLanguageSettings { let mut builder = GlobSetBuilder::new(); for pattern in &patterns.0 { - builder.add(Glob::new(pattern).unwrap()); + match Glob::new(pattern) { + Ok(glob) => { + builder.add(glob); + } + Err(error) => { + log::error!( + "Failed to parse a `file_types` pattern for {language}. Skipping this pattern:\n\n{error}" + ); + } + } } + let matcher = builder.build().unwrap_or_else(|error| { + log::error!( + "Failed to compile `file_types` patterns for {language}. Using an empty matcher:\n\n{error}" + ); + GlobSet::empty() + }); file_types.insert( language.clone(), - ( - builder.build().unwrap(), - patterns.0.iter().cloned().collect(), - ), + (matcher, patterns.0.iter().cloned().collect()), ); } @@ -1036,6 +1048,54 @@ mod tests { use settings::{LocalSettingsKind, LocalSettingsPath, WorktreeId}; use util::rel_path::rel_path; + #[gpui::test] + fn test_file_types_preserves_valid_patterns(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + store.register_setting::(); + + for patterns in [ + vec!["*.rs", "[", "config.*"], + vec!["[", "{"], + vec![], + vec!["*.rs", "config.*"], + ] { + store + .set_user_settings( + &serde_json::json!({ + "file_types": { + "Python": ["*.py"], + "Rust": patterns, + }, + }) + .to_string(), + cx, + ) + .unwrap(); + + let settings = store.get::(None); + let (matcher, sources) = settings.file_types.get("Rust").unwrap(); + assert_eq!(sources, &patterns); + assert_eq!(matcher.is_match("main.rs"), patterns.contains(&"*.rs")); + assert_eq!( + matcher.is_match("config.custom"), + patterns.contains(&"config.*") + ); + assert!(!matcher.is_match("main.py")); + assert!(!matcher.is_match("unrelated.txt")); + assert!(!matcher.is_match("[")); + assert!(!matcher.is_match("{")); + assert_eq!(matcher.is_empty(), !patterns.contains(&"*.rs")); + + let (matcher, sources) = settings.file_types.get("Python").unwrap(); + assert_eq!(sources, &["*.py"]); + assert!(matcher.is_match("main.py")); + assert!(!matcher.is_match("main.rs")); + assert!(!matcher.is_match("config.custom")); + } + }); + } + #[gpui::test] fn test_edit_predictions_enabled_for_file(cx: &mut TestAppContext) { use crate::TestFile; diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index f74c65acac..ac94edba23 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1154,20 +1154,23 @@ impl VsCodeSettings { .any(|enabled| enabled.as_bool() == Some(true)) }) }) - .and_then(|v| v.as_object()) - .map(|v| { - v.iter() - .filter_map(|(k, v)| { - if v.as_bool().unwrap_or(false) { - Some(k.to_owned()) - } else { - None - } + .and_then(Value::as_object) + .map(|patterns| { + patterns + .iter() + .filter(|(pattern, enabled)| { + !pattern.is_empty() + && pattern.as_str() != SplicingVec::REST + && enabled.as_bool() == Some(true) }) + .map(|(pattern, _)| pattern.to_owned()) .collect::>() }) - .filter(|r| !r.is_empty()) - .map(SplicingVec::from), + .filter(|patterns| !patterns.is_empty()) + .map(|mut patterns| { + patterns.push(SplicingVec::REST.to_owned()); + SplicingVec::from(patterns) + }), } } } @@ -1284,7 +1287,13 @@ mod tests { }; let imported = VsCodeSettings::from_str( r#"{ - "files.readonlyInclude": {"**/*.gen.rs": true, "**/*.lock": false}, + "files.readonlyInclude": { + "": true, + "**/*.gen.rs": true, + "**/*.lock": false, + "**/generated/**": true, + "...": true + }, "files.readonlyExclude": {"**/editable.gen.rs": false} }"#, VsCodeSettingsSource::VsCode, @@ -1292,15 +1301,21 @@ mod tests { .worktree_settings_content(); assert_eq!( serde_json::to_value(&imported.read_only_files)?, - serde_json::json!(["**/*.gen.rs"]) + serde_json::json!(["**/*.gen.rs", "**/generated/**", "..."]) + ); + let mut spliced = inherited.clone(); + spliced.merge_from(&imported); + assert_eq!( + serde_json::to_value(&spliced.read_only_files)?, + serde_json::json!(["**/*.gen.rs", "**/generated/**", "**/*.lock"]) ); - let mut replaced = inherited.clone(); - replaced.merge_from(&imported); - assert_eq!(replaced.read_only_files, imported.read_only_files); for content in [ r#"{"files.readonlyExclude": {"**/*.gen.rs": true}}"#, r#"{"files.readonlyInclude": {"**/*.gen.rs": false}}"#, + r#"{"files.readonlyInclude": {"": true}}"#, + r#"{"files.readonlyInclude": {"...": true}}"#, + r#"{"files.readonlyInclude": ["**/*.gen.rs"]}"#, r#"{"files.readonlyInclude": {}}"#, "{}", ] { diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index c2c3268714..9e80bf1d60 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1,4 +1,4 @@ -use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder}; use itertools::Itertools; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -768,6 +768,44 @@ impl PathMatcher { .build() }) .collect::, _>>()?; + Self::from_globs(globs, path_style) + } + + /// Skips invalid globs, reporting each error to `on_error`. + /// If the combined set cannot be built, reports the error and matches nothing. + pub fn new_lenient( + globs: impl IntoIterator>, + path_style: PathStyle, + mut on_error: impl FnMut(globset::Error), + ) -> Self { + let globs = globs + .into_iter() + .filter_map(|pattern| { + match GlobBuilder::new(pattern.as_ref()) + .backslash_escape(path_style.is_posix()) + .build() + { + Ok(glob) => Some(glob), + Err(error) => { + on_error(error); + None + } + } + }) + .collect(); + match Self::from_globs(globs, path_style) { + Ok(matcher) => matcher, + Err(error) => { + on_error(error); + Self { + path_style, + ..Self::default() + } + } + } + } + + fn from_globs(globs: Vec, path_style: PathStyle) -> Result { let sources = globs .iter() .filter_map(|glob| { @@ -1471,6 +1509,67 @@ mod tests { use super::*; use util_macros::perf; + #[test] + fn test_lenient_path_matcher() { + for path_style in [PathStyle::Unix, PathStyle::Windows] { + let patterns = ["**/.git", "[", "target/**", "{"]; + let mut errors = Vec::new(); + let matcher = + PathMatcher::new_lenient(patterns, path_style, |error| errors.push(error)); + let expected = PathMatcher::new(["**/.git", "target/**"], path_style).unwrap(); + assert_eq!(matcher, expected); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].glob(), Some("[")); + assert_eq!(errors[1].glob(), Some("{")); + for path in ["nested/.git", "src/file.rs", "target/file.rs"] { + let path = RelPath::from_unix_str(path).unwrap(); + assert_eq!(matcher.is_match(path), expected.is_match(path)); + } + if path_style == PathStyle::local() { + assert!(matcher.is_match(RelPath::from_unix_str("nested/.git").unwrap())); + assert!(matcher.is_match(RelPath::from_unix_str("target/file.rs").unwrap())); + } + assert!(PathMatcher::new(patterns, path_style).is_err()); + + let mut errors = Vec::new(); + let matcher = + PathMatcher::new_lenient(["[", "{"], path_style, |error| errors.push(error)); + assert_eq!(errors.len(), 2); + assert_eq!(matcher.path_style, path_style); + assert_eq!(matcher.sources().count(), 0); + assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap())); + assert!(!matcher.is_match_std_path("file.rs")); + + let matcher = PathMatcher::new_lenient([] as [&str; 0], path_style, |_| { + panic!("empty patterns are valid") + }); + assert_eq!(matcher.path_style, path_style); + assert_eq!(matcher.sources().count(), 0); + assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap())); + } + } + + #[test] + fn test_lenient_path_matcher_preserves_escaping() { + for path_style in [PathStyle::Unix, PathStyle::Windows] { + let patterns = [r"directory\file.rs", r"literal\*", r"literal\[name]"]; + let strict = PathMatcher::new(patterns, path_style).unwrap(); + let lenient = PathMatcher::new_lenient(patterns, path_style, |_| { + panic!("valid patterns are preserved") + }); + assert_eq!(lenient, strict); + for path in [ + "directory/file.rs", + "literal*", + "literal/file.rs", + "literal[name]", + ] { + let path = RelPath::from_unix_str(path).unwrap(); + assert_eq!(lenient.is_match(path), strict.is_match(path)); + } + } + } + #[test] fn test_parse_str_treats_paren_suffix_as_position() { // This documents the behavior that causes the folder-drop bug: a name ending in diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index 1999ba2092..39f840b612 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -1,9 +1,7 @@ use std::path::Path; -use anyhow::Context as _; use settings::{RegisterSetting, ScanSymlinksSetting, Settings}; use util::{ - ResultExt, paths::{PathMatcher, PathStyle}, rel_path::RelPath, }; @@ -66,46 +64,106 @@ impl Settings for WorktreeSettings { let hidden_files = worktree.hidden_files.unwrap().0; let read_only_files = worktree.read_only_files.unwrap_or_default().0; let scan_symlinks = worktree.scan_symlinks.unwrap(); - let parsed_file_scan_inclusions: Vec = file_scan_inclusions - .iter() - .flat_map(|glob| { - Path::new(glob) - .ancestors() - .skip(1) - .map(|a| a.to_string_lossy().into()) - }) - .filter(|p: &String| !p.is_empty()) - .collect(); + let (file_scan_inclusions, parent_dir_scan_inclusions) = + file_scan_inclusion_matchers(file_scan_inclusions); Self { prevent_sharing_in_public_channels: worktree.prevent_sharing_in_public_channels, - file_scan_exclusions: path_matchers(file_scan_exclusions, "file_scan_exclusions") - .log_err() - .unwrap_or_default(), - parent_dir_scan_inclusions: path_matchers( - parsed_file_scan_inclusions, - "file_scan_inclusions", - ) - .unwrap(), - file_scan_inclusions: path_matchers(file_scan_inclusions, "file_scan_inclusions") - .unwrap(), - private_files: path_matchers(private_files, "private_files") - .log_err() - .unwrap_or_default(), - hidden_files: path_matchers(hidden_files, "hidden_files") - .log_err() - .unwrap_or_default(), - read_only_files: path_matchers(read_only_files, "read_only_files") - .log_err() - .unwrap_or_default(), + file_scan_exclusions: valid_path_matchers(file_scan_exclusions, "file_scan_exclusions"), + parent_dir_scan_inclusions, + file_scan_inclusions, + private_files: valid_path_matchers(private_files, "private_files"), + hidden_files: valid_path_matchers(hidden_files, "hidden_files"), + read_only_files: valid_path_matchers(read_only_files, "read_only_files"), scan_symlinks, file_scan_depth: worktree.file_scan_depth.filter(|depth| *depth > 0), } } } -fn path_matchers(mut values: Vec, context: &'static str) -> anyhow::Result { +fn valid_path_matchers(mut values: Vec, context: &'static str) -> PathMatcher { values.sort(); - PathMatcher::new(values, PathStyle::local()) - .with_context(|| format!("Failed to parse globs from {}", context)) + PathMatcher::new_lenient(values, PathStyle::local(), |error| { + log::error!("Failed to compile patterns in `{context}`: {error}"); + }) +} + +fn file_scan_inclusion_matchers(mut values: Vec) -> (PathMatcher, PathMatcher) { + values.sort(); + let mut errors = Vec::new(); + let inclusions = PathMatcher::new_lenient(&values, PathStyle::local(), |error| { + errors.push(error); + }); + let parent_inclusions = values + .iter() + .filter(|pattern| { + !errors.iter().any(|error| { + error + .glob() + .is_none_or(|invalid| invalid == pattern.as_str()) + }) + }) + .flat_map(|pattern| inclusion_parent_patterns(pattern, PathStyle::local())) + .collect(); + for error in errors { + log::error!("Failed to compile patterns in `file_scan_inclusions`: {error}"); + } + ( + inclusions, + valid_path_matchers(parent_inclusions, "file_scan_inclusions"), + ) +} + +fn inclusion_parent_patterns(pattern: &str, path_style: PathStyle) -> Vec { + let mut parents = Vec::new(); + let mut open_braces = 0_usize; + let mut class_start = None; + let mut escaped = false; + for (index, character) in pattern.char_indices() { + let is_separator = path_style.separators_ch().contains(&character); + let mut split_index = index; + if class_start.is_none() { + if escaped { + escaped = false; + if !is_separator { + continue; + } + split_index -= 1; + } else if character == '\\' && path_style.is_posix() { + escaped = true; + continue; + } + } + if let Some(start) = class_start { + let first_content = start + + 1 + + usize::from(matches!( + pattern.as_bytes().get(start + 1), + Some(b'!' | b'^') + )); + if character == ']' && index > first_content { + class_start = None; + } + } else { + match character { + '[' => class_start = Some(index), + '{' => open_braces += 1, + '}' => open_braces = open_braces.saturating_sub(1), + _ => {} + } + } + if is_separator { + let closing_braces = "}".repeat(open_braces); + if let Some(start) = class_start { + parents.push(format!("{}*{closing_braces}", &pattern[..start])); + } else if open_braces > 0 { + parents.push(format!("{}{closing_braces}", &pattern[..split_index])); + } else if split_index > 0 { + parents.push(pattern[..split_index].to_string()); + } + } + } + parents.sort(); + parents.dedup(); + parents } diff --git a/crates/worktree/tests/integration/worktree_settings_tests.rs b/crates/worktree/tests/integration/worktree_settings_tests.rs index 993a095e41..7fc96fcf7e 100644 --- a/crates/worktree/tests/integration/worktree_settings_tests.rs +++ b/crates/worktree/tests/integration/worktree_settings_tests.rs @@ -1,7 +1,9 @@ +use serde_json::json; +use settings::{MergeFromTrait as _, RootUserSettings as _, Settings, SettingsContent}; use std::path::Path; use util::{ paths::{PathMatcher, PathStyle}, - rel_path::RelPath, + rel_path::{RelPath, rel_path}, }; use worktree::*; @@ -23,6 +25,215 @@ fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings { } } +fn settings_with_patterns(setting: &str, patterns: &[&str]) -> WorktreeSettings { + zlog::init_test(); + let mut content = SettingsContent::parse_json_with_comments(&settings::default_settings()) + .expect("default settings"); + let inherited: SettingsContent = + serde_json::from_value(json!({ setting: ["**/.git"] })).expect("inherited settings"); + content.merge_from(&inherited); + let overrides: SettingsContent = + serde_json::from_value(json!({ setting: patterns })).expect("override settings"); + content.merge_from(&overrides); + WorktreeSettings::from_settings(&content) +} + +fn assert_file_pattern_settings(setting: &str) { + for (patterns, expected) in [ + (vec!["**/alpha/**", "zeta/**"], [false, true, true]), + (vec!["**/alpha/**", "[", "zeta/**"], [false, true, true]), + (vec!["..."], [true, false, false]), + (vec!["...", "["], [true, false, false]), + ( + vec!["**/alpha/**", "...", "[", "zeta/**"], + [true, true, true], + ), + (vec!["[", "{"], [false, false, false]), + (vec![], [false, false, false]), + ] { + let settings = settings_with_patterns(setting, &patterns); + let matcher = match setting { + "file_scan_exclusions" => &settings.file_scan_exclusions, + "file_scan_inclusions" => &settings.file_scan_inclusions, + "hidden_files" => &settings.hidden_files, + "read_only_files" => &settings.read_only_files, + _ => unreachable!(), + }; + for (path, expected) in [".git", "alpha/file.txt", "zeta/file.txt"] + .into_iter() + .zip(expected) + { + assert_eq!( + matcher.is_match(rel_path(path)), + expected, + "{setting}: {patterns:?} matching {path}" + ); + } + assert!(!matcher.is_match(rel_path("unmatched/file.txt"))); + } +} + +#[test] +fn test_invalid_file_scan_exclusions() { + assert_file_pattern_settings("file_scan_exclusions"); +} + +#[test] +fn test_invalid_exclusion_addition_preserves_git() { + let settings = settings_with_patterns("file_scan_exclusions", &["...", "["]); + assert!(settings.is_path_excluded(rel_path(".git/config"))); + assert!(settings.is_path_excluded(rel_path("nested/.git/config"))); +} + +#[test] +fn test_invalid_file_scan_inclusions() { + assert_file_pattern_settings("file_scan_inclusions"); +} + +#[test] +fn test_invalid_hidden_files() { + assert_file_pattern_settings("hidden_files"); +} + +#[test] +fn test_invalid_read_only_files() { + assert_file_pattern_settings("read_only_files"); +} + +#[test] +fn test_invalid_direct_inclusions_do_not_include_parents() { + let settings = + settings_with_patterns("file_scan_inclusions", &["ignored/[", "valid/nested/**"]); + assert!(!settings.is_path_always_included(rel_path("ignored"), true)); + assert!(!settings.is_path_always_included(rel_path("ignored/file.txt"), false)); + assert!(settings.is_path_always_included(rel_path("valid"), true)); + assert!(settings.is_path_always_included(rel_path("valid/nested"), true)); + assert!(settings.is_path_always_included(rel_path("valid/nested/file.txt"), false)); +} + +#[test] +fn test_inclusions_preserve_brace_patterns() { + for (pattern, files) in [ + ( + "ignored/{one/two,three}/file.rs", + ["ignored/one/two/file.rs", "ignored/three/file.rs"], + ), + ( + "{one/two,three}/file.rs", + ["one/two/file.rs", "three/file.rs"], + ), + ] { + assert!(PathMatcher::new([pattern], PathStyle::local()).is_ok()); + let settings = + settings_with_patterns("file_scan_inclusions", &[pattern, "valid/nested/**"]); + for file in files { + assert!(settings.is_path_always_included(rel_path(file), false)); + for parent in rel_path(file) + .ancestors() + .skip(1) + .filter(|path| !path.is_empty()) + { + assert!( + settings.is_path_always_included(parent, true), + "{pattern}: missing ancestor {parent:?}" + ); + } + } + assert!(settings.is_path_always_included(rel_path("valid"), true)); + assert!(settings.is_path_always_included(rel_path("valid/nested/file.txt"), false)); + assert!(!settings.is_path_always_included(rel_path("unmatched/file.txt"), false)); + assert!( + !settings.is_path_always_included(rel_path("unmatched"), true), + "{pattern}: unexpected ancestor" + ); + } +} + +#[test] +fn test_inclusion_parents_close_nested_braces() { + let settings = settings_with_patterns("file_scan_inclusions", &["{a/{b,c},d}/file.rs"]); + for directory in ["a", "a/b", "a/c", "d"] { + assert!( + settings.is_path_always_included(rel_path(directory), true), + "missing ancestor {directory}" + ); + } + for directory in ["b", "c", "unmatched"] { + assert!( + !settings.is_path_always_included(rel_path(directory), true), + "unexpected ancestor {directory}" + ); + } + assert!(settings.is_path_always_included(rel_path("a/b/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("d/file.rs"), false)); +} + +#[test] +fn test_inclusion_parents_close_braces_around_character_classes() { + let settings = settings_with_patterns("file_scan_inclusions", &["{a,[x/y]z}/file.rs"]); + assert!(settings.is_path_always_included(rel_path("a"), true)); + assert!(settings.is_path_always_included(rel_path("xz"), true)); + assert!(settings.is_path_always_included(rel_path("a/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("xz/file.rs"), false)); +} + +#[test] +fn test_inclusion_parents_preserve_literal_braces() { + let mut patterns = vec!["ignored/[{]one/file.rs"]; + if PathStyle::local().is_posix() { + patterns.push(r"ignored/\{one/file.rs"); + } + for pattern in patterns { + let settings = settings_with_patterns("file_scan_inclusions", &[pattern]); + assert!(settings.is_path_always_included(rel_path("ignored/{one/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("ignored/{one"), true)); + assert!(settings.is_path_always_included(rel_path("ignored"), true)); + assert!(!settings.is_path_always_included(rel_path("ignored/other/file.rs"), false)); + } +} + +#[cfg(not(windows))] +#[test] +fn test_inclusion_parents_preserve_escaping() { + let settings = settings_with_patterns("file_scan_inclusions", &[r"{one\/two,three}/file.rs"]); + assert!(settings.is_path_always_included(rel_path("one/two/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("one/two"), true)); + assert!(settings.is_path_always_included(rel_path("one"), true)); + + let settings = settings_with_patterns("file_scan_inclusions", &[r"ignored\/nested/file.rs"]); + assert!(settings.is_path_always_included(rel_path("ignored/nested/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("ignored/nested"), true)); + assert!(settings.is_path_always_included(rel_path("ignored"), true)); +} + +#[cfg(not(windows))] +#[test] +fn test_inclusion_parents_preserve_character_classes() { + let settings = settings_with_patterns("file_scan_inclusions", &[r"[a\]cache/file.rs"]); + assert!(settings.is_path_always_included(rel_path("acache/file.rs"), false)); + assert!(settings.is_path_always_included(rel_path("acache"), true)); + assert!(!settings.is_path_always_included(rel_path("unmatched"), true)); +} + +#[test] +fn test_private_files_preserves_valid_patterns() { + let settings = settings_with_patterns("private_files", &["**/private/**"]); + assert!(settings.is_path_private(rel_path("private/file.txt"))); + assert!(settings.is_path_private(rel_path(".env.local"))); + + let settings = settings_with_patterns("private_files", &["**/private/**", "["]); + assert!(settings.is_path_private(rel_path("private/file.txt"))); + assert!(settings.is_path_private(rel_path(".env.local"))); + assert!(!settings.is_path_private(rel_path("src/main.rs"))); + assert!(PathMatcher::new(["**/private/**", "["], PathStyle::local()).is_err()); + + for patterns in [&["["][..], &[][..]] { + let settings = settings_with_patterns("private_files", patterns); + assert!(settings.is_path_private(rel_path(".env.local"))); + assert!(!settings.is_path_private(rel_path("private/file.txt"))); + } +} + #[test] fn test_is_path_read_only_with_glob_patterns() { let settings = make_settings_with_read_only(&["**/generated/**", "**/*.gen.rs"]); diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index c1984ad290..ad23eab8b6 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -2117,6 +2117,7 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![ "...".to_string(), + "[".to_string(), "ignored/**/excluded.project".to_string(), ])); settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![ @@ -2142,7 +2143,7 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext json!({ ".env.local": "", ".git": {}, - ".gitignore": ".env*\n/ignored/\nunmatched.txt\n", + ".gitignore": ".env*\n/ignored/\n/one/\n/three/\nunmatched.txt\n", "ignored": { "nested": { "excluded.project": "", @@ -2151,6 +2152,8 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext "unmatched.txt": "" } }, + "one": { "two": { "file.rs": "" } }, + "three": { "file.rs": "" }, "unmatched.txt": "" }), ) @@ -2178,6 +2181,7 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext "expected {path} to remain ignored" ); } + assert_eq!(tree.entry_for_path(rel_path(".git")), None); assert_eq!( tree.entry_for_path(rel_path("ignored/nested/excluded.project")), None @@ -2195,17 +2199,54 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext ); }); - for (settings, included_paths) in [ + let ignored_directories = ["ignored", "ignored/nested", "one", "one/two", "three"]; + for (settings, included_paths, always_included_directories) in [ + ( + r#"{ "file_scan_inclusions": ["...", "ignored/**/*.project", "{one/two,three}/file.rs", "invalid/["] }"#, + vec![ + rel_path(".env.local"), + rel_path(".gitignore"), + rel_path("ignored/nested/included.project"), + rel_path("ignored/nested/included.user"), + rel_path("one/two/file.rs"), + rel_path("three/file.rs"), + ], + &ignored_directories[..], + ), + ( + r#"{ "file_scan_inclusions": ["{one/two,three}/file.rs"] }"#, + vec![ + rel_path(".gitignore"), + rel_path("one/two/file.rs"), + rel_path("three/file.rs"), + ], + &["one", "one/two", "three"][..], + ), + ( + r#"{ "file_scan_inclusions": ["ignored/{nested,missing}/included.project"] }"#, + vec![ + rel_path(".gitignore"), + rel_path("ignored/nested/included.project"), + ], + &["ignored", "ignored/nested"][..], + ), ( r#"{ "file_scan_inclusions": ["ignored/**/*.project"] }"#, vec![ rel_path(".gitignore"), rel_path("ignored/nested/included.project"), ], + &["ignored", "ignored/nested"][..], + ), + ( + r#"{ "file_scan_inclusions": ["invalid/["] }"#, + vec![rel_path(".gitignore")], + &[][..], ), ( r#"{ "file_scan_inclusions": [] }"#, vec![rel_path(".gitignore")], + &[][..], ), ] { cx.update(|cx| { @@ -2227,6 +2268,14 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext }) .await; tree.read_with(cx, |tree, _| { + for path in ignored_directories { + assert_eq!( + tree.entry_for_path(rel_path(path)) + .is_some_and(|entry| entry.is_always_included), + always_included_directories.contains(&path), + "ancestor inclusion for {path} with {settings}" + ); + } assert_eq!( tree.files(false, 0) .map(|entry| entry.path.as_ref()) diff --git a/docs/src/migrate/vs-code.md b/docs/src/migrate/vs-code.md index 124f8250d9..78cfaca2b0 100644 --- a/docs/src/migrate/vs-code.md +++ b/docs/src/migrate/vs-code.md @@ -88,6 +88,7 @@ The following VS Code settings are automatically imported when you use **Import | `files.autoSave` | `autosave` | | `files.autoSaveDelay` | `autosave.milliseconds` | | `files.insertFinalNewline` | `ensure_final_newline_on_save` | +| `files.readonlyInclude` | `read_only_files` | | `files.simpleDialog.enable` | `use_system_path_prompts` | | `files.watcherExclude` | `file_scan_exclusions` | | `search.smartCase` | `use_smartcase_search` | @@ -173,14 +174,15 @@ Zed doesn’t import extensions or keybindings, but this import gets core editor You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. Here’s how common VS Code settings translate: -| VS Code | Zed | Notes | -| --- | --- | --- | -| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | -| editor.fontSize | buffer_font_size | Set in pixels | -| editor.tabSize | tab_size | Can override per language | -| editor.insertSpaces | insert_spaces | Boolean | -| editor.formatOnSave | format_on_save | Works with formatter enabled | -| editor.wordWrap | soft_wrap | Supports optional wrap column | + +| VS Code | Zed | Notes | +| ------------------- | ------------------ | ----------------------------- | +| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | +| editor.fontSize | buffer_font_size | Set in pixels | +| editor.tabSize | tab_size | Can override per language | +| editor.insertSpaces | insert_spaces | Boolean | +| editor.formatOnSave | format_on_save | Works with formatter enabled | +| editor.wordWrap | soft_wrap | Supports optional wrap column | Zed also supports per-project settings. You can find these in the Settings Editor as well.