feat: add configurable whitespace markers

This commit is contained in:
2026-10-13 22:53:40 +02:00
parent e2f33519d1
commit 2357b5be46
8 changed files with 210 additions and 7 deletions
+73 -3
View File
@@ -46,6 +46,7 @@ pub struct Config {
pub labels: BTreeMap<String, String>,
pub scroll_distance: Option<usize>,
pub highlight_trailing_whitespace: bool,
pub whitespace_characters: BTreeMap<char, char>,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
pub diff_view_mode: crate::diff::ViewMode,
@@ -93,6 +94,7 @@ impl Default for Config {
labels: BTreeMap::new(),
scroll_distance: Some(0),
highlight_trailing_whitespace: false,
whitespace_characters: BTreeMap::new(),
diff_context_lines: 10,
diff_expand_lines: 15,
diff_view_mode: crate::diff::ViewMode::Split,
@@ -127,6 +129,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
labels,
scroll_distance,
highlight_trailing_whitespace,
whitespace_characters,
diff_context_lines,
diff_expand_lines,
diff_view_mode,
@@ -162,6 +165,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.labels = labels;
config.scroll_distance = scroll_distance;
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
config.whitespace_characters = whitespace_characters;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
config.diff_view_mode = diff_view_mode;
@@ -496,6 +500,8 @@ struct ConfigFrame {
#[serde(deserialize_with = "scroll_distance")]
scroll_distance: Option<usize>,
highlight_trailing_whitespace: bool,
#[serde(deserialize_with = "whitespace_characters")]
whitespace_characters: BTreeMap<char, char>,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
@@ -505,6 +511,30 @@ struct ConfigFrame {
diff_follow_working_copy: bool,
}
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<char, char>, D::Error> {
use unicode_width::UnicodeWidthChar;
let characters = BTreeMap::<char, char>::deserialize(deserializer)?;
for (&whitespace, &marker) in &characters {
if !whitespace.is_whitespace()
|| (whitespace != '\t'
&& (whitespace.is_control() || whitespace.width().unwrap_or(0) == 0))
{
return Err(serde::de::Error::custom(
"whitespace_characters keys must be visible-width whitespace characters or tabs",
));
}
if marker.is_control() || marker.width() != Some(1) {
return Err(serde::de::Error::custom(
"whitespace_characters values must be printable single-cell characters",
));
}
}
Ok(characters)
}
#[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
enum Response {
@@ -1239,9 +1269,9 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
@@ -1319,6 +1349,7 @@ end
"git_gutter": "bar",
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1353,6 +1384,42 @@ end
}
}
#[test]
fn whitespace_characters_validate_real_ruby_configuration() {
let fixture = Fixture::new();
for value in [
"nil",
"false",
"[]",
r#"{ space: "·" }"#,
r#"{ " " => 1 }"#,
r#"{ "" => "." }"#,
r#"{ " " => "." }"#,
r#"{ " " => ".." }"#,
r#"{ "x" => "." }"#,
r#"{ "\n" => "." }"#,
r#"{ "\r" => "." }"#,
r#"{ "\u200b" => "." }"#,
r#"{ " " => "\t" }"#,
r#"{ " " => "\u0301" }"#,
r#"{ " " => "界" }"#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.whitespace_characters = {value} }}"),
);
assert!(load_config(&path).is_err(), "{value}");
}
let path = fixture.file(
"config.rb",
r#"Vamp.configure { |c| c.whitespace_characters = { " " => "·", "\t" => "→", "\u00a0" => "⍽", "\u3000" => "_" } }"#,
);
assert_eq!(
load_config(&path).unwrap().whitespace_characters,
BTreeMap::from([(' ', '·'), ('\t', '→'), ('\u{a0}', '⍽'), ('\u{3000}', '_')]),
);
}
#[test]
fn working_diff_tracking_rejects_non_boolean_configuration() {
let fixture = Fixture::new();
@@ -1587,6 +1654,7 @@ end
"top_bar": {}, "bottom_bar": {}, "labels": {},
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1636,6 +1704,7 @@ end
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"diff_context_lines": 10, "diff_expand_lines": 15,
@@ -2065,6 +2134,7 @@ end"#,
"git_gutter": "bar",
"scroll_distance": 0,
"highlight_trailing_whitespace": false,
"whitespace_characters": {},
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,