use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub use crate::code_gen::source_map::{PastaPos, SourceMapSink};
use crate::normalize::LineShift;
pub type ChunkName = String;
#[derive(Debug, Clone, Default)]
pub struct ChunkSourceMap {
forward: BTreeMap<u32, PastaPos>,
}
impl ChunkSourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn from_forward(forward: BTreeMap<u32, PastaPos>) -> Self {
Self { forward }
}
pub fn len(&self) -> usize {
self.forward.len()
}
pub fn is_empty(&self) -> bool {
self.forward.is_empty()
}
pub fn pasta_for_lua(&self, lua_line: u32) -> Option<&PastaPos> {
self.forward.get(&lua_line)
}
pub fn lua_lines_for_pasta(&self, pasta_line: u32) -> Vec<u32> {
self.forward
.iter()
.filter(|(_, pos)| pos.line == pasta_line)
.map(|(lua_line, _)| *lua_line)
.collect()
}
}
pub struct MapBuilderSink {
pasta_file: String,
chunk_name: ChunkName,
pre_norm: BTreeMap<u32, PastaPos>,
}
impl MapBuilderSink {
pub fn new(pasta_file: String, chunk_name: ChunkName) -> Self {
Self {
pasta_file,
chunk_name,
pre_norm: BTreeMap::new(),
}
}
pub fn chunk_name(&self) -> &ChunkName {
&self.chunk_name
}
pub fn finish(self, shift: &LineShift) -> ChunkSourceMap {
let mut forward: BTreeMap<u32, PastaPos> = BTreeMap::new();
for (pre_line, pos) in self.pre_norm {
if let Some(final_line) = shift.map(pre_line) {
forward.insert(final_line, pos);
}
}
ChunkSourceMap::from_forward(forward)
}
}
impl SourceMapSink for MapBuilderSink {
fn record_line(&mut self, lua_line: u32, pasta_line: u32) {
self.pre_norm.insert(
lua_line,
PastaPos {
file: self.pasta_file.clone(),
line: pasta_line,
},
);
}
}
pub fn canonicalize_chunk_name(raw: &str) -> String {
let without_at = raw.strip_prefix('@').unwrap_or(raw);
let unified = without_at.replace('\\', "/");
#[cfg(windows)]
{
unified.to_lowercase()
}
#[cfg(not(windows))]
{
unified
}
}
fn canonicalize_pasta_file(raw: &str) -> String {
canonicalize_chunk_name(raw)
}
#[derive(Debug, Clone, Default)]
pub struct SourceMap {
chunks: HashMap<ChunkName, ChunkSourceMap>,
reverse: HashMap<String, BTreeMap<u32, Vec<(ChunkName, u32)>>>,
}
impl SourceMap {
pub fn new() -> Self {
Self::default()
}
pub fn insert_chunk(&mut self, chunk_name: ChunkName, pasta_file: String, map: ChunkSourceMap) {
let chunk_key = canonicalize_chunk_name(&chunk_name);
let file_key = canonicalize_pasta_file(&pasta_file);
if self.chunks.contains_key(&chunk_key) {
self.reverse.retain(|_, per_file| {
per_file.retain(|_, entries| {
entries.retain(|(ck, _)| *ck != chunk_key);
!entries.is_empty()
});
!per_file.is_empty()
});
}
let per_file = self.reverse.entry(file_key).or_default();
for (lua_line, pasta_line) in map_forward_iter(&map) {
per_file
.entry(pasta_line)
.or_default()
.push((chunk_key.clone(), lua_line));
}
for entries in per_file.values_mut() {
entries.sort();
}
self.chunks.insert(chunk_key, map);
}
pub fn resolve_lua_to_pasta(&self, chunk: &str, lua_line: u32) -> Option<&PastaPos> {
let chunk_key = canonicalize_chunk_name(chunk);
self.chunks.get(&chunk_key)?.pasta_for_lua(lua_line)
}
pub fn resolve_pasta_to_lua(&self, pasta_file: &str, pasta_line: u32) -> Vec<(ChunkName, u32)> {
let file_key = canonicalize_pasta_file(pasta_file);
self.reverse
.get(&file_key)
.and_then(|per_file| per_file.get(&pasta_line))
.cloned()
.unwrap_or_default()
}
pub fn nearest_pasta_line_with_mapping(&self, pasta_file: &str, from_line: u32) -> Option<u32> {
let file_key = canonicalize_pasta_file(pasta_file);
self.reverse
.get(&file_key)?
.range(from_line..)
.next()
.map(|(&pasta_line, _)| pasta_line)
}
}
fn map_forward_iter(map: &ChunkSourceMap) -> impl Iterator<Item = (u32, u32)> + '_ {
map.forward.iter().map(|(&lua_line, pos)| (lua_line, pos.line))
}
pub const SIDECAR_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SidecarFile {
pub version: u32,
pub pasta_file: String,
pub pairs: Vec<[u32; 2]>,
}
impl SidecarFile {
pub fn from_chunk(pasta_file: impl Into<String>, map: &ChunkSourceMap) -> Self {
let pairs = map_forward_iter(map)
.map(|(lua_line, pasta_line)| [lua_line, pasta_line])
.collect();
Self {
version: SIDECAR_VERSION,
pasta_file: pasta_file.into(),
pairs,
}
}
pub fn to_chunk(&self) -> ChunkSourceMap {
let mut forward = BTreeMap::new();
for &[lua_line, pasta_line] in &self.pairs {
forward.insert(
lua_line,
PastaPos {
file: self.pasta_file.clone(),
line: pasta_line,
},
);
}
ChunkSourceMap::from_forward(forward)
}
}
pub fn sidecar_path_for_lua(lua_path: &Path) -> PathBuf {
let mut name = lua_path.as_os_str().to_os_string();
name.push(".map");
PathBuf::from(name)
}
pub fn write_sidecar(
lua_path: &Path,
pasta_file: &str,
map: &ChunkSourceMap,
) -> std::io::Result<()> {
let sidecar = SidecarFile::from_chunk(pasta_file, map);
let bytes = serde_json::to_vec(&sidecar)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let path = sidecar_path_for_lua(lua_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, bytes)
}
pub fn read_sidecar(lua_path: &Path) -> std::io::Result<ChunkSourceMap> {
let path = sidecar_path_for_lua(lua_path);
let bytes = std::fs::read(&path)?;
let sidecar: SidecarFile = serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(sidecar.to_chunk())
}
#[cfg(test)]
mod tests {
use super::*;
use pasta_dsl::parser::Span;
#[test]
fn canonicalize_chunk_name_unifies_at_separators_and_case() {
let hook = r"@C:/base/profile/pasta/cache/lua/pasta\scene\sys.lua";
let loader = r"C:\base\profile/pasta/cache/lua\pasta/scene\sys.lua";
let hook_canon = canonicalize_chunk_name(hook);
let loader_canon = canonicalize_chunk_name(loader);
assert!(!hook_canon.starts_with('@'));
assert!(!hook_canon.contains('\\'));
assert!(!loader_canon.contains('\\'));
assert_eq!(
hook_canon, loader_canon,
"正規化後はフック source とローダ由来キーが一致しなければならない"
);
}
#[test]
fn canonicalize_chunk_name_case_policy_matches_platform() {
let upper = canonicalize_chunk_name(r"@C:/Base/SYS.lua");
let lower = canonicalize_chunk_name(r"@c:/base/sys.lua");
#[cfg(windows)]
assert_eq!(upper, lower, "Windows は大小文字無視");
#[cfg(not(windows))]
assert_ne!(upper, lower, "非 Windows は大小を区別する");
}
fn pos(line: u32) -> PastaPos {
PastaPos {
file: "dict.pasta".to_string(),
line,
}
}
fn sample_map() -> ChunkSourceMap {
let mut forward = BTreeMap::new();
forward.insert(10u32, pos(3));
forward.insert(12u32, pos(7));
forward.insert(13u32, pos(7));
forward.insert(15u32, pos(7));
forward.insert(20u32, pos(9));
ChunkSourceMap::from_forward(forward)
}
#[test]
fn chunk_source_map_resolves_mapped_lua_line() {
let map = sample_map();
assert_eq!(map.pasta_for_lua(10), Some(&pos(3)));
assert_eq!(map.pasta_for_lua(20), Some(&pos(9)));
assert_eq!(map.pasta_for_lua(12), Some(&pos(7)));
assert_eq!(map.pasta_for_lua(15), Some(&pos(7)));
}
#[test]
fn chunk_source_map_unmapped_lua_line_returns_none() {
let map = sample_map();
assert_eq!(map.pasta_for_lua(11), None);
assert_eq!(map.pasta_for_lua(14), None);
assert_eq!(map.pasta_for_lua(1), None);
assert_eq!(map.pasta_for_lua(999), None);
}
#[test]
fn chunk_source_map_reverse_returns_all_lua_lines_ascending() {
let map = sample_map();
assert_eq!(map.lua_lines_for_pasta(7), vec![12, 13, 15]);
assert_eq!(map.lua_lines_for_pasta(3), vec![10]);
assert_eq!(map.lua_lines_for_pasta(9), vec![20]);
assert_eq!(map.lua_lines_for_pasta(100), Vec::<u32>::new());
}
#[test]
fn chunk_source_map_one_lua_line_maps_to_at_most_one_pasta() {
let mut forward = BTreeMap::new();
forward.insert(10u32, pos(3));
forward.insert(10u32, pos(8));
let map = ChunkSourceMap::from_forward(forward);
assert_eq!(map.pasta_for_lua(10), Some(&pos(8)));
assert_eq!(map.lua_lines_for_pasta(3), Vec::<u32>::new());
assert_eq!(map.lua_lines_for_pasta(8), vec![10]);
}
#[test]
fn chunk_source_map_reverse_is_deterministic_across_calls() {
let map = sample_map();
let first = map.lua_lines_for_pasta(7);
let second = map.lua_lines_for_pasta(7);
let third = map.lua_lines_for_pasta(7);
assert_eq!(first, second);
assert_eq!(second, third);
assert_eq!(first, vec![12, 13, 15]);
}
use crate::normalize::normalize_output_with_shift;
#[test]
fn map_builder_sink_finish_rebases_to_final_lua_lines() {
let input = "l1\nl2\nl3\n\nend\n";
let (out, shift) = normalize_output_with_shift(input);
assert_eq!(out, "l1\nl2\nl3\nend\n");
assert_eq!(shift.map(1), Some(1));
assert_eq!(shift.map(4), None); assert_eq!(shift.map(5), Some(4));
let mut sink = MapBuilderSink::new("dict.pasta".to_string(), "chunk-a".to_string());
sink.record_line(1, 10); sink.record_line(2, 11); sink.record_line(3, 12); sink.record_line(4, 99); sink.record_line(5, 20);
let map = sink.finish(&shift);
assert_eq!(map.pasta_for_lua(1), Some(&pos(10)));
assert_eq!(map.pasta_for_lua(2), Some(&pos(11)));
assert_eq!(map.pasta_for_lua(3), Some(&pos(12)));
assert_eq!(map.pasta_for_lua(4), Some(&pos(20)));
assert!(
map.pasta_for_lua(5).is_none(),
"削除行に紐づく記録は最終写像に残ってはならない(2.1)"
);
assert_eq!(map.len(), 4);
}
#[test]
fn map_builder_sink_same_pre_line_is_last_write_wins() {
let (out, shift) = normalize_output_with_shift("a\nb\n");
assert_eq!(out, "a\nb\n");
assert_eq!(shift.map(1), Some(1));
assert_eq!(shift.map(2), Some(2));
let mut sink = MapBuilderSink::new("dict.pasta".to_string(), "chunk".to_string());
sink.record_line(2, 5); sink.record_line(2, 8);
let map = sink.finish(&shift);
assert_eq!(map.pasta_for_lua(2), Some(&pos(8)));
assert_eq!(map.lua_lines_for_pasta(5), Vec::<u32>::new());
assert_eq!(map.lua_lines_for_pasta(8), vec![2]);
}
#[test]
fn map_builder_sink_finish_is_deterministic() {
let (_out, shift) = normalize_output_with_shift("a\nb\nc\n");
let build = || {
let mut sink =
MapBuilderSink::new("dict.pasta".to_string(), "chunk".to_string());
sink.record_line(3, 7);
sink.record_line(1, 3);
sink.record_line(2, 5);
let map = sink.finish(&shift);
(
map.pasta_for_lua(1).cloned(),
map.pasta_for_lua(2).cloned(),
map.pasta_for_lua(3).cloned(),
)
};
assert_eq!(build(), build());
assert_eq!(
build(),
(Some(pos(3)), Some(pos(5)), Some(pos(7)))
);
}
#[test]
fn map_builder_sink_record_uses_span_start_line() {
let (_out, shift) = normalize_output_with_shift("a\nb\n");
let mut sink = MapBuilderSink::new("dict.pasta".to_string(), "chunk".to_string());
let span = Span::new(42, 1, 42, 9, 9999, 10001);
sink.record(1, span);
let map = sink.finish(&shift);
assert_eq!(
map.pasta_for_lua(1),
Some(&pos(42)),
"record は span.start_line を直接 .pasta 行として採用する(D-3)"
);
}
#[test]
fn map_builder_sink_retains_chunk_name() {
let sink = MapBuilderSink::new("dict.pasta".to_string(), "my-chunk".to_string());
assert_eq!(sink.chunk_name(), "my-chunk");
}
fn pos_in(file: &str, line: u32) -> PastaPos {
PastaPos {
file: file.to_string(),
line,
}
}
fn sample_source_map() -> SourceMap {
let file_a = "C:/proj/scene/a.pasta";
let file_b = "C:/proj/scene/b.pasta";
let mut fwd_a = BTreeMap::new();
fwd_a.insert(10u32, pos_in(file_a, 3));
fwd_a.insert(12u32, pos_in(file_a, 7));
fwd_a.insert(13u32, pos_in(file_a, 7));
fwd_a.insert(20u32, pos_in(file_a, 9));
let mut fwd_b = BTreeMap::new();
fwd_b.insert(5u32, pos_in(file_b, 7));
let mut fwd_c = BTreeMap::new();
fwd_c.insert(4u32, pos_in(file_a, 7));
let mut sm = SourceMap::new();
sm.insert_chunk(
r"@C:\proj\cache\A.lua".to_string(),
file_a.to_string(),
ChunkSourceMap::from_forward(fwd_a),
);
sm.insert_chunk(
r"@C:\proj\cache\b.lua".to_string(),
file_b.to_string(),
ChunkSourceMap::from_forward(fwd_b),
);
sm.insert_chunk(
r"@C:\proj\cache\c.lua".to_string(),
file_a.to_string(),
ChunkSourceMap::from_forward(fwd_c),
);
sm
}
#[test]
fn source_map_resolve_lua_to_pasta_matches_normalized_chunk_key() {
let sm = sample_source_map();
let file_a = "C:/proj/scene/a.pasta";
assert_eq!(
sm.resolve_lua_to_pasta("C:/proj/cache/a.lua", 10),
Some(&pos_in(file_a, 3))
);
#[cfg(windows)]
assert_eq!(
sm.resolve_lua_to_pasta(r"@C:\PROJ\CACHE\A.LUA", 20),
Some(&pos_in(file_a, 9))
);
assert_eq!(
sm.resolve_lua_to_pasta("c:/proj/cache/a.lua", 12),
Some(&pos_in(file_a, 7))
);
}
#[test]
fn source_map_resolve_lua_to_pasta_chunk_or_line_miss_returns_none() {
let sm = sample_source_map();
assert_eq!(sm.resolve_lua_to_pasta("C:/proj/cache/unknown.lua", 10), None);
assert_eq!(sm.resolve_lua_to_pasta("C:/proj/cache/a.lua", 11), None);
assert_eq!(sm.resolve_lua_to_pasta("C:/proj/cache/a.lua", 999), None);
}
#[test]
fn source_map_resolve_pasta_to_lua_returns_all_ascending() {
let sm = sample_source_map();
let resolved = sm.resolve_pasta_to_lua("C:/proj/scene/a.pasta", 7);
#[cfg(windows)]
let expected = vec![
("c:/proj/cache/a.lua".to_string(), 12u32),
("c:/proj/cache/a.lua".to_string(), 13u32),
("c:/proj/cache/c.lua".to_string(), 4u32),
];
#[cfg(not(windows))]
let expected = vec![
("C:/proj/cache/a.lua".to_string(), 12u32),
("C:/proj/cache/a.lua".to_string(), 13u32),
("C:/proj/cache/c.lua".to_string(), 4u32),
];
assert_eq!(resolved, expected);
let resolved_b = sm.resolve_pasta_to_lua(r"C:\proj\scene\b.pasta", 7);
#[cfg(windows)]
assert_eq!(resolved_b, vec![("c:/proj/cache/b.lua".to_string(), 5u32)]);
#[cfg(not(windows))]
assert_eq!(resolved_b, vec![("C:/proj/cache/b.lua".to_string(), 5u32)]);
assert!(sm.resolve_pasta_to_lua("C:/proj/scene/a.pasta", 100).is_empty());
assert!(sm.resolve_pasta_to_lua("C:/proj/scene/zzz.pasta", 7).is_empty());
}
#[test]
fn source_map_nearest_pasta_line_with_mapping() {
let sm = sample_source_map();
assert_eq!(sm.nearest_pasta_line_with_mapping("C:/proj/scene/a.pasta", 3), Some(3));
assert_eq!(sm.nearest_pasta_line_with_mapping("C:/proj/scene/a.pasta", 4), Some(7));
assert_eq!(sm.nearest_pasta_line_with_mapping("C:/proj/scene/a.pasta", 8), Some(9));
assert_eq!(sm.nearest_pasta_line_with_mapping("C:/proj/scene/a.pasta", 1), Some(3));
assert_eq!(sm.nearest_pasta_line_with_mapping("C:/proj/scene/a.pasta", 10), None);
#[cfg(windows)]
assert_eq!(sm.nearest_pasta_line_with_mapping(r"C:\PROJ\Scene\A.pasta", 4), Some(7));
assert_eq!(sm.nearest_pasta_line_with_mapping("C:/proj/scene/zzz.pasta", 1), None);
}
#[test]
fn source_map_resolution_is_deterministic_across_calls() {
let sm = sample_source_map();
let first = sm.resolve_pasta_to_lua("C:/proj/scene/a.pasta", 7);
let second = sm.resolve_pasta_to_lua("C:/proj/scene/a.pasta", 7);
assert_eq!(first, second);
assert_eq!(
sm.nearest_pasta_line_with_mapping("C:/proj/scene/a.pasta", 4),
sm.nearest_pasta_line_with_mapping("C:/proj/scene/a.pasta", 4)
);
}
use tempfile::TempDir;
#[test]
fn sidecar_path_appends_map_next_to_lua() {
let lua = Path::new("/cache/pasta/scene/sys.lua");
assert_eq!(
sidecar_path_for_lua(lua),
PathBuf::from("/cache/pasta/scene/sys.lua.map"),
"サイドカーは生成 .lua の隣に <lua>.map として並ぶ(3.2)"
);
}
#[test]
fn write_then_read_sidecar_round_trips_to_memory_map() {
let dir = TempDir::new().unwrap();
let lua_path = dir.path().join("sys.lua");
let map = sample_map(); let pasta_file = "dict.pasta";
write_sidecar(&lua_path, pasta_file, &map).expect("write_sidecar must succeed");
let sidecar = sidecar_path_for_lua(&lua_path);
assert!(sidecar.exists(), "サイドカー <lua>.map が生成されること(3.2)");
let raw = std::fs::read_to_string(&sidecar).unwrap();
let parsed: SidecarFile = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.version, SIDECAR_VERSION, "version フィールドを持つ(602)");
assert_eq!(parsed.pasta_file, pasta_file, "pasta_file フィールドを持つ(602)");
assert_eq!(
parsed.pairs,
vec![[10, 3], [12, 7], [13, 7], [15, 7], [20, 9]],
"行ペア列は .lua 行昇順で [lua_line, pasta_line](602/8.3)"
);
let reread = read_sidecar(&lua_path).expect("read_sidecar must succeed");
for lua_line in [10u32, 12, 13, 15, 20] {
assert_eq!(
reread.pasta_for_lua(lua_line),
map.pasta_for_lua(lua_line),
"再読込写像はメモリ写像と同一の lua_line→pasta_line ペアを持つ(3.2)"
);
}
assert_eq!(reread.pasta_for_lua(11), None);
assert_eq!(reread.len(), map.len());
assert_eq!(reread.lua_lines_for_pasta(7), vec![12, 13, 15]);
}
#[test]
fn write_sidecar_is_byte_deterministic() {
let dir = TempDir::new().unwrap();
let lua_a = dir.path().join("a.lua");
let lua_b = dir.path().join("b.lua");
let map = sample_map();
write_sidecar(&lua_a, "dict.pasta", &map).unwrap();
write_sidecar(&lua_b, "dict.pasta", &map).unwrap();
let bytes_a = std::fs::read(sidecar_path_for_lua(&lua_a)).unwrap();
let bytes_b = std::fs::read(sidecar_path_for_lua(&lua_b)).unwrap();
assert_eq!(bytes_a, bytes_b, "同一マップ → 同一バイト列(冪等・484)");
write_sidecar(&lua_a, "dict.pasta", &map).unwrap();
let bytes_a2 = std::fs::read(sidecar_path_for_lua(&lua_a)).unwrap();
assert_eq!(bytes_a, bytes_a2, "再書き込みも同一バイト列(冪等・484)");
}
#[test]
fn write_sidecar_failure_is_non_fatal_and_leaves_memory_map_intact() {
let dir = TempDir::new().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"i am a file, not a dir").unwrap();
let bad_lua = blocker.join("sub").join("x.lua");
assert!(blocker.is_file(), "前提: 親パスはファイル(ディレクトリ作成不能)");
let map = sample_map();
let before = map.len();
let result = write_sidecar(&bad_lua, "dict.pasta", &map);
assert!(
result.is_err(),
"書き込み不能パスは Err を返す(非致命・611)。panic/abort しない"
);
assert_eq!(map.len(), before, "メモリ写像は不変(3.1)");
assert_eq!(map.pasta_for_lua(10), Some(&pos(3)));
assert_eq!(map.pasta_for_lua(20), Some(&pos(9)));
assert!(!sidecar_path_for_lua(&bad_lua).exists());
}
#[test]
fn chunk_source_map_empty_has_no_mappings() {
let empty = ChunkSourceMap::new();
assert_eq!(empty.len(), 0);
assert!(empty.is_empty());
assert_eq!(empty.pasta_for_lua(1), None);
assert_eq!(empty.lua_lines_for_pasta(1), Vec::<u32>::new());
let defaulted = ChunkSourceMap::default();
assert!(defaulted.is_empty());
assert_eq!(defaulted.pasta_for_lua(1), None);
let map = sample_map();
assert_eq!(map.len(), 5);
assert!(!map.is_empty());
}
#[test]
fn map_builder_sink_finish_with_no_records_yields_empty_map() {
let (_out, shift) = normalize_output_with_shift("a\nb\n");
let sink = MapBuilderSink::new("dict.pasta".to_string(), "chunk".to_string());
let map = sink.finish(&shift);
assert!(map.is_empty());
assert_eq!(map.pasta_for_lua(1), None);
}
#[test]
fn canonicalize_chunk_name_is_idempotent_and_strips_only_leading_at() {
let plain = canonicalize_chunk_name(r"C:\proj\cache\sys.lua");
assert!(!plain.contains('\\'));
#[cfg(windows)]
assert_eq!(plain, "c:/proj/cache/sys.lua");
#[cfg(not(windows))]
assert_eq!(plain, "C:/proj/cache/sys.lua");
assert_eq!(canonicalize_chunk_name(&plain), plain);
let hook = canonicalize_chunk_name(r"@C:\proj\cache\sys.lua");
assert_eq!(canonicalize_chunk_name(&hook), hook);
assert_eq!(canonicalize_chunk_name("@@x.lua"), "@x.lua");
assert_eq!(canonicalize_chunk_name("a@b.lua"), "a@b.lua");
assert_eq!(canonicalize_chunk_name(""), "");
assert_eq!(canonicalize_chunk_name("@"), "");
}
#[test]
fn source_map_empty_resolves_to_nothing() {
let sm = SourceMap::new();
assert_eq!(sm.resolve_lua_to_pasta("any.lua", 1), None);
assert!(sm.resolve_pasta_to_lua("any.pasta", 1).is_empty());
assert_eq!(sm.nearest_pasta_line_with_mapping("any.pasta", 1), None);
}
#[test]
fn source_map_insert_empty_chunk_yields_no_resolutions() {
let mut sm = SourceMap::new();
sm.insert_chunk(
"@C:/proj/cache/empty.lua".to_string(),
"C:/proj/scene/empty.pasta".to_string(),
ChunkSourceMap::new(),
);
assert_eq!(sm.resolve_lua_to_pasta("C:/proj/cache/empty.lua", 1), None);
assert!(sm.resolve_pasta_to_lua("C:/proj/scene/empty.pasta", 1).is_empty());
assert_eq!(
sm.nearest_pasta_line_with_mapping("C:/proj/scene/empty.pasta", 1),
None
);
}
#[test]
fn source_map_canonicalizes_pasta_file_key_on_store_side() {
let raw_file = r"C:\proj\scene\raw.pasta"; let mut fwd = BTreeMap::new();
fwd.insert(10u32, pos_in(raw_file, 3));
let mut sm = SourceMap::new();
sm.insert_chunk(
"@C:/proj/cache/raw.lua".to_string(),
raw_file.to_string(),
ChunkSourceMap::from_forward(fwd),
);
#[cfg(windows)]
let expected_chunk = "c:/proj/cache/raw.lua".to_string();
#[cfg(not(windows))]
let expected_chunk = "C:/proj/cache/raw.lua".to_string();
assert_eq!(
sm.resolve_pasta_to_lua("C:/proj/scene/raw.pasta", 3),
vec![(expected_chunk, 10u32)]
);
assert_eq!(
sm.nearest_pasta_line_with_mapping("C:/proj/scene/raw.pasta", 1),
Some(3)
);
assert_eq!(
sm.resolve_lua_to_pasta("C:/proj/cache/raw.lua", 10),
Some(&pos_in(raw_file, 3))
);
}
#[test]
fn read_sidecar_missing_or_corrupt_returns_err_without_panic() {
let dir = TempDir::new().unwrap();
let lua_path = dir.path().join("sys.lua");
let missing = read_sidecar(&lua_path);
assert_eq!(
missing.expect_err("不存在は Err").kind(),
std::io::ErrorKind::NotFound
);
std::fs::write(sidecar_path_for_lua(&lua_path), b"{ not valid json !!").unwrap();
let corrupt = read_sidecar(&lua_path);
assert_eq!(
corrupt.expect_err("JSON 不正は Err").kind(),
std::io::ErrorKind::InvalidData
);
}
#[test]
fn sidecar_to_chunk_duplicate_lua_line_is_last_write_wins() {
let sidecar = SidecarFile {
version: SIDECAR_VERSION,
pasta_file: "dict.pasta".to_string(),
pairs: vec![[5, 1], [5, 9], [7, 2]],
};
let map = sidecar.to_chunk();
assert_eq!(map.pasta_for_lua(5), Some(&pos(9)));
assert_eq!(map.lua_lines_for_pasta(1), Vec::<u32>::new());
assert_eq!(map.lua_lines_for_pasta(9), vec![5]);
assert_eq!(map.len(), 2);
}
#[test]
fn read_sidecar_currently_accepts_unknown_version() {
let dir = TempDir::new().unwrap();
let lua_path = dir.path().join("sys.lua");
let future = SidecarFile {
version: SIDECAR_VERSION + 999,
pasta_file: "dict.pasta".to_string(),
pairs: vec![[10, 3]],
};
std::fs::write(
sidecar_path_for_lua(&lua_path),
serde_json::to_vec(&future).unwrap(),
)
.unwrap();
let reread = read_sidecar(&lua_path).expect("未知 version でも現行は読める");
assert_eq!(reread.pasta_for_lua(10), Some(&pos(3)));
}
#[test]
fn write_sidecar_creates_parents_and_round_trips_empty_map() {
let dir = TempDir::new().unwrap();
let lua_path = dir.path().join("nested").join("deep").join("x.lua");
let empty = ChunkSourceMap::new();
write_sidecar(&lua_path, "dict.pasta", &empty)
.expect("親ディレクトリを作成して書き込めること");
assert!(sidecar_path_for_lua(&lua_path).exists());
let reread = read_sidecar(&lua_path).expect("read_sidecar");
assert!(reread.is_empty());
let parsed: SidecarFile =
serde_json::from_slice(&std::fs::read(sidecar_path_for_lua(&lua_path)).unwrap())
.unwrap();
assert_eq!(parsed.pairs, Vec::<[u32; 2]>::new());
assert_eq!(parsed.version, SIDECAR_VERSION);
}
#[test]
fn sidecar_path_appends_for_any_extension() {
assert_eq!(
sidecar_path_for_lua(Path::new("/cache/noext")),
PathBuf::from("/cache/noext.map")
);
assert_eq!(
sidecar_path_for_lua(Path::new("/cache/a.tar.lua")),
PathBuf::from("/cache/a.tar.lua.map")
);
}
#[test]
fn source_map_reinsert_same_chunk_replaces_reverse_entries() {
let file_a = "C:/proj/scene/a.pasta";
let file_b = "C:/proj/scene/b.pasta";
let mut fwd_other = BTreeMap::new();
fwd_other.insert(50u32, pos_in(file_a, 3));
let mut fwd_v1 = BTreeMap::new();
fwd_v1.insert(10u32, pos_in(file_a, 3));
fwd_v1.insert(12u32, pos_in(file_a, 7));
let mut sm = SourceMap::new();
sm.insert_chunk(
"@C:/proj/cache/other.lua".to_string(),
file_a.to_string(),
ChunkSourceMap::from_forward(fwd_other),
);
sm.insert_chunk(
"@C:/proj/cache/x.lua".to_string(),
file_a.to_string(),
ChunkSourceMap::from_forward(fwd_v1),
);
let mut fwd_v2 = BTreeMap::new();
fwd_v2.insert(20u32, pos_in(file_a, 5));
sm.insert_chunk(
"@C:/proj/cache/x.lua".to_string(),
file_a.to_string(),
ChunkSourceMap::from_forward(fwd_v2),
);
#[cfg(windows)]
let (x_key, other_key) = (
"c:/proj/cache/x.lua".to_string(),
"c:/proj/cache/other.lua".to_string(),
);
#[cfg(not(windows))]
let (x_key, other_key) = (
"C:/proj/cache/x.lua".to_string(),
"C:/proj/cache/other.lua".to_string(),
);
assert_eq!(sm.resolve_lua_to_pasta("C:/proj/cache/x.lua", 10), None);
assert_eq!(
sm.resolve_lua_to_pasta("C:/proj/cache/x.lua", 20),
Some(&pos_in(file_a, 5))
);
assert_eq!(
sm.resolve_pasta_to_lua(file_a, 3),
vec![(other_key.clone(), 50u32)],
"再投入チャンクの旧エントリのみ除去・他チャンクは温存"
);
assert!(
sm.resolve_pasta_to_lua(file_a, 7).is_empty(),
"旧 v1 だけが持っていた .pasta 行 7 は対応なしへ戻る"
);
assert_eq!(
sm.resolve_pasta_to_lua(file_a, 5),
vec![(x_key.clone(), 20u32)]
);
assert_eq!(sm.nearest_pasta_line_with_mapping(file_a, 4), Some(5));
assert_eq!(sm.nearest_pasta_line_with_mapping(file_a, 6), None);
let mut fwd_v3 = BTreeMap::new();
fwd_v3.insert(30u32, pos_in(file_b, 9));
sm.insert_chunk(
"@C:/proj/cache/x.lua".to_string(),
file_b.to_string(),
ChunkSourceMap::from_forward(fwd_v3),
);
assert!(
sm.resolve_pasta_to_lua(file_a, 5).is_empty(),
"旧ファイル a.pasta 側の v2 エントリも除去される"
);
assert_eq!(sm.resolve_pasta_to_lua(file_b, 9), vec![(x_key, 30u32)]);
assert_eq!(sm.resolve_pasta_to_lua(file_a, 3), vec![(other_key, 50u32)]);
}
}