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());
|
||||
}
|
||||
Reference in New Issue
Block a user