use crate::common::harness::EditorTestHarness;
use crossterm::event::{KeyCode, KeyModifiers};
fn create_body_logging_lsp_script(dir: &std::path::Path) -> std::path::PathBuf {
let script = r#"#!/bin/bash
# Log file path (passed as first argument)
LOG_FILE="${1:-/tmp/fake_lsp_body_log.txt}"
# Clear log file at start
> "$LOG_FILE"
# Function to read a message
read_message() {
local content_length=0
while IFS= read -r line; do
# Strip carriage return (LSP uses CRLF line endings)
line="${line%$'\r'}"
# Empty line marks end of headers
if [ -z "$line" ]; then
break
fi
# Parse "Key: Value" header
case "$line" in
Content-Length:*)
content_length="${line#Content-Length:}"
content_length="${content_length// /}"
;;
esac
done
# Read content
if [ "$content_length" -gt 0 ] 2>/dev/null; then
dd bs=1 count="$content_length" 2>/dev/null
fi
}
# Function to send a message
send_message() {
local message="$1"
local length=${#message}
printf "Content-Length: %d\r\n\r\n%s" "$length" "$message"
}
# Main loop
while true; do
# Read incoming message
msg=$(read_message)
if [ -z "$msg" ]; then
break
fi
# Extract method from JSON
method=$(echo "$msg" | grep -o '"method":"[^"]*"' | cut -d'"' -f4)
msg_id=$(echo "$msg" | grep -o '"id":[0-9]*' | cut -d':' -f2)
# Log method and full message body for didOpen and didChange
case "$method" in
"textDocument/didOpen"|"textDocument/didChange"|"textDocument/didClose")
echo "METHOD:$method" >> "$LOG_FILE"
echo "BODY:$msg" >> "$LOG_FILE"
echo "---" >> "$LOG_FILE"
;;
*)
if [ -n "$method" ]; then
echo "METHOD:$method" >> "$LOG_FILE"
echo "---" >> "$LOG_FILE"
fi
;;
esac
case "$method" in
"initialize")
send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":{"capabilities":{"completionProvider":{"triggerCharacters":["."]},"textDocumentSync":2}}}'
;;
"textDocument/didOpen"|"textDocument/didChange"|"textDocument/didSave"|"textDocument/didClose"|"initialized")
# Notifications - no response needed
;;
"textDocument/diagnostic")
send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":{"items":[]}}'
;;
"textDocument/inlayHint")
send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":[]}'
;;
"textDocument/completion")
send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":[]}'
;;
"$/cancelRequest")
# Cancel requests are notifications - no response needed
;;
"shutdown")
send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":null}'
break
;;
*)
# Respond to any unhandled requests to prevent pending request buildup
if [ -n "$msg_id" ]; then
send_message '{"jsonrpc":"2.0","id":'$msg_id',"result":null}'
fi
;;
esac
done
"#;
let script_path = dir.join("fake_lsp_body_logging.sh");
std::fs::write(&script_path, script).expect("Failed to write fake LSP script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script_path)
.expect("Failed to get script metadata")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script_path, perms).expect("Failed to set script permissions");
}
script_path
}
#[test]
#[cfg_attr(target_os = "windows", ignore)] fn test_lsp_toggle_off_edit_toggle_on_causes_desync() -> anyhow::Result<()> {
let _ = tracing_subscriber::fmt()
.with_env_filter("fresh=debug")
.try_init();
let temp_dir = tempfile::tempdir()?;
let script_path = create_body_logging_lsp_script(temp_dir.path());
let log_file = temp_dir.path().join("lsp_toggle_desync_log.txt");
let test_file = temp_dir.path().join("test.rs");
let initial_content = "fn main() {\n let x = 5;\n}\n";
std::fs::write(&test_file, initial_content)?;
let mut config = fresh::config::Config::default();
config.lsp.insert(
"rust".to_string(),
fresh::types::LspLanguageConfig::Multi(vec![fresh::services::lsp::LspServerConfig {
command: script_path.to_string_lossy().to_string(),
args: vec![log_file.to_string_lossy().to_string()],
enabled: true,
auto_start: true,
process_limits: fresh::services::process_limits::ProcessLimits::default(),
initialization_options: None,
env: Default::default(),
language_id_overrides: Default::default(),
root_markers: Default::default(),
name: None,
only_features: None,
except_features: None,
}]),
);
config.keybindings.push(fresh::config::Keybinding {
key: "t".to_string(),
modifiers: vec!["alt".to_string()],
keys: vec![],
action: "lsp_toggle_for_buffer".to_string(),
args: std::collections::HashMap::new(),
when: None,
});
let mut harness = EditorTestHarness::create(
120,
30,
crate::common::harness::HarnessOptions::new()
.with_config(config)
.with_working_dir(temp_dir.path().to_path_buf()),
)?;
harness.open_file(&test_file)?;
harness.render()?;
harness.wait_until(|_| {
let log = std::fs::read_to_string(&log_file).unwrap_or_default();
log.contains("METHOD:textDocument/didOpen")
})?;
harness.type_text("abc")?;
harness.wait_until(|_| {
let log = std::fs::read_to_string(&log_file).unwrap_or_default();
log.contains("METHOD:textDocument/didChange")
})?;
harness.send_key(KeyCode::Char('t'), KeyModifiers::ALT)?;
harness.render()?;
harness.wait_until(|_| {
let log = std::fs::read_to_string(&log_file).unwrap_or_default();
log.contains("METHOD:textDocument/didClose")
})?;
harness.type_text("XYZ")?;
harness.render()?;
harness.send_key(KeyCode::Char('t'), KeyModifiers::ALT)?;
harness.render()?;
harness.wait_until(|_| {
let log = std::fs::read_to_string(&log_file).unwrap_or_default();
log.matches("METHOD:textDocument/didOpen").count() >= 2
})?;
let final_log = std::fs::read_to_string(&log_file).unwrap_or_default();
eprintln!("[TEST] Final LSP log:\n{}", final_log);
let did_open_count = final_log.matches("METHOD:textDocument/didOpen").count();
assert_eq!(
did_open_count, 2,
"Expected 2 didOpen messages (initial open + re-open after toggle). Got {}.",
did_open_count
);
let did_close_count = final_log.matches("METHOD:textDocument/didClose").count();
assert_eq!(
did_close_count, 1,
"Expected 1 didClose message when toggling LSP off. Got {}.",
did_close_count
);
Ok(())
}
#[test]
#[cfg_attr(target_os = "windows", ignore)] fn test_lsp_toggle_off_sends_did_close() -> anyhow::Result<()> {
let _ = tracing_subscriber::fmt()
.with_env_filter("fresh=debug")
.try_init();
let temp_dir = tempfile::tempdir()?;
let script_path = create_body_logging_lsp_script(temp_dir.path());
let log_file = temp_dir.path().join("lsp_toggle_close_log.txt");
let test_file = temp_dir.path().join("test.rs");
std::fs::write(&test_file, "fn main() {}\n")?;
let mut config = fresh::config::Config::default();
config.lsp.insert(
"rust".to_string(),
fresh::types::LspLanguageConfig::Multi(vec![fresh::services::lsp::LspServerConfig {
command: script_path.to_string_lossy().to_string(),
args: vec![log_file.to_string_lossy().to_string()],
enabled: true,
auto_start: true,
process_limits: fresh::services::process_limits::ProcessLimits::default(),
initialization_options: None,
env: Default::default(),
language_id_overrides: Default::default(),
root_markers: Default::default(),
name: None,
only_features: None,
except_features: None,
}]),
);
config.keybindings.push(fresh::config::Keybinding {
key: "t".to_string(),
modifiers: vec!["alt".to_string()],
keys: vec![],
action: "lsp_toggle_for_buffer".to_string(),
args: std::collections::HashMap::new(),
when: None,
});
let mut harness = EditorTestHarness::create(
120,
30,
crate::common::harness::HarnessOptions::new()
.with_config(config)
.with_working_dir(temp_dir.path().to_path_buf()),
)?;
harness.open_file(&test_file)?;
harness.render()?;
harness.wait_until(|_| {
let log = std::fs::read_to_string(&log_file).unwrap_or_default();
log.contains("METHOD:textDocument/didOpen")
})?;
harness.send_key(KeyCode::Char('t'), KeyModifiers::ALT)?;
harness.render()?;
harness.wait_until(|_| {
let log = std::fs::read_to_string(&log_file).unwrap_or_default();
log.contains("METHOD:textDocument/didClose")
})?;
let log = std::fs::read_to_string(&log_file).unwrap_or_default();
eprintln!("[TEST] LSP log after toggle off:\n{}", log);
let did_close_count = log.matches("METHOD:textDocument/didClose").count();
assert_eq!(
did_close_count, 1,
"Expected 1 didClose message when toggling LSP off. Got {}.",
did_close_count
);
Ok(())
}