fix: surface rejected configuration in a floating startup diagnostic

This commit is contained in:
2026-10-09 19:49:50 +02:00
parent de6b4d47f3
commit 788638f34e
6 changed files with 301 additions and 129 deletions
+52
View File
@@ -62,6 +62,13 @@ pub struct Palette {
pub selected: usize,
}
pub struct StartupDiagnostic {
pub lines: Vec<String>,
pub top: usize,
pub left: usize,
max_column: usize,
}
pub struct Hover {
text: String,
line_starts: Vec<usize>,
@@ -878,6 +885,7 @@ pub struct Editor {
leader_display: String,
palette: Option<Palette>,
hover: Option<Hover>,
startup_diagnostic: Option<StartupDiagnostic>,
project_root: PathBuf,
file_picker: Option<FilePicker>,
file_explorer_side: DockSide,
@@ -936,6 +944,7 @@ impl Editor {
leader_display: String::new(),
palette: None,
hover: None,
startup_diagnostic: None,
project_root: std::env::current_dir()?,
file_picker: None,
file_explorer_side: DockSide::Right,
@@ -1217,6 +1226,25 @@ impl Editor {
self.palette.as_ref()
}
pub fn startup_diagnostic(&self) -> Option<&StartupDiagnostic> {
self.startup_diagnostic.as_ref()
}
pub(crate) fn show_startup_diagnostic(&mut self, text: String) {
let lines = split_lines(&text);
let max_column = lines
.iter()
.map(|line| line.graphemes(true).count())
.max()
.unwrap_or(0);
self.startup_diagnostic = Some(StartupDiagnostic {
lines,
top: 0,
left: 0,
max_column,
});
}
/// Borrowed popup state, visible only while its source remains current.
pub fn hover(&self) -> Option<&Hover> {
self.hover.as_ref().filter(|hover| {
@@ -1981,6 +2009,30 @@ impl Editor {
Input::Ctrl(ch) => control_key(ch),
input => input,
};
if let Some(diagnostic) = &mut self.startup_diagnostic {
match input {
Input::Enter | Input::Escape => self.startup_diagnostic = None,
Input::Up | Input::Ctrl('k') => diagnostic.top = diagnostic.top.saturating_sub(1),
Input::Down | Input::Ctrl('j') => {
diagnostic.top = diagnostic
.top
.saturating_add(1)
.min(diagnostic.lines.len() - 1)
}
Input::Left | Input::Ctrl('h') => {
diagnostic.left = diagnostic.left.saturating_sub(1)
}
Input::Right | Input::Ctrl('l') => {
diagnostic.left = diagnostic.left.saturating_add(1).min(diagnostic.max_column)
}
Input::Home => {
diagnostic.top = 0;
diagnostic.left = 0;
}
_ => {}
}
return None;
}
if self.handle_hover(&input) {
return None;
}
-7
View File
@@ -1,5 +1,4 @@
use crate::lsp::ServerConfig;
use serde::Deserialize;
use serde_json::Value;
pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
@@ -81,12 +80,6 @@ pub fn resolve(value: Value) -> Result<Vec<ServerConfig>, String> {
Ok(servers)
}
pub(crate) fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<ServerConfig>, D::Error> {
resolve(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom)
}
fn command(value: Value, language: &str) -> Result<Vec<String>, String> {
let error = || {
format!(
+70 -20
View File
@@ -95,7 +95,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
});
}
config.bindings = bindings;
config.lsp = lsp;
config.lsp =
crate::lsp_config::resolve(lsp).map_err(|error| format!("Ruby config: {error}"))?;
config.leader = leader;
config.file_explorer_side = file_explorer_side;
config.file_picker_view = file_picker_view;
@@ -379,8 +380,9 @@ enum Response {
Config {
version: u8,
plugins: Vec<PluginConfig>,
#[serde(deserialize_with = "crate::lsp_config::deserialize")]
lsp: Vec<crate::lsp::ServerConfig>,
// Keep this wire field required; resolve its configuration after decoding.
#[serde(deserialize_with = "serde_json::Value::deserialize")]
lsp: serde_json::Value,
bindings: Vec<Binding>,
leader: String,
file_explorer_side: String,
@@ -1085,33 +1087,40 @@ end"#,
}
let path = fixture.file(
"invalid.rb",
r#"Vamp.configure { |config| config.lsp["unlisted"] = true }"#,
r#"Vamp.configure { |config| config.lsp["ruby"] = true }"#,
);
let error = load_config(&path).unwrap_err();
assert!(!error.contains("Malformed Ruby protocol frame"), "{error}");
assert!(
error.contains("unlisted") && error.contains("preset"),
error.contains("\"ruby\"") && error.contains("command") && error.contains("extensions"),
"{error}"
);
}
#[test]
fn lsp_wire_configuration_is_validated_before_being_consumed() {
fn lsp_wire_configuration_is_resolved_after_decoding() {
let mut frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system"
});
assert!(matches!(
serde_json::from_value::<Response>(frame.clone()).unwrap(),
Response::Config { lsp, .. } if lsp.is_empty()
));
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).unwrap().is_empty());
frame["lsp"] = serde_json::json!({"rust": true});
assert!(matches!(
serde_json::from_value::<Response>(frame.clone()).unwrap(),
Response::Config { lsp, .. }
if lsp.len() == 1 && lsp[0].command == ["rust-analyzer"]
));
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert_eq!(
crate::lsp_config::resolve(lsp).unwrap()[0].command,
["rust-analyzer"]
);
for value in [
serde_json::json!(null),
serde_json::json!([]),
@@ -1127,10 +1136,12 @@ end"#,
serde_json::json!({"rust": {"unexpected": true}}),
] {
frame["lsp"] = value;
assert!(
serde_json::from_value::<Response>(frame.clone()).is_err(),
"{frame}"
);
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).is_err(), "{frame}");
}
let languages: serde_json::Map<String, serde_json::Value> = (0..17)
.map(|index| {
@@ -1141,11 +1152,50 @@ end"#,
})
.collect();
frame["lsp"] = serde_json::Value::Object(languages);
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
let Response::Config { lsp, .. } =
serde_json::from_value::<Response>(frame.clone()).unwrap()
else {
panic!("expected config frame");
};
assert!(crate::lsp_config::resolve(lsp).is_err());
frame.as_object_mut().unwrap().remove("lsp");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn malformed_config_wire_remains_a_protocol_error() {
let fixture = Fixture::new();
for mutation in [
"return '['",
"frame.delete('lsp')",
"frame['leader'] = 7",
"frame['unexpected'] = true",
] {
let source = format!(
r#"
Vamp.configure {{ |config| config.lsp["ruby"] = true }}
module JSON
class << self
alias_method :original_generate, :generate
def generate(frame)
if frame['type'] == 'config'
{mutation}
end
original_generate(frame)
end
end
end
"#
);
let path = fixture.file("config.rb", &source);
let error = load_config(&path).unwrap_err();
assert!(
error.starts_with("Malformed Ruby protocol frame: "),
"{mutation}: {error}"
);
}
}
#[test]
fn crash_hang_callback_failure_and_fresh_host_recovery() {
let fixture = Fixture::new();
+66 -21
View File
@@ -46,8 +46,8 @@ pub struct EditorSession {
}
impl EditorSession {
/// File errors fail opening; config and plugin errors leave core editing
/// available and are reported in the editor's message.
/// File errors fail opening; rejected configuration leaves core editing
/// available and presents a persistent startup diagnostic.
pub fn open(
file: Option<PathBuf>,
capabilities: Vec<String>,
@@ -57,7 +57,10 @@ impl EditorSession {
let mut notices = Vec::new();
let config = match config {
Some(path) => load_config(path).unwrap_or_else(|error| {
notices.push(format!("config disabled: {error}"));
editor.show_startup_diagnostic(format!(
"{}\n\nConfiguration was rejected; using default settings and bindings.\n\n{error}",
path.display()
));
Config::default()
}),
None => Config::default(),
@@ -2317,7 +2320,17 @@ end
let mut session = fixture.open(&format!(
"Vamp.configure do |config|\n config.leader = \",\"\n config.bind \"i\", nil\n {expression}\nend"
));
assert!(session.editor().message().contains(error), "{expression}");
assert!(
session
.editor()
.startup_diagnostic()
.unwrap()
.lines
.iter()
.any(|line| line.contains(error)),
"{expression}"
);
session.handle(Input::Enter);
session.handle(Input::Char('i'));
session.handle(Input::Char('界'));
session.handle(Input::Escape);
@@ -2338,24 +2351,56 @@ end
}
#[test]
fn broken_config_falls_back_to_core_without_failing_the_session() {
let fixture = Fixture::new();
let mut session = fixture.open("raise 'broken config'");
assert!(
session
fn rejected_config_diagnostic_persists_until_dismissed_and_default_editing_survives() {
for (config, reason, dismiss) in [
("raise 'broken config'", "broken config", Input::Enter),
(
"Vamp.configure { |config| config.lsp['ruby'] = true }",
"ruby",
Input::Escape,
),
] {
let fixture = Fixture::new();
let mut session = fixture.open(config);
let diagnostic = session
.editor()
.message()
.contains("config disabled: Ruby config: RuntimeError: broken config")
);
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Escape);
session.execute("wq");
assert!(session.editor().should_quit());
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"x"
);
.startup_diagnostic()
.expect("config failure must be visible");
let text = diagnostic.lines.join("\n");
assert!(text.contains(&fixture.0.join("config.rb").display().to_string()));
assert!(text.contains(reason));
session.editor.set_message("unrelated background message");
session.poll();
for input in [
Input::Char('i'),
Input::Paste("accidental edit".into()),
Input::Ctrl('p'),
] {
session.handle(input);
}
assert_eq!(
session
.editor()
.startup_diagnostic()
.unwrap()
.lines
.join("\n"),
text
);
assert_eq!(session.editor().mode(), Mode::Normal);
assert_eq!(session.editor().lines(), &[""]);
session.handle(dismiss);
assert!(session.editor().startup_diagnostic().is_none());
session.handle(Input::Char('i'));
session.handle(Input::Char('x'));
session.handle(Input::Escape);
session.execute("wq");
assert!(session.editor().should_quit());
assert_eq!(
fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(),
"x"
);
}
}
fn drain_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) {
+111 -81
View File
@@ -29,7 +29,7 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::{
editor::{Editor, Hover, Input, Mode, Palette},
editor::{Editor, Hover, Input, Mode, Palette, StartupDiagnostic},
files::{DockSide, FilePicker, FilePresentation, FileView},
plugin::{Diagnostic, DiagnosticSeverity},
session::EditorSession,
@@ -605,18 +605,7 @@ fn draw(
}
if let Some(picker) = editor.file_picker() {
if picker.presentation == FilePresentation::Floating {
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(100)),
Constraint::Fill(1),
])
.areas(area);
let [_, panel, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(area.height.min(24)),
Constraint::Fill(1),
])
.areas(column);
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
draw_file_list(frame, picker, panel, editor.file_navigation_hint());
} else if let Some(panel) = dock {
@@ -634,6 +623,71 @@ fn draw(
if let Some(hover) = editor.hover() {
draw_hover(frame, hover, viewport);
}
if let Some(diagnostic) = editor.startup_diagnostic() {
draw_startup_diagnostic(frame, diagnostic, area);
}
}
fn floating_panel(area: Rect) -> Rect {
let [_, column, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(area.width.min(100)),
Constraint::Fill(1),
])
.areas(area);
let [_, panel, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(area.height.min(24)),
Constraint::Fill(1),
])
.areas(column);
panel
}
fn draw_startup_diagnostic(frame: &mut Frame, diagnostic: &StartupDiagnostic, area: Rect) {
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
let bordered = panel.width >= 4 && panel.height >= 4;
let block = Block::default()
.borders(if bordered {
Borders::ALL
} else {
Borders::NONE
})
.border_style(Style::default().fg(Color::Red))
.title(" Configuration error ");
let content = if bordered { block.inner(panel) } else { panel };
if bordered {
frame.render_widget(block, panel);
}
let [text_area, help_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 3)),
])
.areas(content);
let lines: Vec<Line> = diagnostic
.lines
.iter()
.skip(diagnostic.top)
.take(usize::from(text_area.height))
.map(|line| {
let line = display_text(line);
Line::from(visible_line(
&line,
display_column(&line, diagnostic.left),
usize::from(text_area.width),
))
})
.collect();
frame.render_widget(Paragraph::new(lines), text_area);
frame.render_widget(
Paragraph::new("Arrows scroll · Home reset · Enter/Esc continue")
.style(Style::default().fg(Color::DarkGray)),
help_area,
);
if text_area.width > 0 && text_area.height > 0 {
frame.set_cursor_position((text_area.x, text_area.y));
}
}
fn draw_hover(frame: &mut Frame, hover: &Hover, area: Rect) {
@@ -1746,74 +1800,6 @@ end
assert_eq!(viewports.syntax.len(), 1);
}
#[test]
fn highlighted_editor_text_stays_out_of_docked_and_floating_picker_surfaces() {
let directory = Directory::new();
let path = directory.file("panels.rs", &vec!["// note"; 30].join("\n"));
let area = Rect::new(3, 2, 100, 24);
for (name, side) in [
("left", Some(DockSide::Left)),
("right", Some(DockSide::Right)),
("top", Some(DockSide::Top)),
("bottom", Some(DockSide::Bottom)),
("floating", None),
] {
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
if side.is_some() {
editor.set_file_explorer_side(name).unwrap();
}
editor.handle(Input::Char(' '));
editor.handle(Input::Char(if side.is_some() { 'e' } else { 'o' }));
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(110, 30),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, &editor))
.unwrap();
let workspace = Rect::new(area.x, area.y, area.width, area.height - 2);
let (text, panel) = if let Some(side) = side {
let (text, panel) = dock_layout(workspace, side, true);
(text, panel.unwrap())
} else {
(workspace, Rect::new(area.x + 8, area.y + 3, 84, 18))
};
let mut expected = Terminal::with_options(
ratatui::backend::TestBackend::new(110, 30),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(panel),
},
)
.unwrap();
expected
.draw(|frame| {
draw_file_list(
frame,
editor.file_picker().unwrap(),
panel,
editor.file_navigation_hint(),
)
})
.unwrap();
let buffer = terminal.backend().buffer();
let expected = expected.backend().buffer();
for y in panel.y..panel.bottom() {
for x in panel.x..panel.right() {
assert_eq!(buffer[(x, y)], expected[(x, y)], "{name}: {x},{y}");
}
}
assert_eq!(buffer[(text.x + 3, text.y)].symbol(), "/");
assert_eq!(buffer[(text.x + 3, text.y)].fg, Color::DarkGray);
assert_eq!(buffer[(text.x, text.y)].fg, Color::Cyan);
assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan);
assert_eq!(buffer[(0, 0)].fg, Color::Reset);
}
}
#[test]
fn translate_distinguishes_control_j_from_enter_and_normalizes_control_letters() {
for (code, modifiers, expected) in [
@@ -2784,6 +2770,50 @@ end
assert_eq!(buffer[(0, 0)].symbol(), " ");
}
#[test]
fn startup_diagnostic_scrolls_long_unicode_reasons_and_dismisses_without_editing() {
let directory = Directory::new();
let source = directory.file("buffer.txt", "keep");
let config = directory.file("config.rb", "raise \"#{'x' * 160}界END\\nlast reason\"");
let mut session =
EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(110, 26)).unwrap();
let mut viewports = BufferViewports::default();
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let text = screen_text(&terminal);
assert!(text.contains("Configuration error"));
assert!(text.contains("config.rb"));
assert!(text.contains("default settings and bindings"));
assert!(text.contains("last reason"));
assert!(!text.contains("界END"));
for _ in 0..100 {
session.handle(Input::Right);
}
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
let scrolled = screen_text(&terminal);
assert!(
scrolled.contains("界") && scrolled.contains("END"),
"{scrolled}"
);
for (width, height) in [(4, 4), (1, 1)] {
let mut tiny =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
tiny.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
}
session.handle(Input::Enter);
terminal
.draw(|frame| viewports.draw(frame, session.editor()))
.unwrap();
assert!(!screen_text(&terminal).contains("Configuration error"));
assert_eq!(session.editor().lines(), &["keep"]);
assert_eq!(session.editor().cursor(), crate::editor::Cursor::default());
}
#[test]
fn picker_conversion_focus_palette_and_closure_preserve_buffer_viewport_cache() {
let directory = Directory::new();