use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use pasta_dsl::parser::parse_str;
use pasta_lua::debug::source_map::canonicalize_chunk_name;
use pasta_lua::loader::CacheManager;
use pasta_lua::{LuaTranspiler, PastaLoader, RuntimeConfig, SourceMode};
const WATCHDOG: Duration = Duration::from_secs(15);
const FIXTURE: &str = include_str!("../fixtures/debug_toggle_e2e.pasta");
const BP_PASTA_LINE: u32 = 4;
struct DapClient {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl DapClient {
fn connect(addr: SocketAddr) -> Self {
let stream = TcpStream::connect(addr).expect("client must connect to the bound port");
stream
.set_read_timeout(Some(WATCHDOG))
.expect("TEST-ONLY read timeout");
let writer = stream.try_clone().expect("clone socket for writing");
Self {
reader: BufReader::new(stream),
writer,
}
}
fn send_request(&mut self, seq: u64, command: &str, arguments: Value) {
let req = json!({
"seq": seq,
"type": "request",
"command": command,
"arguments": arguments,
});
write_frame(&mut self.writer, &req).expect("client write must succeed");
}
fn recv(&mut self) -> Value {
read_frame(&mut self.reader)
.expect("client read must succeed (TEST-ONLY timeout)")
.expect("a frame must be present (peer did not close)")
}
fn recv_until(&mut self, mut pred: impl FnMut(&Value) -> bool) -> Value {
loop {
let msg = self.recv();
if pred(&msg) {
return msg;
}
}
}
}
fn write_frame<W: Write>(out: &mut W, value: &Value) -> std::io::Result<()> {
let body = serde_json::to_vec(value)?;
write!(out, "Content-Length: {}\r\n\r\n", body.len())?;
out.write_all(&body)?;
out.flush()
}
fn read_frame<R: BufRead>(reader: &mut R) -> std::io::Result<Option<Value>> {
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(None);
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
break;
}
if let Some((name, val)) = trimmed.split_once(':')
&& name.trim().eq_ignore_ascii_case("Content-Length")
{
content_length = val.trim().parse::<usize>().ok();
}
}
let len = content_length.expect("framed message must carry a Content-Length");
let mut body = vec![0u8; len];
std::io::Read::read_exact(reader, &mut body)?;
let value = serde_json::from_slice(&body)?;
Ok(Some(value))
}
fn is_event(msg: &Value, name: &str) -> bool {
msg["type"] == "event" && msg["event"] == name
}
fn is_response(msg: &Value, command: &str) -> bool {
msg["type"] == "response" && msg["command"] == command
}
fn assert_pasta_source(frame: &Value, expect_pasta_file: &str, ctx: &str) {
let got = frame["source"]["path"].as_str().expect("`.pasta` 提示 source path");
assert_eq!(
canonicalize_chunk_name(got),
canonicalize_chunk_name(expect_pasta_file),
"{ctx}: トップフレーム source は `.pasta` ファイル (got {got})"
);
}
fn make_base_dir(base: &Path) -> PathBuf {
make_base_dir_with(base, None)
}
fn make_base_dir_with(base: &Path, present_as: Option<&str>) -> PathBuf {
let pasta_file = base.join("dic/test/debug_toggle_e2e.pasta");
std::fs::create_dir_all(pasta_file.parent().unwrap()).unwrap();
std::fs::write(&pasta_file, FIXTURE).unwrap();
let present_as_line = match present_as {
Some(mode) => format!("present_as = \"{mode}\"\n"),
None => String::new(),
};
std::fs::write(
base.join("pasta.toml"),
format!(
"\
[loader]
debug_mode = true
[debug]
enabled = true
port = 0
{present_as_line}"
),
)
.unwrap();
let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for sub in ["pasta_scripts", "scriptlibs"] {
let src = crate_root.join(sub);
let dst = base.join(sub);
if src.exists() {
std::fs::create_dir_all(&dst).unwrap();
copy_dir(&src, &dst).unwrap();
}
}
pasta_file
}
fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest = dst.join(entry.file_name());
if path.is_dir() {
if entry.file_name() == "profile" {
continue;
}
std::fs::create_dir_all(&dest)?;
copy_dir(&path, &dest)?;
} else {
std::fs::copy(&path, &dest)?;
}
}
Ok(())
}
fn transpile_fixture(file: &Path) -> String {
let parsed = parse_str(FIXTURE, &file.to_string_lossy()).expect("fixture must parse");
let transpiler = LuaTranspiler::default();
let mut out = Vec::new();
transpiler
.transpile(&parsed, &mut out)
.expect("fixture must transpile");
String::from_utf8(out).expect("generated lua is valid utf-8")
}
#[test]
fn pasta_breakpoint_toggle_lua_then_pasta_over_tcp() {
let temp = tempfile::TempDir::new().expect("temp dir");
let base = temp.path().to_path_buf();
let pasta_file = make_base_dir(&base);
let cache_manager = CacheManager::new(base.clone(), "profile/pasta/cache/lua");
let chunk = cache_manager
.source_to_cache_path(&pasta_file)
.to_string_lossy()
.to_string();
let pasta_file_key = pasta_file.to_string_lossy().to_string();
let expect_map = PastaLoader::build_source_map(std::slice::from_ref(&pasta_file), &cache_manager, false);
let bp_lua_coords = expect_map.resolve_pasta_to_lua(&pasta_file_key, BP_PASTA_LINE);
assert_eq!(
bp_lua_coords.len(),
1,
"fixture invariant: BP `.pasta` 行 {BP_PASTA_LINE} は単一の `.lua` 実行座標へ一意対応する \
(top-level に実行される行), got {bp_lua_coords:?}"
);
let (bp_chunk, bp_lua_line) = bp_lua_coords[0].clone();
assert_eq!(
canonicalize_chunk_name(&bp_chunk),
canonicalize_chunk_name(&chunk),
"BP の `.lua` 実行座標は当該チャンクを指す"
);
let generated_lua = transpile_fixture(&pasta_file);
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let chunk_for_host = chunk.clone();
let base_for_host = base.clone();
let generated_for_host = generated_lua.clone();
let host = std::thread::spawn(move || -> Result<(), String> {
let runtime = PastaLoader::load_with_config(&base_for_host, RuntimeConfig::new())
.map_err(|e| format!("loader must build an enabled-debug runtime: {e}"))?;
if !runtime.debug_enabled() {
return Err("enabled [debug] must install the backend".to_string());
}
if runtime.debug_source_map().is_none() {
return Err("enabled debug runtime must hold the aggregated source map".to_string());
}
match runtime.debug_source_mode() {
Some(SourceMode::Pasta) => {}
other => {
return Err(format!(
"initial resolved mode must default to `.pasta` (env override not set in CI): {other:?}"
));
}
}
let addr = runtime
.debug_local_addr()
.ok_or_else(|| "enabled runtime must expose a bound debug addr (port 0)".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "no go signal before exec #1".to_string())?;
runtime
.exec_named(&generated_for_host, &chunk_for_host)
.map_err(|e| format!("exec #1 failed: {e}"))?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "no go signal before exec #2".to_string())?;
runtime
.exec_named(&generated_for_host, &chunk_for_host)
.map_err(|e| format!("exec #2 failed: {e}"))?;
drop(runtime); Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let _ = client.recv_until(|m| is_response(m, "initialize"));
let _ = client.recv_until(|m| is_event(m, "initialized"));
client.send_request(
2,
"setBreakpoints",
json!({
"source": { "path": pasta_file_key },
"breakpoints": [{ "line": BP_PASTA_LINE }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"].as_array().expect("breakpoints array");
assert_eq!(bps.len(), 1, "exactly one breakpoint resolved");
assert_eq!(
bps[0]["verified"], true,
"7.3/6.3: `.pasta` 行 BP は検証済み(`.lua` 実行座標へ翻訳・登録された)"
);
assert_eq!(
bps[0]["line"], BP_PASTA_LINE,
"`.pasta` 行 BP は元の `.pasta` 行で報告される"
);
client.send_request(3, "configurationDone", json!({}));
let _ = client.recv_until(|m| is_response(m, "configurationDone"));
go_tx.send(()).expect("go #1");
let stopped1 = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped1["body"]["reason"], "breakpoint",
"exec #1 は `.pasta` 行 BP で停止する"
);
let thread_id = stopped1["body"]["threadId"].as_u64().unwrap_or(1);
client.send_request(10, "stackTrace", json!({ "threadId": thread_id }));
let stack_pasta0 = client.recv_until(|m| is_response(m, "stackTrace"));
let frames0 = stack_pasta0["body"]["stackFrames"].as_array().expect("stackFrames");
assert!(!frames0.is_empty(), "停止フレームが存在する");
assert_pasta_source(&frames0[0], &pasta_file_key, "初期 `.pasta` 提示");
assert_eq!(
frames0[0]["line"], BP_PASTA_LINE,
"初期 `.pasta` 提示: トップフレーム行は `.pasta` 行 {BP_PASTA_LINE}"
);
client.send_request(20, "pasta/sourcePresentation", json!({ "mode": "lua" }));
let toggle_resp_lua = client.recv_until(|m| is_response(m, "pasta/sourcePresentation"));
assert_eq!(toggle_resp_lua["request_seq"], 20, "受理レスポンスは要求 seq に対応");
assert_eq!(
toggle_resp_lua["body"]["mode"], "lua",
"7.1: 受理レスポンスは適用後モード `lua` をエコーする"
);
let toggle_event_lua =
client.recv_until(|m| is_event(m, "pasta/sourcePresentation") && m["body"]["mode"] == "lua");
assert_eq!(
toggle_event_lua["body"]["mode"], "lua",
"7.1: 切替後モードのカスタムイベントが送出される"
);
let restopped_lua = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
restopped_lua["body"]["reason"], "breakpoint",
"3.3: 切替後、現停止が再送され再描画が起動する"
);
client.send_request(21, "stackTrace", json!({ "threadId": thread_id }));
let stack_lua = client.recv_until(|m| is_response(m, "stackTrace"));
let frames_lua = stack_lua["body"]["stackFrames"].as_array().expect("stackFrames");
assert!(!frames_lua.is_empty(), "切替後も停止フレームが存在する");
let top_lua_path = frames_lua[0]["source"]["path"]
.as_str()
.expect("`.lua` 提示 source path");
assert_eq!(
canonicalize_chunk_name(top_lua_path),
canonicalize_chunk_name(&chunk),
"7.1/3.4: `.lua` 提示: トップフレーム source は生成 `.lua` チャンク (got {top_lua_path})"
);
assert_eq!(
frames_lua[0]["line"].as_u64().expect("lua line"),
bp_lua_line as u64,
"7.1/3.4: `.lua` 提示: トップフレーム行は生成 `.lua` 実行行 {bp_lua_line}"
);
client.send_request(30, "pasta/sourcePresentation", json!({ "mode": "pasta" }));
let toggle_resp_pasta = client.recv_until(|m| is_response(m, "pasta/sourcePresentation"));
assert_eq!(
toggle_resp_pasta["body"]["mode"], "pasta",
"7.2: 受理レスポンスは適用後モード `pasta` をエコーする"
);
let _toggle_event_pasta = client
.recv_until(|m| is_event(m, "pasta/sourcePresentation") && m["body"]["mode"] == "pasta");
let _restopped_pasta = client.recv_until(|m| is_event(m, "stopped"));
client.send_request(31, "stackTrace", json!({ "threadId": thread_id }));
let stack_pasta = client.recv_until(|m| is_response(m, "stackTrace"));
let frames_pasta = stack_pasta["body"]["stackFrames"].as_array().expect("stackFrames");
assert!(!frames_pasta.is_empty(), "戻し後も停止フレームが存在する");
assert_pasta_source(&frames_pasta[0], &pasta_file_key, "7.2/3.5: `.pasta` 提示へ復帰");
assert_eq!(
frames_pasta[0]["line"], BP_PASTA_LINE,
"7.2/3.5: `.pasta` 提示へ復帰: トップフレーム行は `.pasta` 行 {BP_PASTA_LINE}"
);
client.send_request(40, "continue", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "continue"));
go_tx.send(()).expect("go #2");
let stopped2 = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped2["body"]["reason"], "breakpoint",
"7.3/6.3: トグルの前後で `.pasta` 行 BP は有効であり続け、再 exec で同じ BP に再停止する"
);
client.send_request(41, "stackTrace", json!({ "threadId": thread_id }));
let stack_after = client.recv_until(|m| is_response(m, "stackTrace"));
let frames_after = stack_after["body"]["stackFrames"].as_array().expect("stackFrames");
assert_pasta_source(&frames_after[0], &pasta_file_key, "7.3: 再停止フレームも `.pasta` 提示");
assert_eq!(
frames_after[0]["line"], BP_PASTA_LINE,
"7.3: 再停止は同じ `.pasta` 行 {BP_PASTA_LINE}"
);
client.send_request(50, "continue", json!({ "threadId": thread_id }));
let _ = client.recv_until(|m| is_response(m, "continue"));
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host thread must not panic")
.expect("both execs must run to completion with the persisted `.pasta` BP");
}
Err(RecvTimeoutError::Timeout) => panic!("host thread did not finish (hang?)"),
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
struct SessionCoords {
base: PathBuf,
pasta_file_key: String,
chunk: String,
bp_lua_line: u32,
generated_lua: String,
}
fn resolve_session(base: &Path, present_as: Option<&str>) -> SessionCoords {
let pasta_file = make_base_dir_with(base, present_as);
let cache_manager = CacheManager::new(base.to_path_buf(), "profile/pasta/cache/lua");
let chunk = cache_manager
.source_to_cache_path(&pasta_file)
.to_string_lossy()
.to_string();
let pasta_file_key = pasta_file.to_string_lossy().to_string();
let expect_map =
PastaLoader::build_source_map(std::slice::from_ref(&pasta_file), &cache_manager, false);
let bp_lua_coords = expect_map.resolve_pasta_to_lua(&pasta_file_key, BP_PASTA_LINE);
assert_eq!(
bp_lua_coords.len(),
1,
"fixture invariant: BP `.pasta` 行 {BP_PASTA_LINE} は単一の `.lua` 実行座標へ一意対応する, got {bp_lua_coords:?}"
);
let (bp_chunk, bp_lua_line) = bp_lua_coords[0].clone();
assert_eq!(
canonicalize_chunk_name(&bp_chunk),
canonicalize_chunk_name(&chunk),
"BP の `.lua` 実行座標は当該チャンクを指す"
);
let generated_lua = transpile_fixture(&pasta_file);
SessionCoords {
base: base.to_path_buf(),
pasta_file_key,
chunk,
bp_lua_line,
generated_lua,
}
}
struct StoppedSession {
client: DapClient,
thread_id: u64,
go_tx: mpsc::Sender<()>,
host: std::thread::JoinHandle<Result<(), String>>,
}
fn start_stopped_session(
coords: &SessionCoords,
attach_source_presentation: Option<&str>,
expected_initial_mode: &str,
) -> StoppedSession {
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let base_for_host = coords.base.clone();
let chunk_for_host = coords.chunk.clone();
let generated_for_host = coords.generated_lua.clone();
let expected_host_mode = expected_initial_mode.to_string();
let attach_present_for_host = attach_source_presentation.is_some();
let host = std::thread::spawn(move || -> Result<(), String> {
let runtime = PastaLoader::load_with_config(&base_for_host, RuntimeConfig::new())
.map_err(|e| format!("loader must build an enabled-debug runtime: {e}"))?;
if !runtime.debug_enabled() {
return Err("enabled [debug] must install the backend".to_string());
}
if runtime.debug_source_map().is_none() {
return Err("enabled debug runtime must hold the aggregated source map".to_string());
}
if !attach_present_for_host {
let baked = runtime.debug_source_mode();
let expect = match expected_host_mode.as_str() {
"lua" => SourceMode::Lua,
_ => SourceMode::Pasta,
};
if baked != Some(expect) {
return Err(format!(
"4.4: attach 引数なしの初期解決モード(env>file>既定)は {expect:?} のはず, got {baked:?}"
));
}
}
let addr = runtime
.debug_local_addr()
.ok_or_else(|| "enabled runtime must expose a bound debug addr (port 0)".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "no go signal before exec #1".to_string())?;
runtime
.exec_named(&generated_for_host, &chunk_for_host)
.map_err(|e| format!("exec #1 failed: {e}"))?;
drop(runtime);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let _ = client.recv_until(|m| is_response(m, "initialize"));
let _ = client.recv_until(|m| is_event(m, "initialized"));
let attach_args = match attach_source_presentation {
Some(mode) => json!({ "sourcePresentation": mode }),
None => json!({}),
};
client.send_request(2, "attach", attach_args);
let _attach_ack = client.recv_until(|m| is_response(m, "attach"));
let attach_event = client.recv_until(|m| is_event(m, "pasta/sourcePresentation"));
assert_eq!(
attach_event["body"]["mode"], expected_initial_mode,
"attach 完了時の push イベントは初期解決モード {expected_initial_mode} を報告する \
(design \"Event Contract\" (a))"
);
let (bp_source_path, bp_line) = if expected_initial_mode == "lua" {
(coords.chunk.clone(), coords.bp_lua_line)
} else {
(coords.pasta_file_key.clone(), BP_PASTA_LINE)
};
client.send_request(
3,
"setBreakpoints",
json!({
"source": { "path": bp_source_path },
"breakpoints": [{ "line": bp_line }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"].as_array().expect("breakpoints array");
assert_eq!(bps.len(), 1, "exactly one breakpoint resolved");
assert_eq!(
bps[0]["verified"], true,
"初期 {expected_initial_mode} 提示座標で張った BP は検証済み"
);
client.send_request(4, "configurationDone", json!({}));
let _ = client.recv_until(|m| is_response(m, "configurationDone"));
go_tx.send(()).expect("go #1");
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "breakpoint",
"exec #1 は `.pasta` 行 BP で停止する"
);
let thread_id = stopped["body"]["threadId"].as_u64().unwrap_or(1);
StoppedSession {
client,
thread_id,
go_tx,
host,
}
}
fn finish_session(mut session: StoppedSession) {
session
.client
.send_request(900, "continue", json!({ "threadId": session.thread_id }));
let _ = session.client.recv_until(|m| is_response(m, "continue"));
let host = session.host;
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host thread must not panic")
.expect("exec #1 must run to completion");
}
Err(RecvTimeoutError::Timeout) => panic!("host thread did not finish (hang?)"),
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
drop(session.go_tx);
}
fn assert_lua_frame(session: &mut StoppedSession, coords: &SessionCoords, seq: u64, ctx: &str) {
session
.client
.send_request(seq, "stackTrace", json!({ "threadId": session.thread_id }));
let stack = session.client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"].as_array().expect("stackFrames");
assert!(!frames.is_empty(), "{ctx}: 停止フレームが存在する");
let path = frames[0]["source"]["path"].as_str().expect("`.lua` 提示 source path");
assert_eq!(
canonicalize_chunk_name(path),
canonicalize_chunk_name(&coords.chunk),
"{ctx}: トップフレーム source は生成 `.lua` チャンク (got {path})"
);
assert_eq!(
frames[0]["line"].as_u64().expect("lua line"),
coords.bp_lua_line as u64,
"{ctx}: トップフレーム行は生成 `.lua` 実行行 {}",
coords.bp_lua_line
);
}
fn assert_pasta_frame(session: &mut StoppedSession, coords: &SessionCoords, seq: u64, ctx: &str) {
session
.client
.send_request(seq, "stackTrace", json!({ "threadId": session.thread_id }));
let stack = session.client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"].as_array().expect("stackFrames");
assert!(!frames.is_empty(), "{ctx}: 停止フレームが存在する");
assert_pasta_source(&frames[0], &coords.pasta_file_key, ctx);
assert_eq!(
frames[0]["line"], BP_PASTA_LINE,
"{ctx}: トップフレーム行は `.pasta` 行 {BP_PASTA_LINE}"
);
}
fn toggle_mode(session: &mut StoppedSession, seq: u64, mode: &str) {
session
.client
.send_request(seq, "pasta/sourcePresentation", json!({ "mode": mode }));
let resp = session
.client
.recv_until(|m| is_response(m, "pasta/sourcePresentation"));
assert_eq!(
resp["body"]["mode"], mode,
"受理レスポンスは適用後モード {mode} をエコーする (requirement 1.3)"
);
let _event = session
.client
.recv_until(|m| is_event(m, "pasta/sourcePresentation") && m["body"]["mode"] == mode);
let restopped = session.client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
restopped["body"]["reason"], "breakpoint",
"3.3: 切替後、現停止が再送され再描画が起動する"
);
}
#[test]
fn attach_initial_lua_is_applied_then_runtime_toggle_overrides_to_pasta() {
let temp = tempfile::TempDir::new().expect("temp dir");
let coords = resolve_session(temp.path(), None);
let mut session = start_stopped_session(&coords, Some("lua"), "lua");
assert_lua_frame(&mut session, &coords, 10, "4.1: attach 初期 `.lua` での最初の停止");
toggle_mode(&mut session, 20, "pasta");
assert_pasta_frame(&mut session, &coords, 21, "4.2/4.3: トグルで初期 `.lua` を `.pasta` へ上書き");
assert_pasta_frame(&mut session, &coords, 22, "4.3: 上書き後モードが後続の読みでも持続");
finish_session(session);
}
#[test]
fn no_attach_arg_file_present_as_lua_resolves_initial_lua_then_toggle_overrides() {
let temp = tempfile::TempDir::new().expect("temp dir");
let coords = resolve_session(temp.path(), Some("lua"));
let mut session = start_stopped_session(&coords, None, "lua");
assert_lua_frame(&mut session, &coords, 10, "4.4 file: present_as=\"lua\" 初期 `.lua` の最初の停止");
toggle_mode(&mut session, 20, "pasta");
assert_pasta_frame(&mut session, &coords, 21, "4.4/4.2/4.3: 解決済み `.lua` をトグルで `.pasta` へ上書き");
assert_pasta_frame(&mut session, &coords, 22, "4.3: 上書き後モードが後続の読みでも持続");
finish_session(session);
}
#[test]
fn no_attach_arg_no_config_resolves_initial_pasta_then_toggle_overrides() {
let temp = tempfile::TempDir::new().expect("temp dir");
let coords = resolve_session(temp.path(), None);
let mut session = start_stopped_session(&coords, None, "pasta");
assert_pasta_frame(&mut session, &coords, 10, "4.4 default: 設定なし初期 `.pasta` の最初の停止");
toggle_mode(&mut session, 20, "lua");
assert_lua_frame(&mut session, &coords, 21, "4.4/4.2/4.3: 解決済み `.pasta` をトグルで `.lua` へ上書き");
assert_lua_frame(&mut session, &coords, 22, "4.3: 上書き後モードが後続の読みでも持続");
finish_session(session);
}
const STEP_FIXTURE: &str = include_str!("../fixtures/debug_toggle_step_e2e.pasta");
fn make_step_base_dir(base: &Path) -> PathBuf {
let pasta_file = base.join("dic/test/debug_toggle_step_e2e.pasta");
std::fs::create_dir_all(pasta_file.parent().unwrap()).unwrap();
std::fs::write(&pasta_file, STEP_FIXTURE).unwrap();
std::fs::write(
base.join("pasta.toml"),
"\
[loader]
debug_mode = true
[debug]
enabled = true
port = 0
",
)
.unwrap();
let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for sub in ["pasta_scripts", "scriptlibs"] {
let src = crate_root.join(sub);
let dst = base.join(sub);
if src.exists() {
std::fs::create_dir_all(&dst).unwrap();
copy_dir(&src, &dst).unwrap();
}
}
pasta_file
}
fn transpile_step_fixture(file: &Path) -> String {
let parsed = parse_str(STEP_FIXTURE, &file.to_string_lossy()).expect("step fixture must parse");
let transpiler = LuaTranspiler::default();
let mut out = Vec::new();
transpiler
.transpile(&parsed, &mut out)
.expect("step fixture must transpile");
String::from_utf8(out).expect("generated lua is valid utf-8")
}
const STEP_DRIVER: &str = r#"
local SCENE = require("pasta.scene")
local ACT = require("pasta.act")
local start = SCENE.get_start("あいさつ1")
if not start then error("scene entrypoint must be registered") end
local act = ACT.new({ ["さくら"] = { name = "さくら" } })
start(act)
return #act.token
"#;
struct StepCoords {
base: PathBuf,
chunk: String,
pasta_file_key: String,
generated_lua: String,
origin_pasta_line: u32,
origin_lua_line: u32,
multi_pasta_line: u32,
multi_lua_lines: Vec<u32>,
next_pasta_line: u32,
next_lua_line: u32,
}
fn resolve_step_session(base: &Path) -> StepCoords {
let pasta_file = make_step_base_dir(base);
let cache_manager = CacheManager::new(base.to_path_buf(), "profile/pasta/cache/lua");
let chunk = cache_manager
.source_to_cache_path(&pasta_file)
.to_string_lossy()
.to_string();
let pasta_file_key = pasta_file.to_string_lossy().to_string();
let map =
PastaLoader::build_source_map(std::slice::from_ref(&pasta_file), &cache_manager, false);
let generated_lua = transpile_step_fixture(&pasta_file);
let header_lua_line = generated_lua
.lines()
.position(|l| l.contains("function SCENE.__start__"))
.map(|i| i as u32 + 1)
.expect("生成 `.lua` に `__start__` ヘッダがある");
let body_lua_for = |pl: u32| -> Vec<u32> {
let mut v: Vec<u32> = map
.resolve_pasta_to_lua(&pasta_file_key, pl)
.iter()
.map(|(_, l)| *l)
.filter(|l| *l > header_lua_line)
.collect();
v.sort_unstable();
v
};
let mut multi: Option<(u32, Vec<u32>)> = None;
for pl in 1..=60u32 {
let body = body_lua_for(pl);
if body.len() >= 2 {
multi = Some((pl, body));
break;
}
}
let (multi_pasta_line, multi_lua_lines) =
multi.expect("fixture invariant: ある `.pasta` トーク行が本体で ≥2 の `.lua` 行へ展開される");
let first_multi_lua = multi_lua_lines[0];
let last_multi_lua = *multi_lua_lines.last().unwrap();
let mut origin: Option<(u32, u32)> = None;
for pl in (1..multi_pasta_line).rev() {
let body = body_lua_for(pl);
if body.len() == 1 && body[0] < first_multi_lua {
origin = Some((pl, body[0]));
break;
}
}
let (origin_pasta_line, origin_lua_line) =
origin.expect("fixture invariant: 多対1行の手前に単一 `.lua` 本体行の `.pasta` 行がある");
for &lua_line in &multi_lua_lines {
let back = map
.resolve_lua_to_pasta(&chunk, lua_line)
.expect("本体 `.lua` 行は前方解決できる");
assert_eq!(
back.line, multi_pasta_line,
"多対1: `.lua` 行 {lua_line} は `.pasta` 行 {multi_pasta_line} へ等価解決する"
);
}
let mut next: Option<(u32, u32)> = None;
for pl in (multi_pasta_line + 1)..=60u32 {
let body: Vec<u32> = body_lua_for(pl)
.into_iter()
.filter(|l| *l > last_multi_lua)
.collect();
if let Some(&lua_line) = body.iter().min() {
next = Some((pl, lua_line));
break;
}
}
let (next_pasta_line, next_lua_line) =
next.expect("fixture invariant: 多対1行の直後に異なる `.pasta` 行(本体実行)がある");
assert!(
origin_pasta_line < multi_pasta_line && multi_pasta_line < next_pasta_line,
"順序: origin {origin_pasta_line} < multi {multi_pasta_line} < next {next_pasta_line}"
);
assert!(
origin_lua_line < first_multi_lua && last_multi_lua < next_lua_line,
"本体 `.lua` 行の順序: origin {origin_lua_line} < multi {multi_lua_lines:?} < next {next_lua_line}"
);
StepCoords {
base: base.to_path_buf(),
chunk,
pasta_file_key,
generated_lua,
origin_pasta_line,
origin_lua_line,
multi_pasta_line,
multi_lua_lines,
next_pasta_line,
next_lua_line,
}
}
struct StepSession {
client: DapClient,
thread_id: u64,
host: std::thread::JoinHandle<Result<(), String>>,
}
fn start_step_session(coords: &StepCoords, initial_mode: &str) -> StepSession {
let (addr_tx, addr_rx) = mpsc::channel::<SocketAddr>();
let (go_tx, go_rx) = mpsc::channel::<()>();
let base_for_host = coords.base.clone();
let chunk_for_host = coords.chunk.clone();
let generated_for_host = coords.generated_lua.clone();
let host = std::thread::spawn(move || -> Result<(), String> {
let runtime = PastaLoader::load_with_config(&base_for_host, RuntimeConfig::new())
.map_err(|e| format!("loader must build an enabled-debug runtime: {e}"))?;
if !runtime.debug_enabled() {
return Err("enabled [debug] must install the backend".to_string());
}
if runtime.debug_source_map().is_none() {
return Err("enabled debug runtime must hold the aggregated source map".to_string());
}
let addr = runtime
.debug_local_addr()
.ok_or_else(|| "enabled runtime must expose a bound debug addr (port 0)".to_string())?;
addr_tx.send(addr).map_err(|_| "addr send failed".to_string())?;
runtime
.exec_named(&generated_for_host, &chunk_for_host)
.map_err(|e| format!("scene define exec failed: {e}"))?;
go_rx
.recv_timeout(WATCHDOG)
.map_err(|_| "no go signal before driver exec".to_string())?;
runtime
.exec(STEP_DRIVER)
.map_err(|e| format!("scene driver exec failed: {e}"))?;
drop(runtime);
Ok(())
});
let addr = addr_rx
.recv_timeout(WATCHDOG)
.expect("host must publish the bound addr before the watchdog");
let mut client = DapClient::connect(addr);
client.send_request(1, "initialize", json!({ "adapterID": "pasta" }));
let _ = client.recv_until(|m| is_response(m, "initialize"));
let _ = client.recv_until(|m| is_event(m, "initialized"));
client.send_request(2, "attach", json!({ "sourcePresentation": initial_mode }));
let _ = client.recv_until(|m| is_response(m, "attach"));
let attach_event = client.recv_until(|m| is_event(m, "pasta/sourcePresentation"));
assert_eq!(
attach_event["body"]["mode"], initial_mode,
"attach 完了時の push イベントは初期解決モード {initial_mode} を報告する"
);
let (bp_source_path, bp_line) = if initial_mode == "lua" {
(coords.chunk.clone(), coords.origin_lua_line)
} else {
(coords.pasta_file_key.clone(), coords.origin_pasta_line)
};
client.send_request(
3,
"setBreakpoints",
json!({
"source": { "path": bp_source_path },
"breakpoints": [{ "line": bp_line }],
}),
);
let bp_resp = client.recv_until(|m| is_response(m, "setBreakpoints"));
let bps = bp_resp["body"]["breakpoints"].as_array().expect("breakpoints array");
assert_eq!(bps.len(), 1, "exactly one breakpoint resolved");
assert_eq!(
bps[0]["verified"], true,
"初期 {initial_mode} 提示座標で張った origin BP は検証済み"
);
client.send_request(4, "configurationDone", json!({}));
let _ = client.recv_until(|m| is_response(m, "configurationDone"));
go_tx.send(()).expect("go driver");
let stopped = client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "breakpoint",
"driver は origin(単一 `.lua` 本体行)の BP で停止する"
);
let thread_id = stopped["body"]["threadId"].as_u64().unwrap_or(1);
StepSession {
client,
thread_id,
host,
}
}
fn finish_step_session(mut session: StepSession) {
session
.client
.send_request(900, "continue", json!({ "threadId": session.thread_id }));
let _ = session.client.recv_until(|m| is_response(m, "continue"));
let host = session.host;
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(host.join());
});
match done_rx.recv_timeout(WATCHDOG) {
Ok(joined) => {
joined
.expect("host thread must not panic")
.expect("define + driver execs must run to completion");
}
Err(RecvTimeoutError::Timeout) => panic!("host thread did not finish (hang?)"),
Err(RecvTimeoutError::Disconnected) => panic!("join watcher disconnected"),
}
}
fn clear_bp_and_step_into_multi(session: &mut StepSession, coords: &StepCoords, initial_lua: bool) {
for (seq, path) in [(10, coords.chunk.clone()), (11, coords.pasta_file_key.clone())] {
session.client.send_request(
seq,
"setBreakpoints",
json!({ "source": { "path": path }, "breakpoints": [] }),
);
let _ = session.client.recv_until(|m| is_response(m, "setBreakpoints"));
}
session
.client
.send_request(12, "next", json!({ "threadId": session.thread_id }));
let _ = session.client.recv_until(|m| is_response(m, "next"));
let stopped = session.client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stopped["body"]["reason"], "step",
"origin からの `next` は reason step で多対1行へ進む(BP 再発火なし)"
);
session
.client
.send_request(13, "stackTrace", json!({ "threadId": session.thread_id }));
let stack = session.client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"].as_array().expect("stackFrames");
let top_line = frames[0]["line"].as_u64().expect("line") as u32;
let top_path = frames[0]["source"]["path"].as_str().expect("source path");
if initial_lua {
assert_eq!(
canonicalize_chunk_name(top_path),
canonicalize_chunk_name(&coords.chunk),
"`.lua` 提示: トップフレーム source は生成 `.lua` チャンク (got {top_path})"
);
assert_eq!(
top_line, coords.multi_lua_lines[0],
"1 回目 `next`(`.lua` 提示)は多対1行の先頭 `.lua` 行 {} で停止する",
coords.multi_lua_lines[0]
);
} else {
assert_eq!(
canonicalize_chunk_name(top_path),
canonicalize_chunk_name(&coords.pasta_file_key),
"`.pasta` 提示: トップフレーム source は `.pasta` ファイル (got {top_path})"
);
assert_eq!(
top_line, coords.multi_pasta_line,
"1 回目 `next`(`.pasta` 提示)は多対1 `.pasta` 行 {} で停止する",
coords.multi_pasta_line
);
}
}
fn step_toggle_mode(session: &mut StepSession, seq: u64, mode: &str) {
session
.client
.send_request(seq, "pasta/sourcePresentation", json!({ "mode": mode }));
let resp = session
.client
.recv_until(|m| is_response(m, "pasta/sourcePresentation"));
assert_eq!(
resp["body"]["mode"], mode,
"受理レスポンスは適用後モード {mode} をエコーする"
);
let _event = session
.client
.recv_until(|m| is_event(m, "pasta/sourcePresentation") && m["body"]["mode"] == mode);
let restopped = session.client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
restopped["body"]["reason"], "step",
"3.3: 切替後、現停止(step で多対1行)が再送され再描画が起動する"
);
}
fn step_next_then_top_line(session: &mut StepSession, coords: &StepCoords, base_seq: u64, expect_lua: bool) -> u32 {
session
.client
.send_request(base_seq, "next", json!({ "threadId": session.thread_id }));
let _ = session.client.recv_until(|m| is_response(m, "next"));
let stopped = session.client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(stopped["body"]["reason"], "step", "`next` は reason step で再停止する");
session
.client
.send_request(base_seq + 1, "stackTrace", json!({ "threadId": session.thread_id }));
let stack = session.client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"].as_array().expect("stackFrames");
assert!(!frames.is_empty(), "停止フレームが存在する");
let path = frames[0]["source"]["path"].as_str().expect("source path");
let expect_path = if expect_lua { &coords.chunk } else { &coords.pasta_file_key };
assert_eq!(
canonicalize_chunk_name(path),
canonicalize_chunk_name(expect_path),
"{} 提示: トップフレーム source は期待する提示先 (got {path})",
if expect_lua { "`.lua`" } else { "`.pasta`" }
);
frames[0]["line"].as_u64().expect("line") as u32
}
#[test]
fn paused_toggle_lua_to_pasta_switches_next_step_to_pasta_granularity() {
let temp = tempfile::TempDir::new().expect("temp dir");
let coords = resolve_step_session(temp.path());
let mut session = start_step_session(&coords, "lua");
clear_bp_and_step_into_multi(&mut session, &coords, true);
step_toggle_mode(&mut session, 20, "pasta");
let stopped_pasta = step_next_then_top_line(&mut session, &coords, 21, false);
assert_eq!(
stopped_pasta, coords.next_pasta_line,
"5.3/5.1: 停止中 `.lua`->`.pasta` トグル後の `next` は `.pasta` 粒度 —— 同一 `.pasta` 行 \
{} の `.lua` 行群({:?})を消化し、次の異なる `.pasta` 行 {} で停止する",
coords.multi_pasta_line, coords.multi_lua_lines, coords.next_pasta_line
);
finish_step_session(session);
}
#[test]
fn paused_toggle_pasta_to_lua_switches_next_step_to_lua_granularity() {
let temp = tempfile::TempDir::new().expect("temp dir");
let coords = resolve_step_session(temp.path());
let mut session = start_step_session(&coords, "pasta");
clear_bp_and_step_into_multi(&mut session, &coords, false);
step_toggle_mode(&mut session, 20, "lua");
let body_second_lua = coords.multi_lua_lines[1];
let stopped_lua = step_next_then_top_line(&mut session, &coords, 21, true);
assert_eq!(
stopped_lua, body_second_lua,
"5.3/5.2: 停止中 `.pasta`->`.lua` トグル後の `next` は `.lua` 粒度 —— 同一 `.pasta` 行 \
{} 内の次の `.lua` 行 {} で停止する(次の異なる `.pasta` 行 {} まで進まない)",
coords.multi_pasta_line, body_second_lua, coords.next_pasta_line
);
assert!(
stopped_lua < coords.next_lua_line,
"5.3/5.2: `.lua` 粒度の停止 `.lua` 行 {stopped_lua} は次の異なる `.pasta` 行の `.lua` 行 \
{} より手前(同一 `.pasta` 行内に留まる)",
coords.next_lua_line
);
finish_step_session(session);
}
fn make_disabled_base_dir(base: &Path) -> PathBuf {
let pasta_file = base.join("dic/test/debug_toggle_e2e.pasta");
std::fs::create_dir_all(pasta_file.parent().unwrap()).unwrap();
std::fs::write(&pasta_file, FIXTURE).unwrap();
std::fs::write(
base.join("pasta.toml"),
"\
[loader]
debug_mode = true
[debug]
enabled = false
",
)
.unwrap();
let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for sub in ["pasta_scripts", "scriptlibs"] {
let src = crate_root.join(sub);
let dst = base.join(sub);
if src.exists() {
std::fs::create_dir_all(&dst).unwrap();
copy_dir(&src, &dst).unwrap();
}
}
pasta_file
}
#[test]
fn disabled_runtime_has_no_toggle_state_to_run() {
let temp = tempfile::TempDir::new().expect("temp dir");
let base = temp.path().to_path_buf();
let _pasta_file = make_disabled_base_dir(&base);
let runtime = PastaLoader::load_with_config(&base, RuntimeConfig::new())
.expect("disabled-debug runtime must build");
assert!(
!runtime.debug_enabled(),
"6.2: OFF ランタイムは DebugHandle を保持しない(トグルブリッジ/アダプタ非起動)"
);
assert_eq!(
runtime.debug_source_mode(),
None,
"6.2: OFF では実行時トグルが反転させる提示モードセル(SharedSourceMode)が実体化しない"
);
assert_eq!(
runtime.debug_local_addr(),
None,
"6.2: OFF では `pasta/sourcePresentation` カスタムリクエストを受ける接続口を開かない"
);
assert!(
runtime.debug_source_map().is_none(),
"6.2: OFF ではモード別提示の対象マップを構築しない(ゼロコスト)"
);
}
#[test]
fn no_toggle_session_stays_in_initial_pasta_mode_throughout() {
let temp = tempfile::TempDir::new().expect("temp dir");
let coords = resolve_session(temp.path(), None);
let mut session = start_stopped_session(&coords, None, "pasta");
assert_pasta_frame(&mut session, &coords, 10, "6.1: トグル未使用・初期 `.pasta` の最初の停止");
assert_pasta_frame(&mut session, &coords, 11, "6.1: トグル未使用・再読でも初期 `.pasta` が持続");
session.client.send_request(
12,
"setBreakpoints",
json!({ "source": { "path": coords.pasta_file_key.clone() }, "breakpoints": [] }),
);
let _ = session.client.recv_until(|m| is_response(m, "setBreakpoints"));
session
.client
.send_request(13, "next", json!({ "threadId": session.thread_id }));
let _ = session.client.recv_until(|m| is_response(m, "next"));
let stepped = session.client.recv_until(|m| is_event(m, "stopped"));
assert_eq!(
stepped["body"]["reason"], "step",
"6.1: トグル未使用の `next` は reason step で再停止する"
);
session
.client
.send_request(14, "stackTrace", json!({ "threadId": session.thread_id }));
let stack = session.client.recv_until(|m| is_response(m, "stackTrace"));
let frames = stack["body"]["stackFrames"].as_array().expect("stackFrames");
assert!(!frames.is_empty(), "6.1: ステップ後も停止フレームが存在する");
assert_pasta_source(
&frames[0],
&coords.pasta_file_key,
"6.1: トグル未使用・ステップ後もトップフレームは `.pasta` 提示(ドリフトなし)",
);
finish_session(session);
}