Phase 5: Projekt- und Dokumentmodell implementieren und archivieren

This commit is contained in:
2026-09-06 15:47:40 +02:00
parent 747ec34c6a
commit 687fc230ec
23 changed files with 2261 additions and 211 deletions

View File

@@ -0,0 +1,790 @@
//! Dokumentaktionen für Editor und Designer. Kein Terminal und keine zweite Quellkopie.
use anyhow::{anyhow, bail, ensure, Context, Result};
use std::{
collections::BTreeMap,
fs::{self, OpenOptions},
io::Write,
ops::Range,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use tb_ui::frm::{self, FormFile, FormNode};
use tb_vm::project_io::{
has_extension, identity, read_document, Content, Manifest, ProjectLine, ProjectSources,
SourceLoader,
};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
fn loaded<T>(result: std::result::Result<T, String>) -> Result<T> {
result.map_err(|e| anyhow!(e))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct DocumentId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ViewId(u64);
#[derive(Debug, Clone)]
pub struct View {
document: DocumentId,
pub cursor: usize,
pub scroll_line: usize,
pub scroll_column: usize,
}
impl View {
pub fn document(&self) -> DocumentId {
self.document
}
}
#[derive(Debug)]
struct Edit {
content: Content,
views: Vec<(ViewId, View)>,
}
#[derive(Debug)]
pub struct Document {
source_path: PathBuf,
path: Option<PathBuf>,
content: Content,
revision: u64,
saved: Option<Content>,
disk: Option<Vec<u8>>,
binary_source: Option<PathBuf>,
warnings: Vec<frm::BinaryWarning>,
undo: Vec<Edit>,
}
impl Document {
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
pub fn source_path(&self) -> &Path {
&self.source_path
}
pub fn content(&self) -> &Content {
&self.content
}
pub fn code(&self) -> &str {
self.content.code()
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn is_dirty(&self) -> bool {
self.saved.as_ref() != Some(&self.content)
}
pub fn import_warnings(&self) -> &[frm::BinaryWarning] {
&self.warnings
}
}
#[derive(Debug, Clone)]
pub struct Destination {
pub path: PathBuf,
/// Ausdrückliche Entscheidung des Benutzers für dieses Ziel, kein globaler Default.
pub overwrite: bool,
}
impl Destination {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
overwrite: false,
}
}
}
#[derive(Debug, Default)]
pub struct SavePlan {
pub project: Option<Destination>,
pub files: BTreeMap<DocumentId, Destination>,
}
pub enum Decision<'a> {
Save(&'a SavePlan),
Discard,
Cancel,
}
pub enum StartupRemoval {
Default,
Replace(DocumentId),
Cancel,
}
#[derive(Debug)]
pub struct Project {
base: PathBuf,
path: Option<PathBuf>,
manifest: Manifest,
saved_manifest: Option<Manifest>,
manifest_disk: Option<Vec<u8>>,
documents: BTreeMap<DocumentId, Document>,
views: BTreeMap<ViewId, View>,
pub include_paths: Vec<PathBuf>,
}
impl Project {
pub fn new(base: &Path) -> Result<Self> {
Ok(Self {
base: identity(base)?,
path: None,
manifest: Manifest::default(),
saved_manifest: None,
manifest_disk: None,
documents: BTreeMap::new(),
views: BTreeMap::new(),
include_paths: Vec::new(),
})
}
pub fn open(path: &Path, include_paths: Vec<PathBuf>) -> Result<Self> {
let mut loader = SourceLoader::default();
loader.include_paths = include_paths.clone();
let path = identity(&loaded(
loader.resolve(Path::new("."), &path.to_string_lossy()),
)?)?;
let sources = loaded(loader.load(&path))?; // Alle Includes prüfen, bevor das alte Projekt ersetzt wird.
let mut project = Self::new(path.parent().unwrap_or(Path::new(".")))?;
project.include_paths = include_paths;
let mut manifest = sources.manifest;
for line in &mut manifest.lines {
if let ProjectLine::File(p) = line {
*p = identity(p)?;
}
}
manifest.startup = manifest.startup.as_deref().map(identity).transpose()?;
let paths: Vec<_> = manifest
.members()
.cloned()
.chain(
sources
.units
.iter()
.flat_map(|u| u.segments.iter().map(|s| PathBuf::from(&s.file))),
)
.collect();
for member in paths {
project.open_document(&member)?;
}
if has_extension(&path, "mak") {
project.manifest_disk = Some(fs::read(&path)?);
project.saved_manifest = Some(manifest.clone());
project.path = Some(path);
}
project.manifest = manifest;
Ok(project)
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn document(&self, id: DocumentId) -> Result<&Document> {
self.documents
.get(&id)
.ok_or_else(|| anyhow!("Dokument nicht geöffnet"))
}
pub fn documents(&self) -> impl Iterator<Item = (DocumentId, &Document)> {
self.documents.iter().map(|(id, d)| (*id, d))
}
pub fn find_document(&self, path: &Path) -> Option<DocumentId> {
let key = identity(path).ok()?;
self.documents
.iter()
.find_map(|(id, d)| (d.source_path == key).then_some(*id))
}
pub fn members(&self) -> Vec<DocumentId> {
self.manifest
.members()
.filter_map(|p| self.find_document(p))
.collect()
}
pub fn startup(&self) -> Option<DocumentId> {
self.manifest
.startup
.as_deref()
.and_then(|p| self.find_document(p))
}
pub fn is_dirty(&self) -> bool {
self.saved_manifest.as_ref() != Some(&self.manifest)
|| self.documents.values().any(Document::is_dirty)
}
pub fn loader(&self) -> Result<SourceLoader> {
let mut loader = SourceLoader::default();
loader.include_paths = self.include_paths.clone();
for doc in self.documents.values() {
loaded(loader.insert(&doc.source_path, doc.content.clone()))?;
}
Ok(loader)
}
pub fn sources(&self) -> Result<ProjectSources> {
loaded(self.loader()?.load_manifest(self.manifest.clone()))
}
/// Auch Includes sind eigenständige Originaldokumente, niemals die Expansion.
pub fn open_document(&mut self, path: &Path) -> Result<DocumentId> {
let loader = self.loader()?;
let path = identity(&loaded(
loader.resolve(&self.base, &path.to_string_lossy()),
)?)?;
if let Some(id) = self.find_document(&path) {
return Ok(id);
}
ensure!(!has_extension(&path, "mak"), "MAK über Open Project öffnen");
let read = loaded(read_document(&path))?;
let id = DocumentId(next_id());
self.documents.insert(
id,
Document {
source_path: path.clone(),
path: Some(path.clone()),
saved: Some(read.content.clone()),
content: read.content,
revision: 0,
disk: Some(read.bytes),
binary_source: read.binary.then_some(path),
warnings: read.warnings,
undo: Vec::new(),
},
);
Ok(id)
}
pub fn add_file(&mut self, path: &Path) -> Result<DocumentId> {
ensure!(
has_extension(path, "bas") || has_extension(path, "frm"),
"Projektmitglied muss BAS oder FRM sein"
);
// Auch die hinzugefügte Include-Kette muss vor jeder Änderung gültig sein.
let loader = self.loader()?;
let path = identity(&loaded(
loader.resolve(&self.base, &path.to_string_lossy()),
)?)?;
loaded(loader.load(&path))?;
let id = self.open_document(&path)?;
if !self.manifest.members().any(|p| p == &path) {
self.manifest.lines.push(ProjectLine::File(path));
}
Ok(id)
}
fn create(&mut self, name: &str, content: Content, extension: &str) -> Result<DocumentId> {
ensure!(
!name.is_empty()
&& name.trim() == name
&& !name.contains(['/', '\\', '\n', '\r', '\0', '"'])
&& name != "."
&& name != "..",
"Ungültiger Dokumentname"
);
let path = identity(&self.base.join(format!("{name}.{extension}")))?;
ensure!(
self.loader()?
.resolve(&self.base, &path.to_string_lossy())
.is_err(),
"{} existiert bereits",
path.display()
);
let id = DocumentId(next_id());
self.documents.insert(
id,
Document {
source_path: path.clone(),
path: None,
content,
revision: 0,
saved: None,
disk: None,
binary_source: None,
warnings: Vec::new(),
undo: Vec::new(),
},
);
self.manifest.lines.push(ProjectLine::File(path));
Ok(id)
}
pub fn new_module(&mut self, name: &str) -> Result<DocumentId> {
self.create(name, Content::Text(String::new()), "bas")
}
pub fn new_form(&mut self, name: &str) -> Result<DocumentId> {
let tokens = tb_frontend::lexer::lex(name);
ensure!(
matches!(
tokens.tokens.as_slice(),
[
tb_frontend::lexer::Token {
kind: tb_frontend::lexer::TokenKind::Ident { suffix: None, .. },
..
},
tb_frontend::lexer::Token {
kind: tb_frontend::lexer::TokenKind::Eol,
..
},
tb_frontend::lexer::Token {
kind: tb_frontend::lexer::TokenKind::Eof,
..
}
]
),
"Ungültiger Formularname"
);
let root = FormNode {
class: tb_frontend::forms::ObjectClass::Form,
name: name.into(),
properties: BTreeMap::new(),
children: Vec::new(),
};
self.create(
name,
Content::Form(Box::new(FormFile::new("1.00", root, ""))),
"frm",
)
}
pub fn set_startup(&mut self, id: Option<DocumentId>) -> Result<()> {
let path = id
.map(|id| self.document(id).map(|d| d.source_path.clone()))
.transpose()?;
if let Some(path) = &path {
ensure!(
self.manifest.members().any(|p| p == path),
"Startdatei ist kein Projektmitglied"
);
}
self.manifest.startup = path;
Ok(())
}
pub fn remove_file(&mut self, id: DocumentId, choice: Option<StartupRemoval>) -> Result<bool> {
let path = self.document(id)?.source_path.clone();
ensure!(
self.members().contains(&id),
"Datei ist kein Projektmitglied"
);
if self.startup() == Some(id) {
match choice.ok_or_else(|| {
anyhow!("Startdatei entfernen: Ersatz, Standard oder Abbrechen wählen")
})? {
StartupRemoval::Cancel => return Ok(false),
StartupRemoval::Default => self.set_startup(None)?,
StartupRemoval::Replace(other) => {
ensure!(other != id, "Ersatz muss ein anderes Mitglied sein");
self.set_startup(Some(other))?;
}
}
}
self.manifest
.lines
.retain(|line| !matches!(line,ProjectLine::File(p) if p==&path));
// Geöffnete Ansichten und ungespeicherte Änderungen bleiben erhalten.
Ok(true)
}
pub fn open_view(&mut self, document: DocumentId) -> Result<ViewId> {
self.document(document)?;
let id = ViewId(next_id());
self.views.insert(
id,
View {
document,
cursor: 0,
scroll_line: 0,
scroll_column: 0,
},
);
Ok(id)
}
pub fn view(&self, id: ViewId) -> Result<&View> {
self.views
.get(&id)
.ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))
}
pub fn view_mut(&mut self, id: ViewId) -> Result<&mut View> {
self.views
.get_mut(&id)
.ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))
}
pub fn close_view(&mut self, id: ViewId) -> Result<()> {
self.views
.remove(&id)
.ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))?;
Ok(())
}
pub fn view_code(&self, id: ViewId) -> Result<&str> {
Ok(self.document(self.view(id)?.document)?.code())
}
fn commit_edit(&mut self, id: DocumentId, content: Content) -> Result<()> {
let old = self.document(id)?;
if old.content == content {
return Ok(());
}
let undo = Edit {
content: old.content.clone(),
views: self
.views
.iter()
.filter(|(_, v)| v.document == id)
.map(|(i, v)| (*i, v.clone()))
.collect(),
};
let doc = self.documents.get_mut(&id).unwrap();
doc.undo.push(undo);
doc.content = content;
doc.revision += 1;
let code = doc.code();
for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor);
}
Ok(())
}
pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> {
let mut content = self.document(id)?.content.clone();
ensure!(
range.start <= range.end && content.code().get(range.clone()).is_some(),
"Ungültiger Textbereich"
);
content.code_mut().replace_range(range.clone(), text);
let cursors: Vec<_> = self
.views
.iter()
.filter(|(_, v)| v.document == id)
.map(|(id, v)| (*id, v.cursor))
.collect();
self.commit_edit(id, content)?;
for (view_id, cursor) in cursors {
let view = self.views.get_mut(&view_id).unwrap();
view.cursor = cursor;
if view.cursor > range.start {
view.cursor = if view.cursor >= range.end {
view.cursor - range.len() + text.len()
} else {
range.start + text.len()
};
}
}
// Neue Positionen werden auch nach Ersetzungen mit Unicode begrenzt.
let code = self.documents[&id].code();
for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor);
}
Ok(())
}
pub fn edit_form(
&mut self,
id: DocumentId,
edit: impl FnOnce(&mut FormFile) -> Result<()>,
) -> Result<()> {
let Content::Form(mut form) = self.document(id)?.content.clone() else {
bail!("Kein Formulardokument");
};
edit(&mut form)?;
frm::read_text(
&self.document(id)?.source_path.display().to_string(),
&frm::write_text(&form),
)?;
self.commit_edit(id, Content::Form(form))
}
pub fn undo(&mut self, id: DocumentId) -> Result<bool> {
self.document(id)?;
let doc = self.documents.get_mut(&id).unwrap();
let Some(edit) = doc.undo.pop() else {
return Ok(false);
};
doc.content = edit.content;
doc.revision += 1;
for (id, view) in edit.views {
if let Some(current) = self.views.get_mut(&id) {
*current = view;
}
}
let code = doc.code();
for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor);
}
Ok(true)
}
pub fn load_text(&mut self, id: DocumentId, at: usize, path: &Path) -> Result<()> {
let text = fs::read_to_string(path).with_context(|| format!("{} lesen", path.display()))?;
self.replace_text(id, at..at, &text)
}
pub fn save_text(
&self,
id: DocumentId,
range: Option<Range<usize>>,
target: &Destination,
) -> Result<()> {
let doc = self.document(id)?;
let text = match range {
Some(r) => doc
.code()
.get(r)
.ok_or_else(|| anyhow!("Ungültiger Textbereich"))?,
None => doc.code(),
};
let path = self.target(&target.path)?;
ensure!(
self.documents.values().all(|d| d.source_path != path),
"Save Text darf kein geöffnetes Dokument überschreiben; Save File verwenden"
);
self.protect_binary(&path)?;
ensure!(
self.path.as_deref() != Some(&path),
"Save Text darf die Projektdatei nicht überschreiben"
);
atomic_write(&path, text.as_bytes(), None, target.overwrite)
}
fn target(&self, path: &Path) -> Result<PathBuf> {
ensure!(!path.as_os_str().is_empty(), "Ausgabepfad fehlt");
let target = identity(&self.base.join(path))?;
ensure!(
target
.to_str()
.is_some_and(|s| !s.contains(['\n', '\r', '\0'])),
"Ausgabepfad ist nicht als UTF-8-Dateiverweis darstellbar"
);
Ok(target)
}
fn protect_binary(&self, path: &Path) -> Result<()> {
ensure!(
!fs::read(path).is_ok_and(|b| b.starts_with(&[0xfc, 0x08, 1, 0])),
"{}: binäre FRM-Quelle ist geschützt; anderes Textziel wählen",
path.display()
);
ensure!(
!self
.documents
.values()
.any(|d| d.binary_source.as_deref() == Some(path)),
"{}: binäre FRM-Quelle ist geschützt; anderes Textziel wählen",
path.display()
);
Ok(())
}
fn file_target(&self, id: DocumentId, target: Option<&Destination>) -> Result<(PathBuf, bool)> {
let doc = self.document(id)?;
let path = match target {
Some(t) => self.target(&t.path)?,
None => doc.path.clone().ok_or_else(|| {
anyhow!("{}: Save-As-Ziel erforderlich", doc.source_path.display())
})?,
};
self.protect_binary(&path)?;
ensure!(
self.path.as_deref() != Some(&path),
"Dokument darf die Projektdatei nicht überschreiben"
);
ensure!(
!self
.documents
.iter()
.any(|(other, d)| *other != id && d.source_path == path),
"{} ist in einem anderen Dokument geöffnet",
path.display()
);
match &doc.content {
Content::Form(_) => ensure!(has_extension(&path, "frm"), "Formularziel muss .frm sein"),
Content::Text(_) if self.members().contains(&id) => {
ensure!(has_extension(&path, "bas"), "Modulziel muss .bas sein")
}
Content::Text(_) => ensure!(
!has_extension(&path, "frm") && !has_extension(&path, "mak"),
"Textziel darf kein FRM/MAK sein"
),
}
Ok((path, target.is_some_and(|t| t.overwrite)))
}
pub fn save_file(&mut self, id: DocumentId, target: Option<&Destination>) -> Result<()> {
let (path, overwrite) = self.file_target(id, target)?;
let doc = self.document(id)?;
let content = loaded(
self.loader()?
.relocate(&doc.content, &doc.source_path, &path),
)?;
let text = content.text();
let expected = if doc.path.as_deref() == Some(&path) {
doc.disk.as_deref()
} else {
None
};
atomic_write(&path, text.as_bytes(), expected, overwrite)?;
self.commit_edit(id, content)?;
let doc = self.documents.get_mut(&id).unwrap();
self.manifest.rename(&doc.source_path, &path);
if doc.source_path != path {
doc.revision += 1;
}
doc.source_path = path.clone();
doc.path = Some(path);
doc.disk = Some(text.into_bytes());
doc.saved = Some(doc.content.clone());
Ok(())
}
pub fn save_project(&mut self, plan: &SavePlan) -> Result<()> {
let target = match &plan.project {
Some(t) => self.target(&t.path)?,
None => self
.path
.clone()
.ok_or_else(|| anyhow!("Projekt-Save-As-Ziel erforderlich"))?,
};
ensure!(has_extension(&target, "mak"), "Projektziel muss .mak sein");
self.protect_binary(&target)?;
ensure!(
self.documents.values().all(|d| d.source_path != target),
"Projektziel ist ein geöffnetes Dokument"
);
for id in plan.files.keys() {
self.document(*id)?;
}
let mut ids = self.members();
ids.extend(
self.documents
.keys()
.filter(|id| !self.members().contains(id))
.copied(),
);
let ids: Vec<_> = ids
.into_iter()
.filter(|id| self.documents[id].is_dirty() || plan.files.contains_key(id))
.collect();
let mut destinations = Vec::new();
let mut prospective = self.manifest.clone();
for id in &ids {
let (path, _) = self.file_target(*id, plan.files.get(id))?;
ensure!(
!destinations.contains(&path) && path != target,
"Mehrere Ausgaben verwenden {}",
path.display()
);
prospective.rename(&self.documents[id].source_path, &path);
destinations.push(path);
}
let text = loaded(prospective.text(&target))?; // Vollständig serialisierbar, bevor eine Datei geschrieben wird.
let expected = if self.path.as_deref() == Some(&target) {
self.manifest_disk.as_deref()
} else {
None
};
check_destination(
&target,
expected,
plan.project.as_ref().is_some_and(|d| d.overwrite),
)?;
for id in ids {
self.save_file(id, plan.files.get(&id))?;
}
// Zweite Konfliktprüfung direkt vor Ersetzen; ein Teilfehler lässt die alte Projektidentität aktiv.
let expected = if self.path.as_deref() == Some(&target) {
self.manifest_disk.as_deref()
} else {
None
};
atomic_write(
&target,
text.as_bytes(),
expected,
plan.project.as_ref().is_some_and(|d| d.overwrite),
)?;
self.manifest_disk = Some(text.into_bytes());
self.saved_manifest = Some(self.manifest.clone());
self.base = target.parent().unwrap().into();
self.path = Some(target);
Ok(())
}
/// Vor Exit/Projektwechsel aufrufen; der Aufrufer schließt erst bei true.
pub fn prepare_close(&mut self, decision: Decision<'_>) -> Result<bool> {
match decision {
Decision::Cancel => Ok(false),
Decision::Discard => Ok(true),
Decision::Save(plan) => {
self.save_project(plan)?;
ensure!(!self.is_dirty(), "Nicht alle Änderungen gespeichert");
Ok(true)
}
}
}
pub fn open_project(&mut self, path: &Path, decision: Decision<'_>) -> Result<bool> {
if matches!(decision, Decision::Cancel) {
return Ok(false);
}
let path = self.base.join(path);
let candidate = Self::open(&path, self.include_paths.clone())?;
let saving = matches!(decision, Decision::Save(_));
if !self.prepare_close(decision)? {
return Ok(false);
}
*self = if saving {
Self::open(&path, self.include_paths.clone())?
} else {
candidate
};
Ok(true)
}
pub fn new_project(&mut self, decision: Decision<'_>) -> Result<bool> {
let mut candidate = Self::new(&self.base)?;
candidate.include_paths = self.include_paths.clone();
if !self.prepare_close(decision)? {
return Ok(false);
}
*self = candidate;
Ok(true)
}
}
fn boundary(text: &str, at: usize) -> usize {
let mut at = at.min(text.len());
while !text.is_char_boundary(at) {
at -= 1;
}
at
}
fn check_destination(path: &Path, expected: Option<&[u8]>, overwrite: bool) -> Result<()> {
let actual = match fs::read(path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e).with_context(|| format!("{} lesen", path.display())),
};
ensure!(
overwrite || actual.as_deref() == expected,
"{}: Datei extern geändert oder Ziel existiert; Überschreibentscheidung erforderlich",
path.display()
);
if let Ok(metadata) = fs::metadata(path) {
ensure!(
!metadata.permissions().readonly(),
"{}: Datei ist schreibgeschützt",
path.display()
);
}
Ok(())
}
/// Ersetzt erst nach erfolgreichem Schreiben und flush/sync; temporäre Datei im selben Verzeichnis.
fn atomic_write(path: &Path, bytes: &[u8], expected: Option<&[u8]>, overwrite: bool) -> Result<()> {
check_destination(path, expected, overwrite)?;
let parent = path
.parent()
.ok_or_else(|| anyhow!("Ungültiges Dateiziel"))?;
let (temporary, mut file) = loop {
let p = parent.join(format!(".tb-save-{}-{}.tmp", std::process::id(), next_id()));
match OpenOptions::new().write(true).create_new(true).open(&p) {
Ok(file) => break (p, file),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e).with_context(|| format!("{} speichern", path.display())),
}
};
struct Cleanup(PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
let cleanup = Cleanup(temporary);
let result = (|| -> Result<()> {
file.write_all(bytes)?;
if let Ok(metadata) = fs::metadata(path) {
file.set_permissions(metadata.permissions())?;
}
file.sync_all()?;
drop(file);
check_destination(path, expected, overwrite)?;
fs::rename(&cleanup.0, path)?;
Ok(())
})();
result.with_context(|| format!("{} speichern", path.display()))
}

2
crates/tb-ide/src/lib.rs Normal file
View File

@@ -0,0 +1,2 @@
//! Testbare IDE-Zustände; Terminalbedienung wird vom IDE-Rahmen angebunden.
pub mod documents;

View File

@@ -0,0 +1,489 @@
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::documents::{Decision, Destination, Project, SavePlan, StartupRemoval};
use tb_vm::project_io::{Content, SourceLoader};
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let p = std::env::temp_dir().join(format!(
"tb-documents-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
fn write(&self, name: &str, text: &str) -> PathBuf {
let p = self.0.join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, text).unwrap();
p
}
fn plan(&self, name: &str) -> SavePlan {
SavePlan {
project: Some(Destination::new(self.0.join(name))),
..SavePlan::default()
}
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn project_create_save_reopen_preserves_forms_members_startup_and_cli_sources() {
let t = Temp::new();
let mut p = Project::new(&t.0).unwrap();
let a = p.new_module("Main").unwrap();
let b = p.new_module("Lib").unwrap();
let form = p.new_form("Form1").unwrap();
p.replace_text(a, 0..0, "CALL Answer\nEND\n").unwrap();
p.replace_text(b, 0..0, "SUB Answer\nPRINT 7\nEND SUB\n")
.unwrap();
p.edit_form(form,|f| {let parsed=tb_ui::frm::read_text("f.frm","VERSION 1.00\nBegin Form Form1\n Begin CommandButton Button1\n Index = 0\n End\n Begin CommandButton Button1\n Index = 2\n End\nEnd\n\nSUB Button1_Click(Index AS INTEGER)\nEND SUB\n")?;*f=parsed;Ok(())}).unwrap();
p.set_startup(Some(a)).unwrap();
let mut plan = t.plan("p.mak");
for (id, name) in [(a, "Main.bas"), (b, "Lib.bas"), (form, "Form1.frm")] {
plan.files.insert(id, Destination::new(t.0.join(name)));
}
p.save_project(&plan).unwrap();
assert!(!p.is_dirty());
let restored = Project::open(&t.0.join("p.mak"), vec![]).unwrap();
assert_eq!(restored.members().len(), 3);
assert_eq!(
restored
.document(restored.startup().unwrap())
.unwrap()
.path()
.unwrap()
.file_name()
.unwrap(),
"Main.bas"
);
let input = restored.sources().unwrap();
assert_eq!(input.forms[0].root.children.len(), 2);
assert!(input.forms[0].code.contains("Button1_Click"));
assert!(fs::read_to_string(t.0.join("Form1.frm"))
.unwrap()
.lines()
.any(|line| line.split_whitespace().collect::<Vec<_>>() == ["Index", "=", "0"]));
let disk = SourceLoader::default().load(&t.0.join("p.mak")).unwrap();
assert_eq!(disk.forms, input.forms);
let mut catalog = tb_frontend::forms::FormCatalog::default();
for f in &disk.forms {
catalog.append(&f.catalog());
}
tb_vm::compile_project("P", &disk.units, &catalog, &disk.forms).unwrap();
}
#[cfg(unix)]
#[test]
fn unrepresentable_save_paths_fail_before_writing_or_changing_documents() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
let t = Temp::new();
let mut p = Project::new(&t.0).unwrap();
let id = p.new_module("Main").unwrap();
p.replace_text(id, 0..0, "PRINT 1\n").unwrap();
for name in [
OsString::from_vec(b"bad\xff.bas".to_vec()),
"bad\n.bas".into(),
] {
let target = t.0.join(name);
assert!(p.save_file(id, Some(&Destination::new(&target))).is_err());
assert!(!target.exists());
assert!(p.document(id).unwrap().path().is_none());
assert!(p.document(id).unwrap().is_dirty());
assert_eq!(p.document(id).unwrap().code(), "PRINT 1\n");
}
let manifest = tb_vm::project_io::Manifest {
lines: vec![tb_vm::project_io::ProjectLine::File(
t.0.join(OsString::from_vec(b"bad\xff.bas".to_vec())),
)],
startup: None,
};
assert!(manifest.text(&t.0.join("p.mak")).is_err());
assert_eq!(fs::read_dir(&t.0).unwrap().count(), 0);
}
#[test]
fn shared_views_text_import_and_undo_are_document_transactions() {
let t = Temp::new();
let mut p = Project::new(&t.0).unwrap();
let id = p.new_module("Main").unwrap();
p.replace_text(id, 0..0, "äbcdef\n").unwrap();
let a = p.open_view(id).unwrap();
let b = p.open_view(id).unwrap();
p.view_mut(a).unwrap().cursor = 2;
p.view_mut(b).unwrap().cursor = 7;
p.view_mut(b).unwrap().scroll_line = 9;
let import = t.write("text.txt", "HELLO");
p.load_text(id, 2, &import).unwrap();
assert_eq!(p.view_code(a).unwrap(), "äHELLObcdef\n");
assert_eq!(p.view_code(a).unwrap(), p.view_code(b).unwrap());
assert_eq!(p.view(b).unwrap().cursor, 12);
assert_eq!(p.view(a).unwrap().cursor, 2);
assert!(p.undo(id).unwrap());
assert_eq!(p.document(id).unwrap().code(), "äbcdef\n");
assert_eq!(p.view(b).unwrap().cursor, 7);
assert_eq!(p.view(b).unwrap().scroll_line, 9);
p.replace_text(id, 2..7, "").unwrap();
assert_eq!(p.view(b).unwrap().cursor, 2);
p.undo(id).unwrap();
p.close_view(a).unwrap();
assert!(p.document(id).is_ok());
assert_eq!(p.view_code(b).unwrap(), "äbcdef\n");
let rev = p.document(id).unwrap().revision();
assert!(p.replace_text(id, 1..2, "bad").is_err());
assert_eq!(p.document(id).unwrap().revision(), rev);
p.save_text(id, Some(0..2), &Destination::new(t.0.join("selection.txt")))
.unwrap();
assert_eq!(fs::read_to_string(t.0.join("selection.txt")).unwrap(), "ä");
}
#[test]
fn edited_include_wins_in_both_modules_without_writing_the_expansion() {
let t = Temp::new();
let path = t.write("p.mak", "a.bas\nb.bas\n");
t.write("a.bas", "'$INCLUDE: 'shared.bi'\nEND\n");
t.write("b.bas", "'$INCLUDE: 'shared.bi'\n");
let file = t.write("shared.bi", "CONST N=1\n");
let mut p = Project::open(&path, vec![]).unwrap();
let id = p.open_document(&file).unwrap();
assert_eq!(p.open_document(&file).unwrap(), id);
p.replace_text(id, 8..9, "9").unwrap();
let input = p.sources().unwrap();
for u in input.units {
assert!(u.segments.iter().any(|s| s.text == "CONST N=9\n"));
}
assert_eq!(fs::read_to_string(&file).unwrap(), "CONST N=1\n");
p.save_file(id, None).unwrap();
assert_eq!(fs::read_to_string(&file).unwrap(), "CONST N=9\n");
assert_eq!(
fs::read_to_string(t.0.join("a.bas")).unwrap(),
"'$INCLUDE: 'shared.bi'\nEND\n"
);
}
#[test]
fn failed_open_add_and_cancel_do_not_replace_existing_documents() {
let t = Temp::new();
let mut p = Project::new(&t.0).unwrap();
let id = p.new_module("Current").unwrap();
p.replace_text(id, 0..0, "PRINT 1\n").unwrap();
let cyclic = t.write("cyclic.bas", "'$INCLUDE: 'cyclic.bas'\n");
let missing = t.write("bad.mak", "missing.bas\n");
for path in [cyclic.clone(), missing] {
assert!(p.open_project(&path, Decision::Discard).is_err());
assert_eq!(p.document(id).unwrap().code(), "PRINT 1\n");
}
assert!(p.add_file(&cyclic).is_err());
assert_eq!(p.members(), vec![id]);
assert!(!p.new_project(Decision::Cancel).unwrap());
assert!(!p.prepare_close(Decision::Cancel).unwrap());
assert!(p.document(id).unwrap().is_dirty());
let valid = t.write("valid.bas", "END\n");
assert!(!p.open_project(&valid, Decision::Cancel).unwrap());
assert!(p.open_project(&valid, Decision::Discard).unwrap());
assert!(p.document(id).is_err());
}
#[test]
fn startup_removal_requires_explicit_replacement_and_never_deletes_a_file() {
let t = Temp::new();
let path = t.write("p.mak", "' header\na.bas\nb.bas\n");
let a_path = t.write("a.bas", "END\n");
t.write("b.bas", "");
let mut p = Project::open(&path, vec![]).unwrap();
let ids = p.members();
p.set_startup(Some(ids[0])).unwrap();
assert!(p.remove_file(ids[0], None).is_err());
assert!(!p.remove_file(ids[0], Some(StartupRemoval::Cancel)).unwrap());
assert_eq!(p.members(), ids);
assert!(p
.remove_file(ids[0], Some(StartupRemoval::Replace(ids[0])))
.is_err());
assert_eq!(p.startup(), Some(ids[0]));
p.remove_file(ids[0], Some(StartupRemoval::Replace(ids[1])))
.unwrap();
assert_eq!(p.startup(), Some(ids[1]));
assert!(a_path.exists());
assert!(p.document(ids[0]).is_ok());
p.save_project(&SavePlan::default()).unwrap();
let saved = fs::read_to_string(path).unwrap();
assert!(saved.contains("' header"));
assert!(!saved.lines().any(|l| l == "a.bas"));
p.remove_file(ids[1], Some(StartupRemoval::Default))
.unwrap();
assert_eq!(p.startup(), None);
}
#[test]
fn external_changes_and_save_as_collisions_need_explicit_decisions() {
let t = Temp::new();
let file = t.write("main.bas", "old\n");
let mut p = Project::open(&file, vec![]).unwrap();
let id = p.members()[0];
p.replace_text(id, 0..3, "new").unwrap();
fs::write(&file, "external\n").unwrap();
assert!(p
.save_file(id, None)
.unwrap_err()
.to_string()
.contains("Überschreibentscheidung"));
assert_eq!(fs::read_to_string(&file).unwrap(), "external\n");
assert!(p.document(id).unwrap().is_dirty());
p.save_file(
id,
Some(&Destination {
path: file.clone(),
overwrite: true,
}),
)
.unwrap();
assert_eq!(fs::read_to_string(&file).unwrap(), "new\n");
let other = t.write("other.bas", "keep\n");
assert!(p.save_file(id, Some(&Destination::new(&other))).is_err());
assert_eq!(p.document(id).unwrap().path(), Some(file.as_path()));
p.save_file(
id,
Some(&Destination {
path: other.clone(),
overwrite: true,
}),
)
.unwrap();
assert_eq!(fs::read_to_string(&other).unwrap(), "new\n");
assert_eq!(p.document(id).unwrap().path(), Some(other.as_path()));
assert!(p
.save_text(
id,
None,
&Destination {
path: other,
overwrite: true
}
)
.is_err());
}
#[test]
fn partial_save_preserves_unsaved_data_and_does_not_finish_close() {
let t = Temp::new();
let path = t.write("p.mak", "a.bas\nb.bas\n");
let a = t.write("a.bas", "old A\n");
let b = t.write("b.bas", "old B\n");
let mut p = Project::open(&path, vec![]).unwrap();
let ids = p.members();
for id in &ids {
p.replace_text(*id, 0..3, "new").unwrap();
}
let mut perms = fs::metadata(&b).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&b, perms).unwrap();
let result = p.prepare_close(Decision::Save(&SavePlan::default()));
assert!(result.is_err());
assert!(!p.document(ids[0]).unwrap().is_dirty());
assert!(p.document(ids[1]).unwrap().is_dirty());
assert!(p.is_dirty());
assert_eq!(p.path(), Some(path.as_path()));
assert_eq!(fs::read_to_string(&a).unwrap(), "new A\n");
assert_eq!(fs::read_to_string(&b).unwrap(), "old B\n");
assert_eq!(fs::read_to_string(&path).unwrap(), "a.bas\nb.bas\n");
// Die ursprünglichen Schreibrechte aus einer normalen Datei wiederherstellen.
fs::set_permissions(&b, fs::metadata(&a).unwrap().permissions()).unwrap();
assert!(p
.prepare_close(Decision::Save(&SavePlan::default()))
.unwrap());
assert!(!p.is_dirty());
assert!(fs::read_dir(&t.0).unwrap().all(|e| !e
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".tb-save-")));
}
#[test]
fn project_save_as_rebases_members_and_keeps_include_targets() {
let t = Temp::new();
let path = t.write("old/p.mak", "main.bas\n' retained\n");
t.write("old/main.bas", "'$INCLUDE: 'inner.bi'\nEND\n");
t.write("old/inner.bi", "PRINT 3\n");
fs::create_dir(t.0.join("new")).unwrap();
let mut p = Project::open(&path, vec![]).unwrap();
let before = p.sources().unwrap();
let target = t.0.join("new/q.mak");
p.save_project(&t.plan("new/q.mak")).unwrap();
assert_eq!(p.path(), Some(target.as_path()));
let reopened = Project::open(&target, vec![]).unwrap();
assert_eq!(
before.units[0]
.segments
.iter()
.map(|s| &s.text)
.collect::<Vec<_>>(),
reopened.sources().unwrap().units[0]
.segments
.iter()
.map(|s| &s.text)
.collect::<Vec<_>>()
);
assert!(fs::read_to_string(&target)
.unwrap()
.contains("../old/main.bas"));
let bad = t.plan("missing/q.mak");
assert!(p.save_project(&bad).is_err());
assert_eq!(p.path(), Some(target.as_path()));
}
#[test]
fn binary_import_only_saves_to_explicit_text_target_and_preserves_original_forever() {
let t = Temp::new();
let bytes: Vec<u8> = include_str!("../../tb-ui/tests/data/new.frm.hex")
.split_whitespace()
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect();
let binary = t.0.join("source.frm");
fs::write(&binary, &bytes).unwrap();
let mut p = Project::open(&binary, vec![]).unwrap();
let id = p.members()[0];
p.replace_text(id, 0..0, "SUB Form_Load\nEND SUB\n")
.unwrap();
assert!(p
.save_file(id, None)
.unwrap_err()
.to_string()
.contains("binäre FRM"));
let text = t.0.join("converted.frm");
p.save_file(id, Some(&Destination::new(&text))).unwrap();
assert!(fs::read_to_string(&text).unwrap().starts_with("VERSION"));
assert_eq!(fs::read(&binary).unwrap(), bytes);
assert!(p
.save_file(
id,
Some(&Destination {
path: binary.clone(),
overwrite: true
})
)
.is_err());
let original = p.document(id).unwrap().content().clone();
p.edit_form(id, |f| {
f.root.name = "Edited".into();
f.code.push_str("' changed\n");
Ok(())
})
.unwrap();
assert!(p.undo(id).unwrap());
assert_eq!(p.document(id).unwrap().content(), &original);
let mut plan = t.plan("converted.mak");
p.save_project(&plan).unwrap();
plan.project = None;
let restored = Project::open(&t.0.join("converted.mak"), vec![]).unwrap();
assert!(matches!(
restored.document(restored.members()[0]).unwrap().content(),
Content::Form(_)
));
}
#[test]
fn save_plan_prevents_same_destination_and_project_overwrite() {
let t = Temp::new();
let mut p = Project::new(&t.0).unwrap();
let a = p.new_module("A").unwrap();
let b = p.new_module("B").unwrap();
let mut plan = t.plan("p.mak");
let target = t.0.join("same.bas");
plan.files.insert(a, Destination::new(&target));
plan.files.insert(b, Destination::new(&target));
assert!(p.save_project(&plan).is_err());
assert!(!target.exists());
assert!(p.document(a).unwrap().is_dirty());
assert!(p
.save_file(a, Some(&Destination::new(t.0.join("bad.frm"))))
.is_err());
assert!(p.new_module("../bad").is_err());
}
#[test]
fn module_save_as_rebases_includes_and_failed_save_does_not_change_identity_or_text() {
let t = Temp::new();
let path = t.write("old/p.mak", "main.bas\n");
let original = "'$INCLUDE: 'common.bi' ' keep comment\nEND\n";
t.write("old/main.bas", original);
t.write("old/common.bi", "PRINT 42\n");
fs::create_dir(t.0.join("new")).unwrap();
let mut p = Project::open(&path, vec![]).unwrap();
let id = p.members()[0];
let old = p.document(id).unwrap().source_path().to_path_buf();
assert!(p
.save_file(id, Some(&Destination::new(t.0.join("missing/main.bas"))))
.is_err());
assert_eq!(p.document(id).unwrap().source_path(), old);
assert_eq!(p.document(id).unwrap().code(), original);
let view = p.open_view(id).unwrap();
p.save_file(id, Some(&Destination::new(t.0.join("new/main.bas"))))
.unwrap();
assert!(p
.document(id)
.unwrap()
.code()
.contains("../old/common.bi' ' keep comment"));
assert_eq!(p.view(view).unwrap().document(), id);
p.save_project(&SavePlan::default()).unwrap();
let restored = Project::open(&path, vec![]).unwrap();
assert!(restored.sources().unwrap().units[0]
.segments
.iter()
.any(|s| s.text == "PRINT 42\n"));
assert_eq!(fs::read_to_string(old).unwrap(), original);
}
#[test]
fn aliases_share_documents_and_form_code_offsets_match_saved_text() {
let t = Temp::new();
let file = t.write("Mixed.bas", "PRINT 1\n");
let mut p = Project::new(&t.0).unwrap();
let a = p.add_file(&file).unwrap();
assert_eq!(p.open_document(&t.0.join("MIXED.BAS")).unwrap(), a);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&file, t.0.join("alias.bas")).unwrap();
assert_eq!(p.open_document(&t.0.join("alias.bas")).unwrap(), a);
}
let id = p.new_form("F").unwrap();
p.replace_text(id, 0..0, "SUB Form_Load\nERROR 6\nEND SUB\n")
.unwrap();
let before = p.sources().unwrap();
let target = t.0.join("F.frm");
p.save_file(id, Some(&Destination::new(&target))).unwrap();
let disk = SourceLoader::default().load(&target).unwrap();
assert_eq!(
before.units[1]
.segments
.iter()
.map(|s| (&s.text, s.first_line))
.collect::<Vec<_>>(),
disk.units[0]
.segments
.iter()
.map(|s| (&s.text, s.first_line))
.collect::<Vec<_>>()
);
}
#[test]
fn cursor_after_shortening_uses_original_position_and_open_after_save_uses_fresh_disk() {
let t = Temp::new();
let path = t.write("p.mak", "main.bas\n");
t.write("main.bas", "abcdefgh\n");
let mut p = Project::open(&path, vec![]).unwrap();
let id = p.members()[0];
let v = p.open_view(id).unwrap();
p.view_mut(v).unwrap().cursor = 9;
p.replace_text(id, 1..2, "").unwrap();
assert_eq!(p.view(v).unwrap().cursor, 8);
p.undo(id).unwrap();
assert_eq!(p.view(v).unwrap().cursor, 9);
p.replace_text(id, 0..8, "PRINT 7").unwrap();
assert!(p
.open_project(&path, Decision::Save(&SavePlan::default()))
.unwrap());
assert_eq!(p.document(p.members()[0]).unwrap().code(), "PRINT 7\n");
}