feat: configure spaces on Tab per language
This commit is contained in:
+35
-1
@@ -13,6 +13,7 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::fmt;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::num::NonZeroU8;
|
||||
use std::ops::Range;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
@@ -1778,6 +1779,8 @@ pub struct Editor {
|
||||
highlight_trailing_whitespace: bool,
|
||||
whitespace_characters: BTreeMap<char, char>,
|
||||
whitespace_scope: WhitespaceScope,
|
||||
tab_spaces: Option<NonZeroU8>,
|
||||
language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||||
battery_charge: Option<u8>,
|
||||
battery_label: String,
|
||||
git_blame: bool,
|
||||
@@ -1883,6 +1886,8 @@ impl Editor {
|
||||
highlight_trailing_whitespace: false,
|
||||
whitespace_characters: BTreeMap::new(),
|
||||
whitespace_scope: WhitespaceScope::All,
|
||||
tab_spaces: None,
|
||||
language_tab_spaces: BTreeMap::new(),
|
||||
battery_charge: None,
|
||||
battery_label: String::new(),
|
||||
git_blame: true,
|
||||
@@ -2653,6 +2658,15 @@ impl Editor {
|
||||
self.whitespace_scope = scope;
|
||||
}
|
||||
|
||||
pub(crate) fn set_tab_spaces(
|
||||
&mut self,
|
||||
spaces: Option<NonZeroU8>,
|
||||
languages: BTreeMap<String, Option<NonZeroU8>>,
|
||||
) {
|
||||
self.tab_spaces = spaces;
|
||||
self.language_tab_spaces = languages;
|
||||
}
|
||||
|
||||
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
|
||||
self.diff_context_options = (context_lines, expand_lines);
|
||||
if let Some(diff) = &mut self.diff {
|
||||
@@ -8975,6 +8989,25 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn type_native(&mut self, text: &str) {
|
||||
const SPACES: [u8; u8::MAX as usize] = [b' '; u8::MAX as usize];
|
||||
let text = if text == "\t" {
|
||||
let width = if self.language_tab_spaces.is_empty() {
|
||||
self.tab_spaces
|
||||
} else {
|
||||
crate::syntax::language_name(
|
||||
self.active.path.as_deref(),
|
||||
self.active.lines.first().map(String::as_str),
|
||||
)
|
||||
.and_then(|language| self.language_tab_spaces.get(language))
|
||||
.copied()
|
||||
.unwrap_or(self.tab_spaces)
|
||||
};
|
||||
width.map_or(text, |width| {
|
||||
std::str::from_utf8(&SPACES[..usize::from(width.get())]).expect("spaces are UTF-8")
|
||||
})
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if self.edit_snippet(text, None) {
|
||||
return;
|
||||
}
|
||||
@@ -23994,6 +24027,7 @@ mod tests {
|
||||
fn snippet_arguments_navigation_undo_redo_and_dot_use_resolved_text() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
editor.set_tab_spaces(NonZeroU8::new(2), BTreeMap::new());
|
||||
type_text(&mut editor, "ica");
|
||||
editor
|
||||
.show_completion(vec![snippet_item(
|
||||
@@ -24026,7 +24060,7 @@ mod tests {
|
||||
assert_eq!(editor.cursor(), Cursor { row: 0, column: 12 });
|
||||
type_text(&mut editor, ";\n\ttail");
|
||||
editor.handle(Input::Escape);
|
||||
let resolved = "call(foo, 5);\n\ttail";
|
||||
let resolved = "call(foo, 5);\n tail";
|
||||
let caret = editor.cursor();
|
||||
assert_eq!(editor.context().text, resolved);
|
||||
editor.handle(Input::Char('u'));
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||
Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
|
||||
Candidates replace text live; Original text restores it; other keys close and act normally
|
||||
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
||||
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
||||
Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal
|
||||
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
|
||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
|
||||
|
||||
+112
-3
@@ -3,6 +3,7 @@ use crate::editor::{Context, Effect, Feature};
|
||||
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::num::NonZeroU8;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
|
||||
@@ -48,6 +49,8 @@ pub struct Config {
|
||||
pub highlight_trailing_whitespace: bool,
|
||||
pub whitespace_characters: BTreeMap<char, char>,
|
||||
pub whitespace_scope: crate::editor::WhitespaceScope,
|
||||
pub tab_spaces: Option<NonZeroU8>,
|
||||
pub language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||||
pub diff_context_lines: usize,
|
||||
pub diff_expand_lines: usize,
|
||||
pub diff_view_mode: crate::diff::ViewMode,
|
||||
@@ -97,6 +100,8 @@ impl Default for Config {
|
||||
highlight_trailing_whitespace: false,
|
||||
whitespace_characters: BTreeMap::new(),
|
||||
whitespace_scope: crate::editor::WhitespaceScope::All,
|
||||
tab_spaces: None,
|
||||
language_tab_spaces: BTreeMap::new(),
|
||||
diff_context_lines: 10,
|
||||
diff_expand_lines: 15,
|
||||
diff_view_mode: crate::diff::ViewMode::Split,
|
||||
@@ -133,6 +138,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
highlight_trailing_whitespace,
|
||||
whitespace_characters,
|
||||
whitespace_scope,
|
||||
tab_spaces,
|
||||
language_tab_spaces,
|
||||
diff_context_lines,
|
||||
diff_expand_lines,
|
||||
diff_view_mode,
|
||||
@@ -170,6 +177,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
config.highlight_trailing_whitespace = highlight_trailing_whitespace;
|
||||
config.whitespace_characters = whitespace_characters;
|
||||
config.whitespace_scope = whitespace_scope;
|
||||
config.tab_spaces = tab_spaces;
|
||||
config.language_tab_spaces = language_tab_spaces;
|
||||
config.diff_context_lines = diff_context_lines;
|
||||
config.diff_expand_lines = diff_expand_lines;
|
||||
config.diff_view_mode = diff_view_mode;
|
||||
@@ -507,6 +516,9 @@ struct ConfigFrame {
|
||||
#[serde(deserialize_with = "whitespace_characters")]
|
||||
whitespace_characters: BTreeMap<char, char>,
|
||||
whitespace_scope: crate::editor::WhitespaceScope,
|
||||
#[serde(deserialize_with = "Option::deserialize")]
|
||||
tab_spaces: Option<NonZeroU8>,
|
||||
language_tab_spaces: BTreeMap<String, Option<NonZeroU8>>,
|
||||
#[serde(deserialize_with = "diff_context_lines")]
|
||||
diff_context_lines: usize,
|
||||
#[serde(deserialize_with = "diff_expand_lines")]
|
||||
@@ -986,6 +998,98 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
|
||||
let fixture = Fixture::new();
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
r#"
|
||||
Vamp.configure do |c|
|
||||
c.tab_spaces = 4
|
||||
c.language_tab_spaces = { "Ruby" => 2, "Makefile" => nil, "JSON" => 1, "YAML" => 255 }
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let widest = " ".repeat(255);
|
||||
for (name, prefix, inserted) in [
|
||||
("default.rs", "", "\t"),
|
||||
("buffer.rs", "", " "),
|
||||
("buffer.rb", "", " "),
|
||||
("Gemfile", "", " "),
|
||||
("script", "#!/usr/bin/env ruby\n", " "),
|
||||
("Makefile", "", "\t"),
|
||||
("plain.txt", "", " "),
|
||||
("buffer.json", "", " "),
|
||||
("buffer.yaml", "", widest.as_str()),
|
||||
] {
|
||||
let source = format!("{prefix}αx\nβy");
|
||||
let path = fixture.file(name, &source);
|
||||
let config = (name != "default.rs").then_some(config.as_path());
|
||||
let mut session = EditorSession::open(Some(path), vec![], config).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
if !prefix.is_empty() {
|
||||
session.handle(Input::Char('j'));
|
||||
}
|
||||
session.handle(Input::Ctrl('e'));
|
||||
for input in [
|
||||
Input::Char('l'),
|
||||
Input::Char('i'),
|
||||
Input::Char('\t'),
|
||||
Input::Ctrl('i'),
|
||||
Input::Escape,
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
let expected = format!("{prefix}α{inserted}{inserted}x\nβ{inserted}{inserted}y");
|
||||
assert_eq!(session.editor().context().text, expected, "{name}");
|
||||
session.handle(Input::Char('u'));
|
||||
assert_eq!(session.editor().context().text, source, "{name}");
|
||||
session.handle(Input::Char('U'));
|
||||
assert_eq!(session.editor().context().text, expected, "{name}");
|
||||
session.handle(Input::Char('u'));
|
||||
session.handle(Input::Char('.'));
|
||||
assert_eq!(session.editor().context().text, expected, "{name}");
|
||||
session.handle(Input::Char('i'));
|
||||
session.handle(Input::End);
|
||||
session.handle(Input::Paste("\t".into()));
|
||||
assert_eq!(
|
||||
session.editor().context().text,
|
||||
format!("{prefix}α{inserted}{inserted}x\t\nβ{inserted}{inserted}y\t"),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_spaces_reject_invalid_widths_and_language_maps() {
|
||||
let fixture = Fixture::new();
|
||||
for value in ["0", "-1", "256", "1.5", "true", r#""4""#, "[]", "{}"] {
|
||||
for assignment in [
|
||||
format!("c.tab_spaces = {value}"),
|
||||
format!(r#"c.language_tab_spaces = {{ "Ruby" => {value} }}"#),
|
||||
] {
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |c| {assignment} }}"),
|
||||
);
|
||||
assert!(load_config(&config).is_err(), "{assignment}");
|
||||
}
|
||||
}
|
||||
for value in [
|
||||
"nil",
|
||||
"[]",
|
||||
r#"{ "" => 2 }"#,
|
||||
r#"{ :Ruby => 2 }"#,
|
||||
r#"{ "\n" => 2 }"#,
|
||||
] {
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |c| c.language_tab_spaces = {value} }}"),
|
||||
);
|
||||
assert!(load_config(&config).is_err(), "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_ruby_dsl_context_queued_effects_and_output_isolation() {
|
||||
let fixture = Fixture::new();
|
||||
@@ -1274,9 +1378,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,"whitespace_characters":{},"whitespace_scope":"all","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":{},"whitespace_scope":"all","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":{},"whitespace_scope":"all","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":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"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":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"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":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"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}");
|
||||
}
|
||||
@@ -1356,6 +1460,7 @@ end
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"tab_spaces": null, "language_tab_spaces": {},
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
@@ -1494,6 +1599,7 @@ end"#,
|
||||
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
|
||||
"git_gutter": "bar", "scroll_distance": 0, "highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {}, "whitespace_scope": "all",
|
||||
"tab_spaces": null, "language_tab_spaces": {},
|
||||
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
|
||||
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
@@ -1755,6 +1861,7 @@ end
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"tab_spaces": null, "language_tab_spaces": {},
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
|
||||
"diff_view_mode": "split", "diff_split_orientation": "vertical",
|
||||
@@ -1806,6 +1913,7 @@ end
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"tab_spaces": null, "language_tab_spaces": {},
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
@@ -2237,6 +2345,7 @@ end"#,
|
||||
"highlight_trailing_whitespace": false,
|
||||
"whitespace_characters": {},
|
||||
"whitespace_scope": "all",
|
||||
"tab_spaces": null, "language_tab_spaces": {},
|
||||
"top_bar": {}, "bottom_bar": {}, "labels": {},
|
||||
"popup": {"command": {}, "search": {}},
|
||||
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
|
||||
|
||||
@@ -145,6 +145,8 @@ module Vamp
|
||||
@highlight_trailing_whitespace = false
|
||||
@whitespace_characters = {}
|
||||
@whitespace_scope = "all"
|
||||
@tab_spaces = nil
|
||||
@language_tab_spaces = {}
|
||||
@diff_context_lines = 10
|
||||
@diff_expand_lines = 15
|
||||
@diff_view_mode = "split"
|
||||
@@ -254,6 +256,25 @@ module Vamp
|
||||
@whitespace_scope = scope
|
||||
end
|
||||
|
||||
# @param spaces [Integer, nil] spaces per Tab, or nil for a literal tab
|
||||
# @return [Integer, nil]
|
||||
def tab_spaces=(spaces)
|
||||
@tab_spaces = tab_spaces!(spaces)
|
||||
end
|
||||
|
||||
# @param languages [Hash{String => Integer, nil}] syntax language names to Tab widths
|
||||
# @return [Hash{String => Integer, nil}]
|
||||
def language_tab_spaces=(languages)
|
||||
raise TypeError, "language_tab_spaces must be a Hash" unless languages.is_a?(Hash)
|
||||
|
||||
@language_tab_spaces = languages.to_h do |language, spaces|
|
||||
language = nonempty_string!(language, "Tab language")
|
||||
raise ArgumentError, "Tab language must not contain control characters" if language.match?(/[[:cntrl:]]/)
|
||||
|
||||
[language, tab_spaces!(spaces)]
|
||||
end
|
||||
end
|
||||
|
||||
# @param lines [Integer] nonnegative context lines around each diff change
|
||||
# @return [Integer]
|
||||
def diff_context_lines=(lines)
|
||||
@@ -329,6 +350,7 @@ module Vamp
|
||||
"highlight_trailing_whitespace" => @highlight_trailing_whitespace,
|
||||
"whitespace_characters" => @whitespace_characters,
|
||||
"whitespace_scope" => @whitespace_scope,
|
||||
"tab_spaces" => @tab_spaces, "language_tab_spaces" => @language_tab_spaces,
|
||||
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
|
||||
"popup" => { "command" => @popup.command, "search" => @popup.search },
|
||||
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
|
||||
@@ -337,6 +359,16 @@ module Vamp
|
||||
end
|
||||
|
||||
private
|
||||
# @param spaces [Integer, nil]
|
||||
# @return [Integer, nil]
|
||||
def tab_spaces!(spaces)
|
||||
unless spaces.nil? || (spaces.is_a?(Integer) && spaces.between?(1, 255))
|
||||
raise ArgumentError, "tab_spaces must be nil or an Integer between 1 and 255"
|
||||
end
|
||||
|
||||
spaces
|
||||
end
|
||||
|
||||
# @param value [Integer]
|
||||
# @param label [String]
|
||||
# @param minimum [Integer]
|
||||
|
||||
@@ -260,6 +260,7 @@ impl EditorSession {
|
||||
editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace);
|
||||
editor.set_whitespace_characters(config.whitespace_characters);
|
||||
editor.set_whitespace_scope(config.whitespace_scope);
|
||||
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
|
||||
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
|
||||
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
|
||||
if let Err(error) =
|
||||
|
||||
@@ -13,6 +13,10 @@ pub(crate) fn is_markdown_path(path: &Path) -> bool {
|
||||
languages::detect(Some(path), None).is_some_and(|syntax| syntax.name == "Markdown")
|
||||
}
|
||||
|
||||
pub(crate) fn language_name(path: Option<&Path>, first_line: Option<&str>) -> Option<&'static str> {
|
||||
languages::detect(path, first_line).map(|syntax| syntax.name)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SyntaxKind {
|
||||
Comment,
|
||||
|
||||
Reference in New Issue
Block a user