Phase 5: Projekt- und Dokumentmodell implementieren und archivieren
This commit is contained in:
@@ -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