Ignore invalid file patterns individually (#64525)
## 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 <kirill@zed.dev>
This commit is contained in:
1 parent
1f37291957
commit
313f63eb71
7 files changed
+561
-67
No files matched your search
@@ -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::<AllLanguageSettings>();
|
||||
|
||||
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::<AllLanguageSettings>(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;
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
})
|
||||
.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": {}}"#,
|
||||
"{}",
|
||||
] {
|
||||
|
||||
+100
-1
@@ -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::<Result<Vec<_>, _>>()?;
|
||||
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<Item = impl AsRef<str>>,
|
||||
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<Glob>, path_style: PathStyle) -> Result<Self, globset::Error> {
|
||||
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
|
||||
|
||||
@@ -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<String> = 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<String>, context: &'static str) -> anyhow::Result<PathMatcher> {
|
||||
fn valid_path_matchers(mut values: Vec<String>, 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<String>) -> (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<String> {
|
||||
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
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user