Phase 6: IDE-Exportanbindung abschließen und Change archivieren

This commit is contained in:
2026-09-07 17:00:29 +02:00
parent 25176947ba
commit 69f2002481
31 changed files with 1310 additions and 136 deletions

View File

@@ -1,4 +1,4 @@
//! Konkreter Vertrag für die spätere native Erzeugung, ohne Phase-5-Dateiausgabe.
//! Revisionsgebundene Exportaufträge und abwerfbare Hintergrundresultate.
use crate::documents::{Destination, DocumentId, Project};
use anyhow::{ensure, Result};
use std::path::PathBuf;
@@ -14,7 +14,7 @@ impl Artifact {
pub fn label(self) -> &'static str {
match self {
Self::Executable => "Natives Standalone-Executable",
Self::Library => "Native Systembibliothek",
Self::Library => "Portable P-Code-Bibliothek (.tbl)",
}
}
}
@@ -22,6 +22,8 @@ impl Artifact {
pub struct ProjectStamp {
pub path: Option<PathBuf>,
pub manifest: Manifest,
pub inputs: Vec<(PathBuf, Option<Vec<u8>>)>,
pub include_paths: Vec<PathBuf>,
pub revisions: Vec<(DocumentId, u64)>,
}
impl ProjectStamp {
@@ -29,6 +31,20 @@ impl ProjectStamp {
Self {
path: project.path().map(PathBuf::from),
manifest: project.manifest().clone(),
include_paths: project.include_paths.clone(),
inputs: project
.sources()
.map(|s| {
s.protected_inputs()
.into_iter()
.filter(|p| has_extension(p, "tbl") || project.find_document(p).is_none())
.map(|p| {
let b = std::fs::read(&p).ok();
(p, b)
})
.collect()
})
.unwrap_or_default(),
revisions: project
.documents()
.map(|(id, d)| (id, d.revision()))
@@ -38,12 +54,14 @@ impl ProjectStamp {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportRequest {
pub generation: u64,
pub project: ProjectStamp,
pub artifact: Artifact,
pub system: String,
pub architecture: String,
pub path: PathBuf,
pub overwrite: bool,
pub previous_output: Option<Vec<u8>>,
}
impl ExportRequest {
pub fn validate(
@@ -53,24 +71,25 @@ impl ExportRequest {
architecture: &str,
destination: &Destination,
) -> Result<Self> {
ensure!(
["linux", "macos", "windows"].contains(&system),
"Unbekanntes Zielsystem"
);
ensure!(
["x86_64", "aarch64"].contains(&architecture),
"Unbekannte Zielarchitektur"
);
if artifact == Artifact::Executable {
target(system, architecture)?;
}
ensure!(
!has_extension(&destination.path, "tbc"),
"TBC ist kein natives Exportartefakt"
);
let path = project.validate_export_target(destination)?;
if artifact == Artifact::Library {
ensure!(has_extension(&path, "tbl"), "Library-Ausgabe benötigt .tbl");
}
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
Ok(Self {
generation: NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
project: ProjectStamp::capture(project),
artifact,
system: system.into(),
architecture: architecture.into(),
previous_output: std::fs::read(&path).ok(),
path,
overwrite: destination.overwrite,
})
@@ -95,3 +114,333 @@ impl ExportStatus {
}
}
}
/// Die unterstützten Kombinationen werden ausschließlich aus dem Exportkatalog gewählt.
pub fn target(system: &str, architecture: &str) -> Result<tb_export::Target> {
tb_export::Target::ALL
.into_iter()
.find(|t| target_parts(*t) == (system, architecture))
.ok_or_else(|| anyhow::anyhow!("Unzulässiges EXE-Ziel: {system}/{architecture}"))
}
pub fn target_parts(target: tb_export::Target) -> (&'static str, &'static str) {
let triple = target.triple();
(
if triple.contains("windows") {
"windows"
} else if triple.contains("apple") {
"macos"
} else {
"linux"
},
triple.split('-').next().unwrap(),
)
}
pub struct Job {
pub request: ExportRequest,
thread: Option<std::thread::JoinHandle<()>>,
cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
result: std::sync::mpsc::Receiver<Result<tb_export::PreparedPublication>>,
}
impl Drop for Job {
fn drop(&mut self) {
self.cancelled
.store(true, std::sync::atomic::Ordering::Release);
// Auch beim Beenden der IDE müssen kontrollierte Tools und Staging enden.
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
impl Job {
pub fn start(
request: ExportRequest,
project: &Project,
runtime_dir: &std::path::Path,
) -> Result<Self> {
let sources = project.sources()?;
let mut compiler = tb_vm::project::ProjectCompiler::default();
compiler.debug_symbols = true;
let diagnostics = |errors: Vec<tb_frontend::Diagnostic>| {
anyhow::anyhow!(errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"))
};
let bytes = match request.artifact {
Artifact::Library => sources
.compile_library(&mut compiler)
.map_err(diagnostics)?
.to_tbl()
.map_err(|e| anyhow::anyhow!(e))?,
Artifact::Executable => sources
.compile(&mut compiler, "IDE")
.map_err(diagnostics)?
.to_tbc(),
};
ensure!(
request.project == ProjectStamp::capture(project),
"Projekt während der Übersetzung geändert"
);
let mut protected = sources.protected_inputs();
protected.extend(
project
.documents()
.map(|(_, d)| d.source_path().to_path_buf()),
);
if let Some(path) = project.path() {
protected.push(path.into());
}
let runtime_dir = runtime_dir.to_path_buf();
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = cancelled.clone();
let (sender, result) = std::sync::mpsc::channel();
let work = request.clone();
let thread = std::thread::Builder::new()
.name("tb-export".into())
.spawn(move || {
let cancelled = || flag.load(std::sync::atomic::Ordering::Acquire);
let prepare = || -> Result<tb_export::PreparedPublication> {
if work.artifact == Artifact::Library {
tb_export::prepare_publication(
&work.path,
work.overwrite,
&protected,
&cancelled,
|p| {
std::fs::write(p, &bytes)?;
Ok(())
},
)
} else {
let target = target(&work.system, &work.architecture)?;
let template = runtime_dir
.join(target.triple())
.join(target.runtime_name());
let module = tb_vm::bytecode::CompiledModule::from_tbc(&bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
tb_export::prepare_export(
tb_export::Export {
module: &module,
target,
template: &template,
output: &work.path,
overwrite: work.overwrite,
protected: &protected,
},
&cancelled,
)
}
};
// Ein geschlossener Empfänger verwirft das Staging-Ergebnis per Drop.
let _ = sender.send(prepare());
})?;
Ok(Self {
thread: Some(thread),
request,
cancelled,
result,
})
}
pub fn take_result(&self) -> Option<Result<tb_export::PreparedPublication>> {
match self.result.try_recv() {
Ok(result) => Some(result),
Err(std::sync::mpsc::TryRecvError::Empty) => None,
Err(_) => Some(Err(anyhow::anyhow!("Exportworker beendet ohne Ergebnis"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::App, commands::Command};
use std::{
fs,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc, Arc,
},
};
struct Dir(PathBuf);
impl Dir {
fn new() -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"tb-export-ui-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn delayed(
app: &mut App,
output: &std::path::Path,
) -> (
mpsc::Sender<Result<tb_export::PreparedPublication>>,
Arc<AtomicBool>,
) {
app.execute(Command::MakeLibrary);
let request = ExportRequest::validate(
&app.project,
Artifact::Library,
"",
"",
&Destination {
path: output.into(),
overwrite: true,
},
)
.unwrap();
let d = app.dialog.as_mut().unwrap();
d.request = Some(request.clone());
d.export_status = ExportStatus::Running;
let (tx, rx) = mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(false));
app.export_job = Some(Job {
thread: None,
request,
cancelled: cancelled.clone(),
result: rx,
});
(tx, cancelled)
}
fn prepared(output: &std::path::Path) -> tb_export::PreparedPublication {
tb_export::prepare_publication(output, true, &[], &|| false, |p| {
fs::write(p, b"new")?;
Ok(())
})
.unwrap()
}
fn no_staging(dir: &std::path::Path) {
assert!(fs::read_dir(dir).unwrap().all(|p| !p
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".tb-export-")));
}
#[test]
fn delayed_result_is_published_only_after_ui_validation_and_success_stays_success() {
let dir = Dir::new();
let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap();
let output = dir.0.join("out.tbl");
fs::write(&output, b"old").unwrap();
let (tx, _) = delayed(&mut app, &output);
let staged = prepared(&output);
app.tick_export();
app.handle(crossterm::event::Event::Resize(110, 35));
assert_eq!(app.size, (110, 35));
assert_eq!(fs::read(&output).unwrap(), b"old");
tx.send(Ok(staged)).ok().unwrap();
app.tick_export();
assert_eq!(fs::read(&output).unwrap(), b"new");
assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success);
app.handle(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
));
assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success);
no_staging(&dir.0);
}
#[test]
fn cancelled_superseded_changed_and_foreign_jobs_never_publish() {
for mode in 0..5 {
let dir = Dir::new();
let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap();
let output = dir.0.join("out.tbl");
fs::write(&output, b"old").unwrap();
let (tx, flag) = delayed(&mut app, &output);
let staged = prepared(&output);
match mode {
0 => app.handle(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
)),
1 => {
let id = app.project.members()[0];
app.project.replace_text(id, 0..0, "PRINT 2\n").unwrap();
}
2 => {
app.project = Project::new(&dir.0).unwrap();
}
3 => {
let old = app.export_job.as_ref().unwrap().request.generation;
let (_new, _) = delayed(&mut app, &output);
assert_ne!(old, app.export_job.as_ref().unwrap().request.generation);
}
_ => {
fs::write(&output, b"external").unwrap();
}
}
let _ = tx.send(Ok(staged));
app.tick_export();
assert!(flag.load(Ordering::Acquire));
assert_eq!(
fs::read(&output).unwrap(),
if mode == 4 {
b"external".as_slice()
} else {
b"old".as_slice()
}
);
assert!(!app
.last_export
.as_ref()
.is_some_and(|(_, s)| *s == ExportStatus::Success));
no_staging(&dir.0);
}
}
#[test]
fn replaced_library_invalidates_pending_export_even_with_same_length() {
let dir = Dir::new();
let source = dir.0.join("lib.bas");
fs::write(&source, "SUB Answer\nPRINT 1\nEND SUB\n").unwrap();
let mut compiler = tb_vm::project::ProjectCompiler::default();
let build = |compiler: &mut tb_vm::project::ProjectCompiler| {
tb_vm::project_io::SourceLoader::default()
.load(&source)
.unwrap()
.compile_library(compiler)
.unwrap()
.to_tbl()
.unwrap()
};
let a = build(&mut compiler);
fs::write(&source, "SUB Answer\nPRINT 2\nEND SUB\n").unwrap();
let b = build(&mut compiler);
assert_eq!(a.len(), b.len());
let lib = dir.0.join("lib.tbl");
fs::write(&lib, &a).unwrap();
fs::write(dir.0.join("main.bas"), "CALL Answer\nEND\n").unwrap();
fs::write(dir.0.join("main.mak"), "main.bas\nlib.tbl\n").unwrap();
let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap();
app.load_initial_project(dir.0.join("main.mak")).unwrap();
let before = app.compile_current().unwrap().to_tbc();
assert!(app.current_compilation().is_some());
let out = dir.0.join("out.tbl");
fs::write(&out, b"old").unwrap();
let (tx, flag) = delayed(&mut app, &out);
let staged = prepared(&out);
fs::write(&lib, &b).unwrap();
assert!(app.current_compilation().is_none());
let _ = tx.send(Ok(staged));
app.tick_export();
assert!(flag.load(Ordering::Acquire));
assert_eq!(fs::read(&out).unwrap(), b"old");
assert_ne!(before, app.compile_current().unwrap().to_tbc());
no_staging(&dir.0);
}
}