Phase 5: Projekt- und Dokumentmodell implementieren und archivieren
This commit is contained in:
@@ -15,6 +15,7 @@ use std::process::ExitCode;
|
||||
use tb_runtime::host::{Ereignis, Host};
|
||||
use tb_ui::host::TerminalHost;
|
||||
use tb_vm::interp::{RunEvent, Vm};
|
||||
use tb_vm::project_io::{module_name, relative_case_insensitive, SourceLoader};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -75,168 +76,6 @@ fn cmd_convert_frm(args: &[String]) -> ExitCode {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn module_name(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_uppercase())
|
||||
.unwrap_or_else(|| "MODUL".into())
|
||||
}
|
||||
|
||||
fn relative_case_insensitive(base: &Path, relative: &str) -> std::io::Result<PathBuf> {
|
||||
let direct = base.join(relative);
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
let wanted = Path::new(relative);
|
||||
let mut current = base.to_path_buf();
|
||||
for component in wanted.components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
current.pop();
|
||||
}
|
||||
Component::Normal(name) => {
|
||||
let entry = std::fs::read_dir(¤t)?.find_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&name.to_string_lossy())
|
||||
.then(|| entry.path())
|
||||
});
|
||||
current = entry.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("{} nicht gefunden", current.join(name).display()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => current.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
fn include_name(line: &str) -> Option<String> {
|
||||
let trimmed = line.trim();
|
||||
let upper = trimmed.to_ascii_uppercase();
|
||||
let at = upper.find("$INCLUDE")?;
|
||||
let rest = trimmed[at + "$INCLUDE".len()..].trim_start();
|
||||
let rest = rest.strip_prefix(':')?.trim_start();
|
||||
let rest = rest.strip_prefix('\'')?;
|
||||
Some(rest.split('\'').next().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
fn expand_includes(
|
||||
path: &Path,
|
||||
source: &str,
|
||||
first_line: u32,
|
||||
stack: &mut Vec<PathBuf>,
|
||||
) -> Result<Vec<tb_frontend::source::SourceSegment>, String> {
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
if stack.contains(&canonical) {
|
||||
return Err(format!("{}: zyklisches $INCLUDE", path.display()));
|
||||
}
|
||||
stack.push(canonical);
|
||||
let mut segments = vec![tb_frontend::source::SourceSegment {
|
||||
file: path.display().to_string(),
|
||||
first_line,
|
||||
text: String::new(),
|
||||
}];
|
||||
for (line_no, line) in source.split_inclusive('\n').enumerate() {
|
||||
if let Some(name) = include_name(line) {
|
||||
if name.is_empty() {
|
||||
return Err(format!(
|
||||
"{}:{}: leeres $INCLUDE",
|
||||
path.display(),
|
||||
first_line + line_no as u32
|
||||
));
|
||||
}
|
||||
let included =
|
||||
relative_case_insensitive(path.parent().unwrap_or(Path::new(".")), &name)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let text = std::fs::read_to_string(&included)
|
||||
.map_err(|e| format!("{}: {e}", included.display()))?;
|
||||
segments.extend(expand_includes(&included, &text, 1, stack)?);
|
||||
} else {
|
||||
segments.push(tb_frontend::source::SourceSegment {
|
||||
file: path.display().to_string(),
|
||||
first_line: first_line + line_no as u32,
|
||||
text: line.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn read_form(path: &Path) -> Result<tb_ui::frm::FormFile, String> {
|
||||
let bytes = std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
|
||||
if bytes.starts_with(b"VERSION ") || bytes.starts_with(b"Version ") {
|
||||
let source = std::str::from_utf8(&bytes)
|
||||
.map_err(|_| format!("{}: ungültige Textkodierung", path.display()))?;
|
||||
let form = tb_ui::frm::read_text(&path.display().to_string(), source)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(form)
|
||||
} else {
|
||||
tb_ui::frm::read_binary(&path.display().to_string(), &bytes)
|
||||
.map(|read| read.form)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn input_sources(
|
||||
path: &Path,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<tb_frontend::source::SourceUnit>,
|
||||
Vec<tb_ui::frm::FormFile>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
||||
let paths = if extension.eq_ignore_ascii_case("mak") {
|
||||
let project = std::fs::read_to_string(path)
|
||||
.map_err(|error| format!("{}: {error}", path.display()))?;
|
||||
let base = path.parent().unwrap_or(Path::new("."));
|
||||
project
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('\''))
|
||||
.map(|line| {
|
||||
relative_case_insensitive(base, line)
|
||||
.map_err(|error| format!("{}: {error}", path.display()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
} else {
|
||||
vec![path.to_path_buf()]
|
||||
};
|
||||
let mut source = Vec::new();
|
||||
let mut forms = Vec::new();
|
||||
for member in paths {
|
||||
if member
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
|
||||
{
|
||||
let form = read_form(&member)?;
|
||||
source.push(tb_frontend::source::SourceUnit {
|
||||
name: form.root.name.clone(),
|
||||
segments: expand_includes(&member, &form.code, form.code_line(), &mut Vec::new())?,
|
||||
});
|
||||
forms.push(form);
|
||||
} else {
|
||||
let text = std::fs::read_to_string(&member)
|
||||
.map_err(|error| format!("{}: {error}", member.display()))?;
|
||||
source.push(tb_frontend::source::SourceUnit {
|
||||
name: module_name(&member),
|
||||
segments: expand_includes(&member, &text, 1, &mut Vec::new())?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok((source, forms))
|
||||
}
|
||||
|
||||
fn compile(
|
||||
path_arg: Option<&String>,
|
||||
) -> Result<(PathBuf, tb_vm::bytecode::CompiledModule), ExitCode> {
|
||||
@@ -259,7 +98,7 @@ fn compile(
|
||||
})?;
|
||||
return Ok((path, module));
|
||||
}
|
||||
let (source, forms) = match input_sources(&path) {
|
||||
let input = match SourceLoader::default().load(&path) {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
@@ -267,10 +106,11 @@ fn compile(
|
||||
}
|
||||
};
|
||||
let mut catalog = tb_frontend::forms::FormCatalog::default();
|
||||
for form in &forms {
|
||||
for form in &input.forms {
|
||||
catalog.append(&form.catalog());
|
||||
}
|
||||
let compiled = tb_vm::compile_project(&module_name(&path), &source, &catalog, &forms);
|
||||
let compiled =
|
||||
tb_vm::compile_project(&module_name(&path), &input.units, &catalog, &input.forms);
|
||||
match compiled {
|
||||
Ok(m) => Ok((path, m)),
|
||||
Err(diags) => {
|
||||
|
||||
790
crates/tb-ide/src/documents.rs
Normal file
790
crates/tb-ide/src/documents.rs
Normal 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
2
crates/tb-ide/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
//! Testbare IDE-Zustände; Terminalbedienung wird vom IDE-Rahmen angebunden.
|
||||
pub mod documents;
|
||||
489
crates/tb-ide/tests/documents.rs
Normal file
489
crates/tb-ide/tests/documents.rs
Normal 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");
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod codegen;
|
||||
pub mod interp;
|
||||
|
||||
pub mod project;
|
||||
pub mod project_io;
|
||||
pub use project::compile_project;
|
||||
use tb_frontend::{source::SourceUnit, Diagnostic};
|
||||
|
||||
|
||||
501
crates/tb-vm/src/project_io.rs
Normal file
501
crates/tb-vm/src/project_io.rs
Normal file
@@ -0,0 +1,501 @@
|
||||
//! Gemeinsame Projekt-/Include-Auflösung für CLI und bearbeitete IDE-Quellen.
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
use tb_frontend::source::{SourceSegment, SourceUnit};
|
||||
use tb_ui::frm::{self, FormFile};
|
||||
|
||||
pub fn module_name(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_uppercase())
|
||||
.unwrap_or_else(|| "MODUL".into())
|
||||
}
|
||||
|
||||
/// Absolute Identität, auch für noch nicht gespeicherte Dateien unter Symlink-Verzeichnissen.
|
||||
pub fn identity(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let absolute = std::path::absolute(path)?;
|
||||
let mut result = PathBuf::new();
|
||||
for part in absolute.components() {
|
||||
match part {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
result.pop();
|
||||
}
|
||||
_ => result.push(part.as_os_str()),
|
||||
}
|
||||
if let Ok(real) = result.canonicalize() {
|
||||
result = real;
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn has_extension(path: &Path, extension: &str) -> bool {
|
||||
path.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case(extension))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Content {
|
||||
Text(String),
|
||||
Form(Box<FormFile>),
|
||||
}
|
||||
impl Content {
|
||||
pub fn code(&self) -> &str {
|
||||
match self {
|
||||
Self::Text(s) => s,
|
||||
Self::Form(f) => &f.code,
|
||||
}
|
||||
}
|
||||
pub fn code_mut(&mut self) -> &mut String {
|
||||
match self {
|
||||
Self::Text(s) => s,
|
||||
Self::Form(f) => &mut f.code,
|
||||
}
|
||||
}
|
||||
pub fn text(&self) -> String {
|
||||
match self {
|
||||
Self::Text(s) => s.clone(),
|
||||
Self::Form(f) => frm::write_text(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReadDocument {
|
||||
pub content: Content,
|
||||
pub bytes: Vec<u8>,
|
||||
pub binary: bool,
|
||||
pub warnings: Vec<frm::BinaryWarning>,
|
||||
}
|
||||
|
||||
pub fn read_document(path: &Path) -> Result<ReadDocument, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let binary = has_extension(path, "frm") && bytes.starts_with(&[0xfc, 0x08, 1, 0]);
|
||||
let mut warnings = Vec::new();
|
||||
let content = if binary {
|
||||
let read =
|
||||
frm::read_binary(&path.display().to_string(), &bytes).map_err(|e| e.to_string())?;
|
||||
warnings = read.skipped;
|
||||
Content::Form(Box::new(read.form))
|
||||
} else {
|
||||
let text = std::str::from_utf8(&bytes)
|
||||
.map_err(|e| format!("{}: ungültige Textkodierung: {e}", path.display()))?;
|
||||
if has_extension(path, "frm") {
|
||||
Content::Form(Box::new(
|
||||
frm::read_text(&path.display().to_string(), text).map_err(|e| e.to_string())?,
|
||||
))
|
||||
} else {
|
||||
Content::Text(text.into())
|
||||
}
|
||||
};
|
||||
Ok(ReadDocument {
|
||||
content,
|
||||
bytes,
|
||||
binary,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProjectLine {
|
||||
File(PathBuf),
|
||||
Comment(String),
|
||||
Blank,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Manifest {
|
||||
pub lines: Vec<ProjectLine>,
|
||||
pub startup: Option<PathBuf>,
|
||||
}
|
||||
impl Manifest {
|
||||
pub fn members(&self) -> impl Iterator<Item = &PathBuf> {
|
||||
self.lines.iter().filter_map(|l| match l {
|
||||
ProjectLine::File(p) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub fn parse(path: &Path, text: &str, loader: &SourceLoader) -> Result<Self, String> {
|
||||
let base = path.parent().unwrap_or(Path::new("."));
|
||||
let mut out = Self::default();
|
||||
let mut startup = None;
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let s = line.trim();
|
||||
let fail = |msg: &str| format!("{}:{}: {msg}", path.display(), index + 1);
|
||||
if s.is_empty() {
|
||||
out.lines.push(ProjectLine::Blank);
|
||||
} else if let Some(comment) = s.strip_prefix('\'') {
|
||||
let comment = comment.trim_start();
|
||||
if comment.to_ascii_uppercase().starts_with("$STARTUP") {
|
||||
if startup.is_some() {
|
||||
return Err(fail("doppeltes $STARTUP"));
|
||||
}
|
||||
let value = comment[8..]
|
||||
.trim_start()
|
||||
.strip_prefix(':')
|
||||
.map(str::trim)
|
||||
.and_then(|s| s.strip_prefix('"'))
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.filter(|s| !s.is_empty() && !s.contains('"'))
|
||||
.ok_or_else(|| fail("ungültiges $STARTUP"))?;
|
||||
startup = Some(loader.resolve(base, value).map_err(|e| fail(&e))?);
|
||||
} else {
|
||||
out.lines.push(ProjectLine::Comment(line.into()));
|
||||
}
|
||||
} else {
|
||||
let member = loader.resolve(base, s).map_err(|e| fail(&e))?;
|
||||
if !has_extension(&member, "bas") && !has_extension(&member, "frm") {
|
||||
return Err(fail("Projektmitglied muss BAS oder FRM sein"));
|
||||
}
|
||||
let key = identity(&member).map_err(|e| fail(&e.to_string()))?;
|
||||
if out
|
||||
.members()
|
||||
.any(|p| identity(p).ok().as_ref() == Some(&key))
|
||||
{
|
||||
return Err(fail("doppeltes Projektmitglied"));
|
||||
}
|
||||
out.lines.push(ProjectLine::File(member));
|
||||
}
|
||||
}
|
||||
if let Some(start) = startup {
|
||||
let key = identity(&start).map_err(|e| e.to_string())?;
|
||||
let chosen = out
|
||||
.members()
|
||||
.find(|p| identity(p).ok().as_ref() == Some(&key))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"{}: $STARTUP ist kein Projektmitglied: {}",
|
||||
path.display(),
|
||||
start.display()
|
||||
)
|
||||
})?;
|
||||
out.startup = Some(chosen);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
pub fn text(&self, destination: &Path) -> Result<String, String> {
|
||||
let base = destination.parent().unwrap_or(Path::new("."));
|
||||
let mut out = String::new();
|
||||
if let Some(path) = &self.startup {
|
||||
let name = relative_path(base, path).map_err(|e| e.to_string())?;
|
||||
if name.contains(['"', '\n', '\r']) {
|
||||
return Err("$STARTUP-Pfad kann nicht dargestellt werden".into());
|
||||
}
|
||||
out.push_str(&format!("' $STARTUP: \"{name}\"\n"));
|
||||
}
|
||||
for line in &self.lines {
|
||||
match line {
|
||||
ProjectLine::File(path) => {
|
||||
let name = relative_path(base, path).map_err(|e| e.to_string())?;
|
||||
if name.contains(['\n', '\r']) || name.trim() != name || name.starts_with('\'')
|
||||
{
|
||||
return Err("MAK-Pfad kann nicht dargestellt werden".into());
|
||||
}
|
||||
out.push_str(&name);
|
||||
}
|
||||
ProjectLine::Comment(s) => out.push_str(s),
|
||||
ProjectLine::Blank => {}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
pub fn rename(&mut self, old: &Path, new: &Path) {
|
||||
for line in &mut self.lines {
|
||||
if let ProjectLine::File(p) = line {
|
||||
if p == old {
|
||||
*p = new.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.startup.as_deref() == Some(old) {
|
||||
self.startup = Some(new.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn relative_path(base: &Path, target: &Path) -> std::io::Result<String> {
|
||||
let base = identity(base)?;
|
||||
let target = identity(target)?;
|
||||
let a: Vec<_> = base.components().collect();
|
||||
let b: Vec<_> = target.components().collect();
|
||||
let common = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
|
||||
if common == 0 {
|
||||
return target.to_str().map(str::to_owned).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Projektpfad ist nicht UTF-8",
|
||||
)
|
||||
});
|
||||
}
|
||||
let mut result = PathBuf::new();
|
||||
for _ in common..a.len() {
|
||||
result.push("..");
|
||||
}
|
||||
for c in &b[common..] {
|
||||
result.push(c.as_os_str());
|
||||
}
|
||||
result.to_str().map(str::to_owned).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Projektpfad ist nicht UTF-8",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SourceLoader {
|
||||
overlays: BTreeMap<PathBuf, Content>,
|
||||
pub include_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectSources {
|
||||
pub manifest: Manifest,
|
||||
pub units: Vec<SourceUnit>,
|
||||
pub forms: Vec<FormFile>,
|
||||
}
|
||||
impl SourceLoader {
|
||||
pub fn insert(&mut self, path: &Path, content: Content) -> Result<(), String> {
|
||||
let key = identity(path).map_err(|e| e.to_string())?;
|
||||
self.overlays.insert(key, content);
|
||||
Ok(())
|
||||
}
|
||||
pub fn resolve(&self, base: &Path, relative: &str) -> Result<PathBuf, String> {
|
||||
let direct = base.join(relative);
|
||||
let key = identity(&direct).map_err(|e| e.to_string())?;
|
||||
if self.overlays.contains_key(&key) {
|
||||
return Ok(key);
|
||||
}
|
||||
if let Ok(path) = relative_case_insensitive(base, relative) {
|
||||
return Ok(path);
|
||||
}
|
||||
let matches: Vec<_> = self
|
||||
.overlays
|
||||
.keys()
|
||||
.filter(|p| {
|
||||
p.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&key.to_string_lossy())
|
||||
})
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[path] => Ok((*path).clone()),
|
||||
[] => Err(format!("{} nicht gefunden", direct.display())),
|
||||
_ => Err(format!("{} ist mehrdeutig", direct.display())),
|
||||
}
|
||||
}
|
||||
pub fn read(&self, path: &Path) -> Result<Content, String> {
|
||||
let key = identity(path).map_err(|e| e.to_string())?;
|
||||
self.overlays
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| read_document(path).map(|d| d.content))
|
||||
}
|
||||
/// Relative Includes bei Save As auf dieselben Dateien richten. Nutztext bleibt unverändert.
|
||||
pub fn relocate(&self, content: &Content, from: &Path, to: &Path) -> Result<Content, String> {
|
||||
if from.parent() == to.parent() {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
let old_base = from.parent().unwrap_or(Path::new("."));
|
||||
let new_base = to.parent().unwrap_or(Path::new("."));
|
||||
let mut text = String::new();
|
||||
for line in content.code().split_inclusive('\n') {
|
||||
if let Some((name, column)) = include_name(line) {
|
||||
if !name.is_empty() && !Path::new(&name).is_absolute() {
|
||||
let target = self
|
||||
.resolve_include(old_base, &name)
|
||||
.unwrap_or_else(|_| old_base.join(&name));
|
||||
let relative = relative_path(new_base, &target).map_err(|e| e.to_string())?;
|
||||
if relative.contains(['\'', '\n', '\r']) {
|
||||
return Err(format!("{}: Include-Pfad nicht darstellbar", to.display()));
|
||||
}
|
||||
let start = line
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(line.len());
|
||||
let comment = &line[start..];
|
||||
// Der Lexer hat die gültige Include-Syntax bereits erkannt.
|
||||
let quote = start + comment.find(':').unwrap() + 1;
|
||||
let begin = quote + line[quote..].find('\'').unwrap() + 1;
|
||||
let end = begin + line[begin..].find('\'').unwrap();
|
||||
text.push_str(&line[..begin]);
|
||||
text.push_str(&relative);
|
||||
text.push_str(&line[end..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
text.push_str(line);
|
||||
}
|
||||
let mut result = content.clone();
|
||||
*result.code_mut() = text;
|
||||
Ok(result)
|
||||
}
|
||||
fn resolve_include(&self, base: &Path, name: &str) -> Result<PathBuf, String> {
|
||||
let mut found = self.resolve(base, name);
|
||||
if found.is_err() && !Path::new(name).is_absolute() {
|
||||
for base in &self.include_paths {
|
||||
found = self.resolve(base, name);
|
||||
if found.is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
pub fn load(&self, path: &Path) -> Result<ProjectSources, String> {
|
||||
let path = self.resolve(Path::new("."), &path.to_string_lossy())?;
|
||||
let manifest = if has_extension(&path, "mak") {
|
||||
Manifest::parse(&path, &self.read(&path)?.text(), self)?
|
||||
} else {
|
||||
Manifest {
|
||||
lines: vec![ProjectLine::File(path)],
|
||||
startup: None,
|
||||
}
|
||||
};
|
||||
self.load_manifest(manifest)
|
||||
}
|
||||
pub fn load_manifest(&self, manifest: Manifest) -> Result<ProjectSources, String> {
|
||||
let mut units = Vec::new();
|
||||
let mut forms = Vec::new();
|
||||
for path in manifest.members() {
|
||||
let content = self.read(path)?;
|
||||
let (name, code, first_line) = match content {
|
||||
Content::Text(text) => (module_name(path), text, 1),
|
||||
Content::Form(form) => {
|
||||
// Bei bearbeiteter Struktur müssen die physischen Codezeilen
|
||||
// zur tatsächlich gespeicherten Textfassung passen.
|
||||
let form = frm::read_text(&path.display().to_string(), &frm::write_text(&form))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = (form.root.name.clone(), form.code.clone(), form.code_line());
|
||||
forms.push(form);
|
||||
result
|
||||
}
|
||||
};
|
||||
units.push(SourceUnit {
|
||||
name,
|
||||
segments: self.expand(path, &code, first_line, &mut Vec::new())?,
|
||||
});
|
||||
}
|
||||
Ok(ProjectSources {
|
||||
manifest,
|
||||
units,
|
||||
forms,
|
||||
})
|
||||
}
|
||||
fn expand(
|
||||
&self,
|
||||
path: &Path,
|
||||
text: &str,
|
||||
first_line: u32,
|
||||
stack: &mut Vec<PathBuf>,
|
||||
) -> Result<Vec<SourceSegment>, String> {
|
||||
let key = identity(path).map_err(|e| e.to_string())?;
|
||||
if stack.contains(&key) {
|
||||
return Err(format!(
|
||||
"{}: zyklisches $INCLUDE: {} -> {}",
|
||||
path.display(),
|
||||
stack
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" -> "),
|
||||
key.display()
|
||||
));
|
||||
}
|
||||
stack.push(key);
|
||||
let mut out = vec![SourceSegment {
|
||||
file: path.display().to_string(),
|
||||
first_line,
|
||||
text: String::new(),
|
||||
}];
|
||||
for (line_no, line) in text.split_inclusive('\n').enumerate() {
|
||||
if let Some((name, column)) = include_name(line) {
|
||||
let prefix: String = line.chars().take(column).collect();
|
||||
if !prefix.trim().is_empty() {
|
||||
out.push(SourceSegment {
|
||||
file: path.display().to_string(),
|
||||
first_line: first_line + line_no as u32,
|
||||
text: format!("{prefix}\n"),
|
||||
});
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err(format!(
|
||||
"{}:{}: leeres $INCLUDE",
|
||||
path.display(),
|
||||
first_line + line_no as u32
|
||||
));
|
||||
}
|
||||
let found = self.resolve_include(path.parent().unwrap_or(Path::new(".")), &name);
|
||||
let included = found.map_err(|e| {
|
||||
format!("{}:{}: {e}", path.display(), first_line + line_no as u32)
|
||||
})?;
|
||||
let source = self.read(&included)?.text();
|
||||
out.extend(self.expand(&included, &source, 1, stack)?);
|
||||
} else {
|
||||
out.push(SourceSegment {
|
||||
file: path.display().to_string(),
|
||||
first_line: first_line + line_no as u32,
|
||||
text: line.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn include_name(line: &str) -> Option<(String, usize)> {
|
||||
if !line.to_ascii_uppercase().contains("$INCLUDE") {
|
||||
return None;
|
||||
}
|
||||
tb_frontend::lexer::lex(line)
|
||||
.tokens
|
||||
.into_iter()
|
||||
.find_map(|token| {
|
||||
if let tb_frontend::lexer::TokenKind::MetaInclude(name) = token.kind {
|
||||
Some((name, token.pos.column.saturating_sub(1) as usize))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn relative_case_insensitive(base: &Path, relative: &str) -> std::io::Result<PathBuf> {
|
||||
let direct = base.join(relative);
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
let wanted = Path::new(relative);
|
||||
let mut current = identity(base)?;
|
||||
for component in wanted.components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
current.pop();
|
||||
}
|
||||
Component::Normal(name) => {
|
||||
let entry = std::fs::read_dir(¤t)?.find_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&name.to_string_lossy())
|
||||
.then(|| entry.path())
|
||||
});
|
||||
current = entry.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("{} nicht gefunden", current.join(name).display()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => current.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
193
crates/tb-vm/tests/project_io.rs
Normal file
193
crates/tb-vm/tests/project_io.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tb_vm::project_io::{identity, relative_path, Content, Manifest, SourceLoader};
|
||||
struct Temp(PathBuf);
|
||||
impl Temp {
|
||||
fn new() -> Self {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"tb-project-io-{}-{}",
|
||||
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
|
||||
}
|
||||
}
|
||||
impl Drop for Temp {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn overlays_keep_original_files_and_distinct_module_contexts() {
|
||||
let t = Temp::new();
|
||||
let p = t.write("app.mak", "a.bas\nb.bas\n");
|
||||
t.write("a.bas", "'$INCLUDE: 'common.bi'\nPRINT n\nEND\n");
|
||||
t.write("b.bas", "'$INCLUDE: 'common.bi'\nSUB Test\nEND SUB\n");
|
||||
let include = t.write("common.bi", "CONST n=1\n");
|
||||
let mut loader = SourceLoader::default();
|
||||
loader
|
||||
.insert(&include, Content::Text("CONST n=7\n".into()))
|
||||
.unwrap();
|
||||
let input = loader.load(&p).unwrap();
|
||||
let mut origins = Vec::new();
|
||||
for (i, u) in input.units.iter().enumerate() {
|
||||
let (_, errors) = u.parse(i as u16, &mut origins);
|
||||
assert!(errors.is_empty());
|
||||
assert!(u
|
||||
.segments
|
||||
.iter()
|
||||
.any(|s| s.text == "CONST n=7\n" && s.first_line == 1));
|
||||
}
|
||||
let positions: Vec<_> = origins
|
||||
.iter()
|
||||
.filter(|o| Path::new(&o.path).file_name().unwrap() == "common.bi")
|
||||
.collect();
|
||||
assert_eq!(positions.len(), 2);
|
||||
assert_ne!(positions[0].module, positions[1].module);
|
||||
assert_eq!(fs::read_to_string(include).unwrap(), "CONST n=1\n");
|
||||
}
|
||||
#[test]
|
||||
fn relative_include_precedes_search_paths_and_virtual_files_are_resolved() {
|
||||
let t = Temp::new();
|
||||
let main = t.write("src/main.bas", "'$INCLUDE: 'Mixed Name.bi'\n");
|
||||
t.write("library/mixed name.bi", "PRINT 8\n");
|
||||
let mut loader = SourceLoader::default();
|
||||
loader.include_paths.push(t.0.join("library"));
|
||||
assert!(loader.load(&main).unwrap().units[0]
|
||||
.segments
|
||||
.iter()
|
||||
.any(|s| s.text == "PRINT 8\n"));
|
||||
loader
|
||||
.insert(
|
||||
&t.0.join("src/MIXED NAME.BI"),
|
||||
Content::Text("PRINT 9\n".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(loader.load(&main).unwrap().units[0]
|
||||
.segments
|
||||
.iter()
|
||||
.any(|s| s.text == "PRINT 9\n"));
|
||||
let virtual_main = t.0.join("virtual.bas");
|
||||
loader
|
||||
.insert(&virtual_main, Content::Text("END\n".into()))
|
||||
.unwrap();
|
||||
assert_eq!(loader.load(&virtual_main).unwrap().units[0].name, "VIRTUAL");
|
||||
assert!(!virtual_main.exists());
|
||||
}
|
||||
#[test]
|
||||
fn cycle_missing_include_and_literal_directives_are_distinguished() {
|
||||
let t = Temp::new();
|
||||
let main = t.write("main.bas", "'$INCLUDE: 'outer.bi'\n");
|
||||
t.write("outer.bi", "'$INCLUDE: 'inner.bi'\n");
|
||||
t.write("inner.bi", "'$INCLUDE: 'outer.bi'\n");
|
||||
let loader = SourceLoader::default();
|
||||
let error = loader.load(&main).unwrap_err();
|
||||
assert!(
|
||||
error.contains("zyklisches") && error.contains("outer.bi") && error.contains("inner.bi")
|
||||
);
|
||||
t.write("inner.bi", "'$INCLUDE: 'missing.bi'\n");
|
||||
let error = loader.load(&main).unwrap_err();
|
||||
assert!(error.contains("inner.bi:1") && error.contains("missing.bi"));
|
||||
t.write("main.bas","PRINT \"$INCLUDE: 'missing.bi'\"\n' Dieser Kommentar erwähnt $INCLUDE: 'missing.bi'\nDATA $INCLUDE: 'missing.bi'\nEND\n");
|
||||
let input = loader.load(&main).unwrap();
|
||||
assert!(input.units[0]
|
||||
.segments
|
||||
.iter()
|
||||
.any(|s| s.text.starts_with("PRINT")));
|
||||
t.write("main.bas", "PRINT 1: '$INCLUDE: 'good.bi'\nEND\n");
|
||||
t.write("good.bi", "PRINT 2\n");
|
||||
let input = loader.load(&main).unwrap();
|
||||
let code = input.units[0]
|
||||
.segments
|
||||
.iter()
|
||||
.map(|s| s.text.as_str())
|
||||
.collect::<String>();
|
||||
assert!(code.contains("PRINT 1:") && code.contains("PRINT 2"));
|
||||
t.write("main.bas", "'$INCLUDE: 'unterminated\n");
|
||||
assert!(loader.load(&main).unwrap_err().contains("leeres $INCLUDE"));
|
||||
}
|
||||
#[test]
|
||||
fn startup_metadata_roundtrip_and_invalid_contracts() {
|
||||
let t = Temp::new();
|
||||
t.write("a.bas", "END\n");
|
||||
t.write("b.bas", "");
|
||||
let p = t.write(
|
||||
"p.mak",
|
||||
"' keep this\nA.BAS\n\n' $STARTUP: \"b.bas\"\nb.bas\n",
|
||||
);
|
||||
let loader = SourceLoader::default();
|
||||
let input = loader.load(&p).unwrap();
|
||||
let output = input.manifest.text(&t.0.join("nested/new.mak")).unwrap();
|
||||
assert!(output.contains("../a.bas") || output.contains("../A.BAS"));
|
||||
assert!(output.contains("' keep this"));
|
||||
let roundtrip = Manifest::parse(&t.0.join("nested/new.mak"), &output, &loader).unwrap();
|
||||
assert_eq!(roundtrip.members().count(), 2);
|
||||
assert_eq!(
|
||||
identity(roundtrip.startup.as_ref().unwrap()).unwrap(),
|
||||
identity(&t.0.join("b.bas")).unwrap()
|
||||
);
|
||||
for text in [
|
||||
"' $STARTUP: \"a.bas\"\n' $STARTUP: \"a.bas\"\na.bas\n",
|
||||
"' $STARTUP: a.bas\na.bas\n",
|
||||
"' $STARTUP: \"b.bas\"\na.bas\n",
|
||||
"' $STARTUP: \"\"\na.bas\n",
|
||||
"' $STARTUP: \"missing.bas\"\na.bas\n",
|
||||
] {
|
||||
assert!(Manifest::parse(&p, text, &loader).is_err(), "{text}");
|
||||
}
|
||||
assert!(Manifest::parse(&p, "a.bas\n", &loader)
|
||||
.unwrap()
|
||||
.startup
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
relative_path(&t.0.join("nested"), &t.0.join("a.bas")).unwrap(),
|
||||
"../a.bas"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_mak_example_is_accepted_by_the_shared_loader() {
|
||||
let t = Temp::new();
|
||||
t.write("main.bas", "END\n");
|
||||
t.write("lib.bas", "");
|
||||
t.write("form.frm", "VERSION 1.00\nBegin Form F\nEnd\n");
|
||||
let docs = include_str!("../../../docs/dateiformate.md");
|
||||
let example = docs
|
||||
.split("```mak\n")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("```")
|
||||
.next()
|
||||
.unwrap();
|
||||
let path = t.write("example.mak", example);
|
||||
let result = SourceLoader::default().load(&path).unwrap();
|
||||
assert_eq!(result.units.len(), 3);
|
||||
assert_eq!(result.forms.len(), 1);
|
||||
assert_eq!(
|
||||
result.manifest.startup.unwrap().file_name().unwrap(),
|
||||
"main.bas"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_parent_lookup_works_from_relative_base_without_changing_cwd() {
|
||||
let t = Temp::new();
|
||||
let sibling = t.write("Sibling.bas", "END\n");
|
||||
fs::create_dir(t.0.join("child")).unwrap();
|
||||
let base = PathBuf::from(
|
||||
relative_path(&std::env::current_dir().unwrap(), &t.0.join("child")).unwrap(),
|
||||
);
|
||||
let found = tb_vm::project_io::relative_case_insensitive(&base, "../SIBLING.BAS").unwrap();
|
||||
assert_eq!(identity(&found).unwrap(), identity(&sibling).unwrap());
|
||||
}
|
||||
@@ -135,6 +135,74 @@ akzeptiert zusätzlich Kommentarzeilen mit `'`. `tbc check`, `build` und
|
||||
DOS-Großschreibung auf; Formularobjekte aller `.FRM`-Einträge stehen dem
|
||||
gemeinsamen Modulverbund zur Verfügung.
|
||||
|
||||
Optional speichert das Projekt eine ausdrücklich gewählte Startdatei in
|
||||
einem Metakommentar (Terminal-Basic-Erweiterung):
|
||||
|
||||
```mak
|
||||
' Beispielprojekt
|
||||
' $STARTUP: "main.bas"
|
||||
main.bas
|
||||
lib.bas
|
||||
form.frm
|
||||
```
|
||||
|
||||
Der Wert muss genau ein vorhandenes BAS-/FRM-Mitglied bezeichnen. Doppelte,
|
||||
leere, fehlerhafte oder auf Nichtmitglieder gerichtete Angaben werden mit
|
||||
Dateipfad diagnostiziert. Ohne Metakommentar bleiben die bisherige
|
||||
Modulreihenfolge und Startformularwahl erhalten. Der gemeinsame Lader
|
||||
erhält die Auswahl als `ProjectSources.manifest.startup`; ihre zusätzliche
|
||||
Ausführungswirkung gehört zum Phase-5-Change 04. Ältere Leser ignorieren
|
||||
diese Kommentarzeile. Mitgliedsreihenfolge, Leerzeilen und gewöhnliche
|
||||
Kommentare bleiben beim Speichern erhalten; der Startup-Kommentar wird
|
||||
kanonisch vorangestellt.
|
||||
|
||||
### Bearbeitete Dokumente und Speichern
|
||||
|
||||
`tb_vm::project_io::SourceLoader` ist der gemeinsame BAS-/FRM-/MAK- und
|
||||
Include-Lader von CLI und IDE-Modell. Bearbeitete Dokumente überlagern ihren
|
||||
Plattenstand; Includes behalten ihre physische Datei, Zeile und den
|
||||
jeweiligen Modulkontext. Relative Includes gewinnen vor optionalen
|
||||
Include-Suchverzeichnissen. Dateialiasse werden für die Dokumentverwaltung
|
||||
auf eine gemeinsame absolute Identität aufgelöst; noch ungespeicherte
|
||||
Dokumente haben ebenfalls einen stabilen Quellpfad, aber keinen Speicherpfad.
|
||||
|
||||
`tb_ide::documents::Project` stellt Projekt-, Datei-, Textimport-/Export-,
|
||||
Undo- und Schließaktionen als direkt testbare API bereit. Ansichten haben
|
||||
eigene Cursor-/Scrollpositionen und teilen den Dokumentinhalt. Ein
|
||||
Formulardokument enthält den vorhandenen `FormFile` einschließlich Code;
|
||||
Importwarnungen bleiben über `Document::import_warnings` abrufbar. Die
|
||||
Terminaldialoge werden in Change 02 daran angebunden.
|
||||
|
||||
Save File und Save As schreiben zunächst eine temporäre Datei im
|
||||
Zielverzeichnis und ersetzen das Ziel erst nach erfolgreichem Schreiben.
|
||||
Externe Änderungen und bestehende neue Ziele erfordern eine ausdrückliche
|
||||
Überschreibentscheidung für die jeweilige Datei. Schreibschutz und binäre
|
||||
FRM-Originale bleiben geschützt. Binärimporte benötigen für Save File ein
|
||||
anderes, ausdrücklich gewähltes FRM-Textziel. Ein Dateiexport verändert
|
||||
weder Projektmitgliedschaft noch Dokumentpfad.
|
||||
|
||||
Save Project speichert geänderte Mitglieder und geöffnete geänderte
|
||||
Include-/Textdokumente vor der MAK-Datei. Es gibt keine atomare Transaktion
|
||||
über mehrere Dateien: Bei einem Teilfehler bleiben bereits erfolgreich
|
||||
gespeicherte Dokumente gespeichert, die übrigen geändert und offen. Ein
|
||||
Schließ-/Projektwechsel wird dann nicht als erfolgreich gemeldet. Die
|
||||
Konfliktprüfung wird unmittelbar vor dem Ersetzen wiederholt; sie ist ein
|
||||
optimistischer Abgleich und keine Sperre für fremde Editoren.
|
||||
|
||||
Projekt-Save-As berechnet die Mitgliedsverweise relativ zum neuen
|
||||
Projektverzeichnis. Save As eines Moduls oder Formulars in ein anderes
|
||||
Verzeichnis passt dessen relative Includes so an, dass sie dieselben
|
||||
Dateien erreichen; unveränderte Originaldateien bleiben erhalten. Die
|
||||
aktive Datei-/Projektidentität wechselt erst nach erfolgreicher Ausgabe.
|
||||
Remove File entfernt nur die Mitgliedschaft; Dateien, geöffnete Ansichten
|
||||
und ungespeicherter Code bleiben erhalten. Für die aktuelle Startdatei
|
||||
muss ausdrücklich Ersatz, Standard oder Abbrechen gewählt werden.
|
||||
|
||||
Load Text fügt UTF-8-Text am Cursor als eine rückgängig machbare Aktion ein.
|
||||
Save Text exportiert Auswahl oder gesamten Code. Zum Überschreiben eines
|
||||
geöffneten Dokuments wird Save File verwendet, damit Dokumentstand und
|
||||
Formularstruktur nicht durch einen reinen Textexport auseinanderlaufen.
|
||||
|
||||
## Record-Dateien (`GET`/`PUT`, `OPEN … FOR RANDOM`)
|
||||
|
||||
Feste Strings (`STRING * n`) in Records werden als **UTF-32LE** gespeichert
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Planungsstand: 2026-09-06, Codebasis `024336e29ce0494fc268452e5eaf48c4ff68fbb2`.
|
||||
Diese Artefakte planen die Umsetzung; sie bestätigen keine bereits implementierte IDE.
|
||||
Maßgeblich sind [PLAN.md](../../../PLAN.md), die [IDE-Referenz](../../../docs/ide-referenz.md) und die bestehenden Hauptspezifikationen.
|
||||
Maßgeblich sind [PLAN.md](../../../../PLAN.md), die [IDE-Referenz](../../../../docs/ide-referenz.md) und die bestehenden Hauptspezifikationen.
|
||||
|
||||
## Ergebnis der Exploration
|
||||
|
||||
@@ -10,17 +10,17 @@ Zum Ausgangsstand hatte Phase 5 acht offene und zwei bereits erfüllte PLAN-Punk
|
||||
|
||||
| Bereich | Beobachteter Stand | Konsequenz für die Planung |
|
||||
|---|---|---|
|
||||
| [IDE-Einstieg](../../../crates/tb-ide/src/main.rs) | Nur Platzhalterausgabe; notwendige Workspace-Abhängigkeiten schon vorhanden | Rahmen, konkrete Fenster und Ereignisverteilung in 02 |
|
||||
| [CLI-Treiber](../../../crates/tb-cli/src/main.rs) | Private Projekt-/Include-/FRM-Lader, Compile, RUN-Zielauflösung und Reset | Laden in 01 gemeinsam nutzbar machen; Sitzung/Reset in 04 |
|
||||
| [Quellmodell](../../../crates/tb-frontend/src/source.rs) | SourceUnit/SourceSegment erhalten Originaldateien und physische Zeilen | Identitäten wiederverwenden; ungespeicherte Dokumente ergänzen |
|
||||
| [Projektcompiler](../../../crates/tb-vm/src/project.rs) | Übersetzt alle Module erneut, verknüpft Module und Forms; erstes Formular wird Startformular | Modulcache in 03; explizite Startup-Auswahl in 01/04 |
|
||||
| [VM](../../../crates/tb-vm/src/interp.rs) | RunEvents, Modul-/Zeilenbreakpoints, Step und einfache Inspektion vorhanden | Sichere Poll-/Wartezustände in 04; vollständiger Debugger in 06 |
|
||||
| [Runtime-Builtins](../../../crates/tb-runtime/src/builtins.rs) | Wartende Eingaben/Dialogs und direkte SHELL-Kindprozesse | Nicht nur den Run-Aufruf einbetten: Fortsetzungen und Terminalübergabe explizit umsetzen |
|
||||
| [Terminalhost](../../../crates/tb-ui/src/host.rs) | BASIC-Tastencodierung mit Ctrl+C-Abbruch und F1–F10 | IDE routet eigene Tasten einschließlich F11/F12 vor BASIC-Zustellung |
|
||||
| [ScreenWidget](../../../crates/tb-ui/src/screen.rs) | Mindestgröße wird an der Zeichenfläche geprüft | Physisches Terminal und kleines Output-Unterfenster unterscheiden |
|
||||
| [FRM-Modell](../../../crates/tb-ui/src/frm.rs) | Baum, Code, Binärimport, unveränderte/kanonische Textausgabe vorhanden | 05 editiert dieses Modell; keine zweite FRM-Implementierung |
|
||||
| [Forms-Metadaten](../../../crates/tb-frontend/src/forms.rs) | Klassen, Eigenschaften, Ereignisse und Arrayverträge vorhanden | Toolbox, Propertyvalidierung und Handlererzeugung daraus ableiten |
|
||||
| [Dokumentation](../../../docs/ide-referenz.md) | Rekonstruierte Menüs/Tasten, Farbschema, Designer und Hilfeverhalten beschrieben | Vollständige Befehlszuordnung unten; Dokumente als Offline-Hilfe in 07 |
|
||||
| [IDE-Einstieg](../../../../crates/tb-ide/src/main.rs) | Nur Platzhalterausgabe; notwendige Workspace-Abhängigkeiten schon vorhanden | Rahmen, konkrete Fenster und Ereignisverteilung in 02 |
|
||||
| [CLI-Treiber](../../../../crates/tb-cli/src/main.rs) | Private Projekt-/Include-/FRM-Lader, Compile, RUN-Zielauflösung und Reset | Laden in 01 gemeinsam nutzbar machen; Sitzung/Reset in 04 |
|
||||
| [Quellmodell](../../../../crates/tb-frontend/src/source.rs) | SourceUnit/SourceSegment erhalten Originaldateien und physische Zeilen | Identitäten wiederverwenden; ungespeicherte Dokumente ergänzen |
|
||||
| [Projektcompiler](../../../../crates/tb-vm/src/project.rs) | Übersetzt alle Module erneut, verknüpft Module und Forms; erstes Formular wird Startformular | Modulcache in 03; explizite Startup-Auswahl in 01/04 |
|
||||
| [VM](../../../../crates/tb-vm/src/interp.rs) | RunEvents, Modul-/Zeilenbreakpoints, Step und einfache Inspektion vorhanden | Sichere Poll-/Wartezustände in 04; vollständiger Debugger in 06 |
|
||||
| [Runtime-Builtins](../../../../crates/tb-runtime/src/builtins.rs) | Wartende Eingaben/Dialogs und direkte SHELL-Kindprozesse | Nicht nur den Run-Aufruf einbetten: Fortsetzungen und Terminalübergabe explizit umsetzen |
|
||||
| [Terminalhost](../../../../crates/tb-ui/src/host.rs) | BASIC-Tastencodierung mit Ctrl+C-Abbruch und F1–F10 | IDE routet eigene Tasten einschließlich F11/F12 vor BASIC-Zustellung |
|
||||
| [ScreenWidget](../../../../crates/tb-ui/src/screen.rs) | Mindestgröße wird an der Zeichenfläche geprüft | Physisches Terminal und kleines Output-Unterfenster unterscheiden |
|
||||
| [FRM-Modell](../../../../crates/tb-ui/src/frm.rs) | Baum, Code, Binärimport, unveränderte/kanonische Textausgabe vorhanden | 05 editiert dieses Modell; keine zweite FRM-Implementierung |
|
||||
| [Forms-Metadaten](../../../../crates/tb-frontend/src/forms.rs) | Klassen, Eigenschaften, Ereignisse und Arrayverträge vorhanden | Toolbox, Propertyvalidierung und Handlererzeugung daraus ableiten |
|
||||
| [Dokumentation](../../../../docs/ide-referenz.md) | Rekonstruierte Menüs/Tasten, Farbschema, Designer und Hilfeverhalten beschrieben | Vollständige Befehlszuordnung unten; Dokumente als Offline-Hilfe in 07 |
|
||||
|
||||
Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Textänderung, Include-Breakpoints ohne Dateiidentität, wiederholte I/O-Nebenwirkungen nach einer Pause und voneinander abweichende Designer-/FRM-/Codezustände. Jeder dieser Fälle hat konkrete Szenarien und Tasks in seinem zuständigen Change.
|
||||
|
||||
@@ -29,13 +29,13 @@ Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Text
|
||||
| Nr. | Change | Direkt benötigte Vorgänger | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| 01 | [Projekt- und Dokumentmodell](proposal.md) | keine | Gemeinsame Lade-/Speicheraktionen, bearbeitete Quellen, MAK und Startup-Metadaten |
|
||||
| 02 | [IDE-Rahmen](../phase-5-02-ide-rahmen/proposal.md) | 01 | Terminalbesitz, Menüs, Fenster, Dialoge, Optionen und Eingabetestpfad |
|
||||
| 03 | [Editor und inkrementelle Übersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile |
|
||||
| 04 | [Ausführung und Output](../phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck |
|
||||
| 05 | [Formular-Designer](../phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode |
|
||||
| 06 | [Debugger und Direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte |
|
||||
| 07 | [Hilfesystem](../phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation |
|
||||
| 08 | [Integration und Phasenabnahme](../phase-5-08-integration-und-phasenabnahme/proposal.md) | 01–07 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status |
|
||||
| 02 | [IDE-Rahmen](../../phase-5-02-ide-rahmen/proposal.md) | 01 | Terminalbesitz, Menüs, Fenster, Dialoge, Optionen und Eingabetestpfad |
|
||||
| 03 | [Editor und inkrementelle Übersetzung](../../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile |
|
||||
| 04 | [Ausführung und Output](../../phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck |
|
||||
| 05 | [Formular-Designer](../../phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode |
|
||||
| 06 | [Debugger und Direktfenster](../../phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte |
|
||||
| 07 | [Hilfesystem](../../phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation |
|
||||
| 08 | [Integration und Phasenabnahme](../../phase-5-08-integration-und-phasenabnahme/proposal.md) | 01–07 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status |
|
||||
|
||||
```text
|
||||
01 --> 02 --> 03 --> 04 --> 06 --+
|
||||
@@ -140,7 +140,7 @@ Die lokale IDE-Referenz enthält bewusst offene beziehungsweise nur grob beschri
|
||||
|
||||
**Geklärte Benutzervorgabe (2026-09-06):** Make EXE erstellt ein eigenständiges natives Executable, Make Library eine tatsächliche Bibliothek im jeweiligen Systemformat. Die vollständige IDE-Bedienung gehört zu Phase 5: beide Dialoge, Zielsystem/-architektur, Ausgabepfad, Prüfung, Überschreibentscheidung, Status/Fehler/Abbruch und die konkrete Auftrags-/Ergebnisübergabe. Diese Arbeit liegt in 02 und wird in 08 integriert geprüft. Bis Phase 6 fehlen ausschließlich die tatsächliche Erzeugung und die reale Backend-Anbindung; die Dialoge bleiben bedienbar und erklären die gesperrte Erzeugen-Aktion. Kontrollierte Ergebnisrückmeldungen sind UI-Tests und kein Beleg für native Artefakterzeugung.
|
||||
|
||||
[PLAN.md](../../../PLAN.md) enthält dafür getrennte Aufgaben: Export-UI in Phase 5 sowie native Standalone-Executables und native Systembibliotheken in Phase 6. Die dortige Executable-Erzeugung kann den bereits geplanten vorkompilierten Runner mit eingebettetem Bytecode verwenden; das Ergebnis muss ohne separat installiertes tb/tbc direkt ausführbar sein. Der Bibliothekspunkt verlangt natives Format, Symbol-/Aufruf-/Linkvertrag und einen nativen Verbraucher-Nachweis. Eine bloße oder umbenannte `.tbc`-Datei erfüllt keinen dieser beiden Exportpunkte. Format- und ABI-Details der Bibliothek werden beim Phase-6-Backend festgelegt, ohne in Phase 5 eine zweite Formatimplementierung in der UI vorwegzunehmen.
|
||||
[PLAN.md](../../../../PLAN.md) enthält dafür getrennte Aufgaben: Export-UI in Phase 5 sowie native Standalone-Executables und native Systembibliotheken in Phase 6. Die dortige Executable-Erzeugung kann den bereits geplanten vorkompilierten Runner mit eingebettetem Bytecode verwenden; das Ergebnis muss ohne separat installiertes tb/tbc direkt ausführbar sein. Der Bibliothekspunkt verlangt natives Format, Symbol-/Aufruf-/Linkvertrag und einen nativen Verbraucher-Nachweis. Eine bloße oder umbenannte `.tbc`-Datei erfüllt keinen dieser beiden Exportpunkte. Format- und ABI-Details der Bibliothek werden beim Phase-6-Backend festgelegt, ohne in Phase 5 eine zweite Formatimplementierung in der UI vorwegzunehmen.
|
||||
|
||||
## Abnahmepfad
|
||||
|
||||
@@ -25,4 +25,4 @@ crates/tb-cli/src/main.rs, crates/tb-vm/src/project.rs, crates/tb-frontend/src/s
|
||||
|
||||
**Abhängigkeiten:** Keine offenen Vorgänger; Einstieg in Phase 5.
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
@@ -1,21 +1,21 @@
|
||||
## 1. Gemeinsame Quellen und Projektdateien
|
||||
|
||||
- [ ] 1.1 Die privaten BAS/FRM/MAK-/Include-Ladefunktionen aus tb-cli in die vorhandene gemeinsame Crate übernehmen und alle CLI-Aufrufer umstellen; die bisherigen Projekt-, Include- und Diagnoseorttests müssen unverändert bestehen.
|
||||
- [ ] 1.2 Dokumentüberlagerung und stabile virtuelle/physische Quellidentitäten ergänzen; ein Test mit zwei Modulen und einem ungespeicherten Include belegt denselben neuen Inhalt bei getrennten Modulkontexten.
|
||||
- [ ] 1.3 Optionale Include-Suchpfade mit Vorrang der relativen Datei anbinden; Tests belegen Fallback, Großschreibung, Leerzeichen, fehlende Dateien und benannte Include-Zyklen.
|
||||
- [ ] 1.4 MAK-Mitgliedschaft, Reihenfolge und optionalen STARTUP-Metakommentar lesen/schreiben; Roundtrip-Tests belegen alte Dateien ohne Metadaten sowie Ablehnung doppelter/ungültiger Startangaben.
|
||||
- [x] 1.1 Die privaten BAS/FRM/MAK-/Include-Ladefunktionen aus tb-cli in die vorhandene gemeinsame Crate übernehmen und alle CLI-Aufrufer umstellen; die bisherigen Projekt-, Include- und Diagnoseorttests müssen unverändert bestehen.
|
||||
- [x] 1.2 Dokumentüberlagerung und stabile virtuelle/physische Quellidentitäten ergänzen; ein Test mit zwei Modulen und einem ungespeicherten Include belegt denselben neuen Inhalt bei getrennten Modulkontexten.
|
||||
- [x] 1.3 Optionale Include-Suchpfade mit Vorrang der relativen Datei anbinden; Tests belegen Fallback, Großschreibung, Leerzeichen, fehlende Dateien und benannte Include-Zyklen.
|
||||
- [x] 1.4 MAK-Mitgliedschaft, Reihenfolge und optionalen STARTUP-Metakommentar lesen/schreiben; Roundtrip-Tests belegen alte Dateien ohne Metadaten sowie Ablehnung doppelter/ungültiger Startangaben.
|
||||
|
||||
## 2. Bearbeitbare Dokumente und sichere Dateiaktionen
|
||||
|
||||
- [ ] 2.1 Projekt-/Dokumentzustand mit Revision, Pfad, gespeichertem Stand und mehreren Ansichten einführen; ein Test belegt gemeinsame Änderungen bei unabhängigen Cursor-/Scrollpositionen.
|
||||
- [ ] 2.2 New/Open Project, New Module/Form, Add/Remove File und Startdateiwahl als Dokumentaktionen implementieren; Tests belegen Abbruch ohne Teiländerung und Entfernen ohne Dateilöschung.
|
||||
- [ ] 2.3 FRM-Code und FormFile-Struktur als gemeinsames Dokument führen; ein Roundtrip mit Control-Array, Index 0 und Ereigniscode bestätigt den bestehenden Textvertrag.
|
||||
- [ ] 2.4 Save/Save As/Save Project mit geschützten Einzeldatei-Ersetzungen, Mitglieder-vor-MAK-Reihenfolge und Konflikterkennung implementieren; injizierte Schreibfehler und externe Änderungen erhalten jeweils den bearbeiteten Stand und letzte gültige Datei.
|
||||
- [ ] 2.5 Projekt-Save-As und Dokumentpfadwechsel korrekt auf relative Referenzen anwenden; ein Wiederöffnungstest aus einem anderen Verzeichnis lädt dieselben Mitglieder/Includes.
|
||||
- [ ] 2.6 Binär-FRM-Import mit ausdrücklichem Textziel sowie Load/Save Text implementieren; Tests belegen unveränderte Binärquelle und rückgängig machbaren Textimport.
|
||||
- [ ] 2.7 Speichern/Verwerfen/Abbrechen als gemeinsame Wechsel-/Schließaktion anbieten; Tests für mehrere geänderte Dokumente und Teilspeicherfehler verhindern falsches Schließen.
|
||||
- [x] 2.1 Projekt-/Dokumentzustand mit Revision, Pfad, gespeichertem Stand und mehreren Ansichten einführen; ein Test belegt gemeinsame Änderungen bei unabhängigen Cursor-/Scrollpositionen.
|
||||
- [x] 2.2 New/Open Project, New Module/Form, Add/Remove File und Startdateiwahl als Dokumentaktionen implementieren; Tests belegen Abbruch ohne Teiländerung und Entfernen ohne Dateilöschung.
|
||||
- [x] 2.3 FRM-Code und FormFile-Struktur als gemeinsames Dokument führen; ein Roundtrip mit Control-Array, Index 0 und Ereigniscode bestätigt den bestehenden Textvertrag.
|
||||
- [x] 2.4 Save/Save As/Save Project mit geschützten Einzeldatei-Ersetzungen, Mitglieder-vor-MAK-Reihenfolge und Konflikterkennung implementieren; injizierte Schreibfehler und externe Änderungen erhalten jeweils den bearbeiteten Stand und letzte gültige Datei.
|
||||
- [x] 2.5 Projekt-Save-As und Dokumentpfadwechsel korrekt auf relative Referenzen anwenden; ein Wiederöffnungstest aus einem anderen Verzeichnis lädt dieselben Mitglieder/Includes.
|
||||
- [x] 2.6 Binär-FRM-Import mit ausdrücklichem Textziel sowie Load/Save Text implementieren; Tests belegen unveränderte Binärquelle und rückgängig machbaren Textimport.
|
||||
- [x] 2.7 Speichern/Verwerfen/Abbrechen als gemeinsame Wechsel-/Schließaktion anbieten; Tests für mehrere geänderte Dokumente und Teilspeicherfehler verhindern falsches Schließen.
|
||||
|
||||
## 3. Nachweise und Dokumentation
|
||||
|
||||
- [ ] 3.1 docs/dateiformate.md um die optionale Startup-Erweiterung und Speicher-/Importregeln ergänzen; Beispiele werden vom gemeinsamen Loader als erwarteter Projektzustand gelesen.
|
||||
- [ ] 3.2 Die neue API durch IDE-Backend- und vorhandene CLI-Integrationstests gemeinsam prüfen und den Nachweis je Spec-Szenario in verification.md festhalten; cargo fmt --all -- --check und die betroffenen Crate-Tests müssen bestehen.
|
||||
- [x] 3.1 docs/dateiformate.md um die optionale Startup-Erweiterung und Speicher-/Importregeln ergänzen; Beispiele werden vom gemeinsamen Loader als erwarteter Projektzustand gelesen.
|
||||
- [x] 3.2 Die neue API durch IDE-Backend- und vorhandene CLI-Integrationstests gemeinsam prüfen und den Nachweis je Spec-Szenario in verification.md festhalten; cargo fmt --all -- --check und die betroffenen Crate-Tests müssen bestehen.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Verifizierung: Phase 5 – Projekt- und Dokumentmodell
|
||||
|
||||
Stand: 2026-09-06. Geprüft wurden die Implementierungsänderungen auf Basis
|
||||
von `747ec34c6a18fb40ed3f915998e58801f2c36bc9` gegen Proposal, Design,
|
||||
Tasks und `specs/ide-projekte/spec.md` dieses Changes.
|
||||
|
||||
## Ergebnis
|
||||
|
||||
| Dimension | Ergebnis |
|
||||
| --- | --- |
|
||||
| Vollständigkeit | 13/13 Tasks, 5/5 Anforderungen und 9/9 Szenarien umgesetzt und geprüft |
|
||||
| Korrektheit | Szenarien durch Backend-/Loader-Tests und bestehende CLI-Integration abgesichert |
|
||||
| Kohärenz | Gemeinsamer Lader in tb-vm, ein Dokumentstand je Datei, bestehendes FormFile und dessen Leser/Schreiber wiederverwendet |
|
||||
| Offene Befunde | 0 CRITICAL, 0 WARNING, 0 SUGGESTION |
|
||||
|
||||
Die Prüfung umfasst die unmittelbar ausführbare Backend-API dieses Changes.
|
||||
Terminalmenüs und Dialoge sind gemäß Design und Phase-5-Übersicht Change 02
|
||||
zugeordnet. Die zusätzliche Ausführungswirkung der gespeicherten Startdatei
|
||||
ist Change 04 zugeordnet; hier wird die Auswahl validiert, gespeichert und
|
||||
in `ProjectSources.manifest.startup` bereitgestellt. Diese Abgrenzungen sind
|
||||
auch in `docs/dateiformate.md` festgehalten.
|
||||
|
||||
## Anforderungen und Implementierung
|
||||
|
||||
Alle Codepfade sind relativ zur Repository-Wurzel angegeben.
|
||||
|
||||
| Anforderung | Implementierung und Prüfung |
|
||||
| --- | --- |
|
||||
| Gemeinsame Projekt- und Include-Auflösung | `crates/tb-vm/src/project_io.rs`: `SourceLoader::{resolve,read,load,load_manifest,expand}` und `relative_case_insensitive`; `crates/tb-cli/src/main.rs::compile` verwendet denselben Lader wie `Project::sources`. Overlay-Inhalte, relative Includes vor Suchpfaden, virtuelle und kanonische Identitäten, Modulkontexte und physische Quellzeilen geprüft. |
|
||||
| Projektmitglieder und Startdatei | `Manifest::{parse,text,rename}` sowie `crates/tb-ide/src/documents.rs`: `Project::{new,open,new_module,new_form,add_file,remove_file,set_startup,save_project}`. Geordnete Mitglieder, gewöhnliche Kommentare, optionale eindeutige Startup-Metadaten und alle drei Entscheidungen beim Entfernen der Startdatei geprüft. Bestehende CLI-Projekttests bleiben unverändert grün. |
|
||||
| Gemeinsame Dokumente und Ansichten | `Document`, `View`, `Project::{open_document,open_view,close_view,replace_text,edit_form,undo}`. Stabile Dokument-IDs, gemeinsame Inhalte, separate Cursor-/Scrollwerte, Revision und gespeicherter Inhaltsstand; FRM-Struktur und Code liegen in genau einem `FormFile`. Originaldokumente werden getrennt von der Include-Expansion gespeichert. |
|
||||
| Verlustfreies Speichern und Wechseln | `Project::{save_file,save_project,prepare_close,open_project,new_project}`, `check_destination`, `atomic_write` und `SourceLoader::relocate`. Temporäre Einzeldateien im Zielverzeichnis, Konfliktprüfung vor Ersetzen, explizite Überschreibentscheidung, Mitglieder vor MAK und Zustandswechsel erst nach erfolgreicher Ausgabe. Teilfehler, Zielkollisionen, externe Änderungen und Save As geprüft. |
|
||||
| Formularimport und Textaustausch | `read_document` verwendet vorhandene FRM-Leser; `Content::text` verwendet den vorhandenen Schreiber. `Project::{protect_binary,load_text,save_text,edit_form,undo}` schützt Binäroriginale, verlangt ein separates Textziel und erhält Dokumentidentität/Mitgliedschaft beim Textaustausch. |
|
||||
|
||||
## Nachweis je Spec-Szenario
|
||||
|
||||
Testnamen ohne weiteren Pfad liegen in `crates/tb-ide/tests/documents.rs`.
|
||||
|
||||
| Szenario | Ausführbarer Nachweis |
|
||||
| --- | --- |
|
||||
| Ungespeichertes gemeinsames Include | `edited_include_wins_in_both_modules_without_writing_the_expansion`; zusätzlich `crates/tb-vm/tests/project_io.rs::overlays_keep_original_files_and_distinct_module_contexts` prüft Quellidentität und getrennte Modulkontexte. |
|
||||
| Fehler beim Öffnen | `failed_open_add_and_cancel_do_not_replace_existing_documents`; Loader-Test `cycle_missing_include_and_literal_directives_are_distinguished` prüft fehlende Dateien, benannte Zyklen und Diagnosepfade. |
|
||||
| Projekt wieder öffnen | `project_create_save_reopen_preserves_forms_members_startup_and_cli_sources`: zwei BAS-Module, FRM mit Control-Array einschließlich Index 0 und Ereigniscode, Startup-Auswahl, Speicherung/Wiederöffnung sowie Laden und Kompilieren über die gemeinsame CLI-Grundlage. Loader-Test `startup_metadata_roundtrip_and_invalid_contracts` prüft zusätzlich Reihenfolge und ungültige Metadaten. |
|
||||
| Startdatei entfernen | `startup_removal_requires_explicit_replacement_and_never_deletes_a_file`: fehlende Entscheidung, Abbruch, ungültiger Ersatz, gültiger Ersatz und Rückkehr zum Standard; physische Datei und geöffnetes Dokument bleiben erhalten. |
|
||||
| Zweites Codefenster | `shared_views_text_import_and_undo_are_document_transactions`: zwei Ansichten teilen Änderungen, eigene Positionen bleiben erhalten, Schließen einer Ansicht entfernt das Dokument nicht. |
|
||||
| Schreibfehler im Projekt | `partial_save_preserves_unsaved_data_and_does_not_finish_close`: zweites Mitglied schreibgeschützt, erstes erfolgreich gespeichert, zweites unverändert auf Platte und weiterhin dirty, MAK unverändert, Schließen verhindert; erfolgreicher Wiederholungsversuch und Tempdatei-Aufräumen geprüft. |
|
||||
| Save As in anderes Verzeichnis | `project_save_as_rebases_members_and_keeps_include_targets`: Wiederöffnung lädt dieselben Inhalte über angepasste relative Mitgliedspfade; fehlendes Zielverzeichnis erhält den bisherigen Projektpfad. `module_save_as_rebases_includes_and_failed_save_does_not_change_identity_or_text` deckt zusätzlich Dokument-Save-As ab. |
|
||||
| Binäres Formular bearbeiten | `binary_import_only_saves_to_explicit_text_target_and_preserves_original_forever`: vorhandene Binärfixture importiert, Code bearbeitet, normales Save abgelehnt, ausdrückliches Textziel gespeichert und wieder geöffnet; Originalbytes bleiben unverändert, auch ausdrückliches späteres Überschreiben des Binärpfads wird verhindert. Struktur-/Array-Vertrag zusätzlich durch FRM-Roundtrip im Projekt-Test und bestehende tb-ui-Tests abgesichert. |
|
||||
| Textimport rückgängig machen | `shared_views_text_import_and_undo_are_document_transactions`: Text am Cursor eingefügt, Undo stellt ursprünglichen Unicode-Text und Ansichtspositionen wieder her; Auswahl-/Gesamtexport verändert die Mitgliedschaft nicht. |
|
||||
|
||||
## Behobene Befunde und zusätzliche Grenzfälle
|
||||
|
||||
- Include-Erkennung verwendet den vorhandenen Lexer: Stringliterale, DATA und gewöhnliche Kommentare lösen keine Dateizugriffe aus; Code vor einem echten Inline-Include bleibt erhalten.
|
||||
- Bei veränderter Formularstruktur werden physische Codezeilen aus der tatsächlichen Textausgabe ermittelt. `aliases_share_documents_and_form_code_offsets_match_saved_text` prüft dies zusammen mit Großschreibungs- und Symlink-Aliasen.
|
||||
- Cursorverschiebung bei verkürzendem Ersetzen verwendet die ursprüngliche Position. Öffnen nach erfolgreichem Speichern liest den neuen Plattenstand. Nachweis: `cursor_after_shortening_uses_original_position_and_open_after_save_uses_fresh_disk`.
|
||||
- Dokument-Save-As passt relative Includes an; bei einem fehlgeschlagenen Schreibversuch bleiben ursprünglicher Text und Pfad aktiv. Der Regressionstest prüft auch Kommentare am Include und unveränderte Originaldateien.
|
||||
- Großschreibungsunabhängige Pfadauflösung über `..` startet bei einer absoluten Basis. Nachweis: `case_insensitive_parent_lookup_works_from_relative_base_without_changing_cwd` im Loader-Testziel.
|
||||
- Nicht als Textverweis darstellbare Ausgabepfade werden vor dem Schreiben abgelehnt. `unrepresentable_save_paths_fail_before_writing_or_changing_documents` prüft Nicht-UTF-8- und Zeilenumbruchpfade sowie verlustfreie Ablehnung bei MAK-Serialisierung.
|
||||
- `external_changes_and_save_as_collisions_need_explicit_decisions` und `save_plan_prevents_same_destination_and_project_overwrite` sichern Konfliktentscheidungen und kollidierende Ausgaben ab.
|
||||
- `documented_mak_example_is_accepted_by_the_shared_loader` lädt den tatsächlichen MAK-Beispielblock aus `docs/dateiformate.md`.
|
||||
|
||||
Die abschließende Wiederholungsprüfung nach den Korrekturen ergab keine
|
||||
weiteren Befunde. Die Einzeldatei-Ersetzung bietet bewusst keine globale
|
||||
Mehrdatei-Transaktion oder Sperre gegen fremde Editoren; das entspricht der
|
||||
ausdrücklichen Designentscheidung und ist dokumentiert.
|
||||
|
||||
## Ausgeführte Prüfungen
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| `cargo test --workspace` nach den letzten Codekorrekturen | 501 bestanden, 0 fehlgeschlagen, 2 bereits explizit ignorierte Tests |
|
||||
| Neue IDE-Backend-Tests innerhalb dieses Laufs | 14/14 bestanden |
|
||||
| Neue gemeinsame Loader-Tests innerhalb dieses Laufs | 6/6 bestanden |
|
||||
| Bestehende CLI-Projektintegration innerhalb dieses Laufs | 3/3 bestanden; weitere CLI-, VM-, Frontend-, Runtime- und FRM-Tests ebenfalls grün |
|
||||
| `cargo fmt --all -- --check` | bestanden |
|
||||
| `cargo clippy -p tb-ide -p tb-cli -p tb-vm --all-targets -- -D warnings` | bestanden |
|
||||
| `openspec validate --all --strict --json` | 23/23 gültig: 15 Hauptspecs und 8 Changes |
|
||||
| `git diff --check` und Whitespace-Prüfung neuer Dateien | bestanden |
|
||||
|
||||
Die beiden ignorierten Tests sind die absichtlich manuell auszulösende
|
||||
Golden-File-Erzeugung und der zusätzliche Fremdprogrammtest mit externem
|
||||
`TB_VBDOS_REPO`. Kein Test wurde für diesen Change deaktiviert. OpenSpec
|
||||
meldet bei bestehenden Hauptspecs lediglich INFO-Hinweise zur Textlänge;
|
||||
der aktuelle Change hat keine Validator-Hinweise.
|
||||
|
||||
Tests arbeiten mit absoluten temporären Pfaden und verändern das globale
|
||||
Arbeitsverzeichnis nicht. Der abschließende Repository-Status enthält die
|
||||
Änderungen dieses Changes und keine erzeugten Datenbank-, Druck- oder
|
||||
temporären Speicherdateien.
|
||||
|
||||
## Erneute Prüfung vor Synchronisation und Archivierung
|
||||
|
||||
Am 2026-09-06 wurden Proposal, Design, alle 13 Tasks und alle fünf
|
||||
Anforderungen mit neun Szenarien erneut gegen Lader, Dokumentaktionen,
|
||||
Speicherpfade und deren Tests abgeglichen. Ergebnis: keine offenen Befunde
|
||||
in Vollständigkeit, Korrektheit oder Kohärenz. Die erneut ausgeführten
|
||||
Workspace-Tests bestanden mit 501 erfolgreichen und zwei bereits
|
||||
ignorierten Tests; Formatprüfung und Clippy für tb-ide, tb-cli und tb-vm
|
||||
mit allen Targets und Warnungen als Fehler bestanden ebenfalls.
|
||||
|
||||
Die Synchronisation hat `openspec/specs/ide-projekte/spec.md` mit allen
|
||||
fünf Anforderungen und neun Szenarien angelegt. Der vollständige Vergleich
|
||||
mit der Delta-Spec bestätigt identische Inhalte bei kanonischer
|
||||
Hauptspec-Struktur. Vor der Archivierung bestanden 16/16 Hauptspecs die
|
||||
strikte Validierung. Danach bestanden erneut 23/23 aktive Einträge
|
||||
(16 Hauptspecs, sieben offene Changes). Archivierungsbedingt angepasste
|
||||
Markdown-Verweise auf Übersicht, Folgechanges und Repository-Dateien
|
||||
wurden auf vorhandene Ziele geprüft.
|
||||
@@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
|
||||
|
||||
crates/tb-ide/src/main.rs sowie IDE-interne App-, Fenster- und Dialogmodule; vorhandenes ratatui/crossterm und tb-ui-Rendering. Der BASIC-Tastenpfad bleibt von IDE-Kürzeln getrennt.
|
||||
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../phase-5-01-projekt-und-dokumentmodell/proposal.md).
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md).
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
|
||||
@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
|
||||
|
||||
tb-ide-Editor, tb-frontend Lexer/Parser/Sema und tb-vm Projektcompiler/Linker; bestehende Compile-Budget-Tests und docs/tbvm-design.md. Kein zweiter BASIC-Parser und kein Optimizer.
|
||||
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md).
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md).
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
|
||||
@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
|
||||
|
||||
tb-ide-Sitzung, tb-vm/interp.rs, CLI-run_chain, tb-runtime/host.rs und builtins.rs sowie tb-ui/host.rs und screen.rs. Vorhandene Ereignisse, TextScreen und CLI-Ausführung bleiben die Grundlage.
|
||||
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
|
||||
@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
|
||||
|
||||
tb-ide-Designer, vorhandenes tb-ui::frm::FormFile, FormsModel und tb-frontend-Forms-Metadaten. Keine zweite FRM-Implementierung und keine Programmausführung als Designvorschau.
|
||||
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
|
||||
@@ -25,4 +25,4 @@ tb-vm/interp.rs, Debug-Quellorte und Frontend/Codegen für Ausdrücke und Direkt
|
||||
|
||||
**Abhängigkeiten:** [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md).
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
|
||||
@@ -25,4 +25,4 @@ tb-ide-Hilfe, docs/ und Paketierung von tb; nutzt Fensterverwaltung und Editor-K
|
||||
|
||||
**Abhängigkeiten:** [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
|
||||
@@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
|
||||
|
||||
tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise.
|
||||
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md).
|
||||
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md).
|
||||
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.
|
||||
|
||||
58
openspec/specs/ide-projekte/spec.md
Normal file
58
openspec/specs/ide-projekte/spec.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# ide-projekte Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Verwaltet Projekte, bearbeitbare Quell- und Formulardokumente sowie ihre Dateien konsistent zwischen IDE und Compiler, ohne Änderungen oder ursprüngliche Quellorte zu verlieren.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Gemeinsame Projekt- und Include-Auflösung
|
||||
IDE und CLI SHALL BAS-, FRM- und MAK-Quellen mit derselben relativen, DOS-großschreibungsunabhängigen Pfadauflösung laden. Verschachtelte Includes SHALL relativ zur einschließenden Datei aufgelöst werden; Diagnoseorte SHALL Modul, ursprüngliche Datei und physische Zeile erhalten. Offene Dokumente SHALL beim IDE-Übersetzen Vorrang vor ihrem Plattenstand haben.
|
||||
|
||||
#### Scenario: Ungespeichertes gemeinsames Include
|
||||
- **WHEN** zwei Module dasselbe Include verwenden und dessen geöffneter Text geändert wird
|
||||
- **THEN** erhalten beide Module den bearbeiteten Text mit der Identität der Include-Datei; auf der Platte bleibt der bisherige Text bis zum Speichern bestehen
|
||||
|
||||
#### Scenario: Fehler beim Öffnen
|
||||
- **WHEN** eine Projektdatei fehlt oder ein Include-Zyklus entdeckt wird
|
||||
- **THEN** nennt die Diagnose die beteiligten Pfade und das bisher geöffnete Projekt bleibt vollständig erhalten
|
||||
|
||||
### Requirement: Projektmitglieder und Startdatei
|
||||
Die IDE SHALL New/Open/Save Project, New Module, New Form, Add File und Remove File anbieten. MAK-Mitglieder SHALL geordnet gespeichert werden. Set Start-up File SHALL genau ein vorhandenes BAS- oder FRM-Mitglied auswählbar und dauerhaft erkennbar machen. MAK ohne explizite Auswahl SHALL ihre bisherige Ausführungsreihenfolge und Startformularwahl behalten. Remove File SHALL nur die Mitgliedschaft entfernen und MUST NOT die Datei löschen.
|
||||
|
||||
#### Scenario: Projekt wieder öffnen
|
||||
- **WHEN** Module und Formulare hinzugefügt, eine Startdatei gewählt und das Projekt gespeichert und erneut geöffnet werden
|
||||
- **THEN** bleiben Mitgliedschaft, Reihenfolge und Startdatei erhalten und dieselben Quellen sind auch über die CLI ladbar
|
||||
|
||||
#### Scenario: Startdatei entfernen
|
||||
- **WHEN** die ausgewählte Startdatei entfernt werden soll
|
||||
- **THEN** wird eine Ersatzwahl oder die ausdrückliche Rückkehr zum bisherigen Standard verlangt; Abbrechen verändert nichts
|
||||
|
||||
### Requirement: Gemeinsame Dokumente und Ansichten
|
||||
Mehrere Codefenster und der Formular-Designer SHALL denselben Dokumentstand benutzen. Fensterfokus, Cursor und Scrollposition SHALL ansichtsbezogen sein. Änderungen SHALL dokumentbezogen als ungespeichert erkennbar sein; Include-Ansichten MUST NOT expandierten Quelltext versehentlich als Ursprungsdatei speichern.
|
||||
|
||||
#### Scenario: Zweites Codefenster
|
||||
- **WHEN** ein Dokument in zwei Fenstern offen ist und eines den Text ändert
|
||||
- **THEN** zeigt das andere denselben neuen Text mit eigener Cursorposition; Schließen einer Ansicht verwirft das Dokument nicht
|
||||
|
||||
### Requirement: Verlustfreies Speichern und Wechseln
|
||||
Save File, Save File As, Save Project sowie Projektwechsel und IDE-Ende SHALL Änderungen mit Speichern/Verwerfen/Abbrechen behandeln. Fehlgeschlagenes Speichern MUST den bearbeiteten Stand erhalten und darf die letzte gültige Zieldatei nicht beschädigen. Externe Dateiänderungen und bestehende Save-As-Ziele SHALL vor Überschreiben erkennbar sein und eine ausdrückliche Wahl erfordern. Erst erfolgreich gespeicherte Dokumente SHALL als gespeichert gelten.
|
||||
|
||||
#### Scenario: Schreibfehler im Projekt
|
||||
- **WHEN** ein Mitglied erfolgreich gespeichert wird, ein weiteres aber nicht geschrieben werden kann
|
||||
- **THEN** bleibt das zweite als geändert offen, der Fehler nennt den Pfad und das Projekt wird nicht als vollständig gespeichert oder geschlossen gemeldet
|
||||
|
||||
#### Scenario: Save As in anderes Verzeichnis
|
||||
- **WHEN** ein Projekt unter einem neuen Pfad gespeichert wird
|
||||
- **THEN** werden relative Mitgliederverweise so angepasst, dass dieselben Dateien geladen werden; ein fehlgeschlagener Vorgang lässt den bisherigen Projektpfad aktiv
|
||||
|
||||
### Requirement: Formularimport und Textaustausch
|
||||
Die IDE SHALL bestehende FRM-Leser und die kanonische Textausgabe gemäß forms-dateiformat verwenden. Binäre FRM-Originale MUST beim Import erhalten bleiben; die bearbeitete Textfassung SHALL über ein ausdrücklich gewähltes Ziel gespeichert werden. Load Text und Save Text SHALL Text in das aktive Codedokument einfügen beziehungsweise dessen Auswahl oder gesamten Inhalt exportieren, ohne die Projektmitgliedschaft unbemerkt zu ändern.
|
||||
|
||||
#### Scenario: Binäres Formular bearbeiten
|
||||
- **WHEN** ein binäres Formular geöffnet, geändert und gespeichert wird
|
||||
- **THEN** fordert die IDE einen Text-Zielpfad an; Struktur, Array-Indizes und Code bleiben erhalten und die Binärquelle wird nicht überschrieben
|
||||
|
||||
#### Scenario: Textimport rückgängig machen
|
||||
- **WHEN** Load Text eine Datei am Cursor einfügt und Undo folgt
|
||||
- **THEN** wird der ursprüngliche Dokumenttext wiederhergestellt
|
||||
Reference in New Issue
Block a user