diff --git a/crates/foxvm/src/builtins/mod.rs b/crates/foxvm/src/builtins/mod.rs index 9a9b2b3..35ac24d 100644 --- a/crates/foxvm/src/builtins/mod.rs +++ b/crates/foxvm/src/builtins/mod.rs @@ -90,7 +90,8 @@ pub trait BuiltinCtx { fn object_members(&mut self, obj: crate::value::Handle) -> Option>; /// The object whose method is running, for DODEFAULT(). fn running_object(&self) -> Option; - /// What that method is called. + /// What that method is compiled as: `CLASS.EVENT` for a class of a program, `PATH.EVENT` for + /// an object's own, with an ancestor's copy of an overridden method marked `EVENT#n`. fn running_method(&self) -> String; /// COMARRAY(): how an array is passed to that COM object, as it was last set. fn com_setting(&self, obj: u32) -> i64; diff --git a/crates/foxvm/src/builtins/object.rs b/crates/foxvm/src/builtins/object.rs index c44ec13..628e108 100644 --- a/crates/foxvm/src/builtins/object.rs +++ b/crates/foxvm/src/builtins/object.rs @@ -305,12 +305,14 @@ fn f_dodefault(c: &mut dyn BuiltinCtx, a: Vec) -> Result [v] - DoDefault(u8), // ---- exceptions /// Installs a handler; either target may be `NO_TARGET`. On an error the VM unwinds to the diff --git a/crates/foxvm/src/compiler.rs b/crates/foxvm/src/compiler.rs index 6017356..bdd1d41 100644 --- a/crates/foxvm/src/compiler.rs +++ b/crates/foxvm/src/compiler.rs @@ -2209,9 +2209,12 @@ impl ModuleCompiler { StmtKind::NoDefault => { fb.emit(Instr::NoDefault); } + // `DODEFAULT()` on a line of its own runs the parent's code as the function form + // does, and the answer is dropped StmtKind::DoDefault(args) => { - let argc = self.args(fb, args, false, false); - fb.emit(Instr::DoDefault(argc)); + let (id, _) = builtins::lookup("DODEFAULT").expect("DODEFAULT is a builtin"); + let argc = self.args(fb, args, true, false); + fb.emit(Instr::CallBuiltin { id, argc }); fb.emit(Instr::Pop); } StmtKind::Text { target, additive, textmerge, noshow, raw } => { @@ -3693,6 +3696,22 @@ impl ModuleCompiler { continue; } ExprKind::Var(_) => self.warning(a.expr.span, "Argument is passed by value here"), + // `@m.uArg1`: the `m.` says a memory variable, which is what `@` passes, so + // it is the variable by reference - unless a local is really called M + ExprKind::Member { obj, name } + if a.by_ref + && matches!(&obj.kind, ExprKind::Var(base) if base.upper == "M") + && fb.local("M").is_none() => + { + if allow_ref { + let v = self.var_target(fb, name); + fb.emit(Instr::Ref(v)); + continue; + } + // where a plain `@name` is passed by value - a method of an object - so + // is this, and with the same warning + self.warning(a.expr.span, "Argument is passed by value here"); + } _ if a.by_ref => self.error(a.expr.span, "Only a variable can be passed by reference"), _ => {} } @@ -3876,9 +3895,11 @@ impl ModuleCompiler { let n = self.subscripts(fb, args, e.span); fb.emit(Instr::LoadIndex(n)); } + // `@var` reaches a method as the variable itself: an object's method of FoxPro code + // writes to it as a procedure does, which is how CodeMine hands back a second answer ExprKind::MethodCall { obj, name, args } => { self.expr(fb, obj); - let argc = self.args(fb, args, false, false); + let argc = self.args(fb, args, true, false); let m = self.member(name); fb.emit(Instr::CallMethod { name: m, argc }); } @@ -3996,11 +4017,6 @@ impl ModuleCompiler { fb.emit(Instr::CallBuiltin { id, argc }); return; } - if name.upper == "DODEFAULT" { - let argc = self.args(fb, args, false, false); - fb.emit(Instr::DoDefault(argc)); - return; - } let argc = self.args(fb, args, true, false); let n = self.name(&name.upper); fb.emit(Instr::IndexOrCall { name: n, argc }); diff --git a/crates/foxvm/src/host.rs b/crates/foxvm/src/host.rs index d859d5e..cb4d48b 100644 --- a/crates/foxvm/src/host.rs +++ b/crates/foxvm/src/host.rs @@ -306,9 +306,14 @@ pub enum HostRequest { /// came from has no such method. CallParentMethod { obj: u32, - /// The method being run, which is the one to look for above it. + /// The method being run, which is the one to look for above it: its event, with the + /// `#n` of an ancestor's copy when that is what is running. method: String, args: Vec, + /// The whole name the running code was compiled under - `CLASS.EVENT` for a class of a + /// program - which says whose code it is, and so where above it to start looking. + #[serde(default)] + from: String, }, /// `BUILD APP | EXE | DLL | MTDLL | PROJECT`: a project turned into the file that ships, /// or the project itself built out of the files it names. Resume with Null; a build that @@ -861,6 +866,16 @@ pub enum JsonValue { #[serde(rename = "$fn")] function: u32, }, + /// A variable passed by reference to a method - `o.GetClassName(cAlias, @m.cLibrary)`. It + /// travels as a number naming the variable's cell, with the value beside it for a host that + /// only wants to read it; when the host runs the method's code the number comes back, and + /// the parameter is that same cell, so what the method writes the caller sees. + Ref { + #[serde(rename = "$ref")] + cell: u32, + #[serde(rename = "$val")] + value: Box, + }, Date { #[serde(rename = "$date")] date: String, @@ -922,8 +937,19 @@ impl JsonValue { } } + /// A call's argument: a variable passed by reference crosses as a `Ref`, everything else as + /// its value. + pub fn from_arg(v: &Value) -> JsonValue { + match v { + Value::Ref(cell) => JsonValue::Ref { cell: ref_cells::hold(cell), value: Box::new(JsonValue::from_value(v)) }, + other => JsonValue::from_value(other), + } + } + pub fn to_value(&self) -> Value { match self { + // the variable itself, when it is still there; otherwise the value it crossed with + JsonValue::Ref { cell, value } => ref_cells::find(*cell).map_or_else(|| value.to_value(), Value::Ref), JsonValue::Null => Value::Null, JsonValue::Bool(b) => Value::Logical(*b), JsonValue::Num(n) => Value::number(*n), @@ -1080,3 +1106,37 @@ pub struct BrowseRow { pub deleted: bool, pub values: Vec, } + +/// The variables passed by reference that are out in the host, by the number they crossed as. +/// +/// Held weakly: the variable belongs to the routine that declared it, and a number the host keeps +/// after that routine has returned finds nothing, rather than keeping the variable alive. +mod ref_cells { + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::{Rc, Weak}; + + use crate::value::Value; + + thread_local! { + static CELLS: RefCell<(u32, HashMap>>)> = RefCell::new((0, HashMap::new())); + } + + pub fn hold(cell: &Rc>) -> u32 { + CELLS.with(|c| { + let mut c = c.borrow_mut(); + // numbers for variables that have gone are let go of now and then + if c.1.len() > 4096 { + c.1.retain(|_, w| w.strong_count() > 0); + } + c.0 = c.0.wrapping_add(1); + let id = c.0; + c.1.insert(id, Rc::downgrade(cell)); + id + }) + } + + pub fn find(id: u32) -> Option>> { + CELLS.with(|c| c.borrow().1.get(&id).and_then(Weak::upgrade)) + } +} diff --git a/crates/foxvm/src/mock_host.rs b/crates/foxvm/src/mock_host.rs index b8ead0e..964850f 100644 --- a/crates/foxvm/src/mock_host.rs +++ b/crates/foxvm/src/mock_host.rs @@ -1018,7 +1018,7 @@ impl MockHost { HostRequest::GetObject { .. } => Value::Null, HostRequest::RunLine { .. } => Value::Null, HostRequest::NewDocument { .. } => Value::Null, - HostRequest::CallParentMethod { .. } => Value::Logical(false), + HostRequest::CallParentMethod { .. } => Value::Logical(true), // no data source to reach, which is what a connection that cannot be made answers HostRequest::Sql { .. } => Value::number(-1.0), HostRequest::CloseMemo { .. } => Value::Null, diff --git a/crates/foxvm/src/vm.rs b/crates/foxvm/src/vm.rs index 833073c..3f65f18 100644 --- a/crates/foxvm/src/vm.rs +++ b/crates/foxvm/src/vm.rs @@ -278,6 +278,9 @@ pub struct Vm { /// file is released its routines are not found, while a program that ran with `DO` keeps /// its routines findable after it returns. These are the ones that go when released. procedure_only: HashSet, + /// The last error raised anywhere in the session, for ERROR() and MESSAGE() asked from a + /// fiber that has not raised one of its own - a class's Error method, run by the host. + last_error: Option, /// Objects whose `Error` method is running, so an error inside it is not handed back to it. in_class_error: HashSet, /// The work area the last USE opened, so the index beside it can be read next. @@ -1565,6 +1568,7 @@ impl Vm { library_funcs: HashMap::new(), procedure_files: Vec::new(), procedure_only: HashSet::new(), + last_error: None, last_opened: None, index_build: None, idx_open: None, @@ -2495,6 +2499,7 @@ impl Vm { /// Gives the error to the nearest handler that wants it. fn handle(&mut self, host: &mut dyn Host, fb: &mut Fiber, err: RtError, floor: usize) -> Option { fb.last_error = Some(err.clone()); + self.last_error = Some(err.clone()); // An error raised while a HAVING predicate was running leaves the query saying that no // record is current. A CATCH that swallows it must not leave every later field read // complaining, so the clause is no longer running the moment the error is handed on. @@ -2515,6 +2520,33 @@ impl Vm { fb.query_unwind = fb.query.take(); } } + // Measured in Visual FoxPro 9, an error raised inside an object's Error method goes one of + // two ways, and a TRY inside the method is nearer than either: + // - the program called the method (or DODEFAULT() reached an ancestor's from there): the + // error goes neither to Error again nor to ON ERROR, and the method carries on at its + // next statement; + // - the runtime called it, because a method of the object raised an error: the error is + // not skipped and goes to the default handler, the dialog, without ON ERROR being asked. + // `in_class_error` holds the object while the runtime is running its Error method. + let mut in_runtime_error_method = false; + if let Some((ix, obj)) = self.running_error_method(fb, floor) + && !fb.handlers.last().is_some_and(|h| h.frame >= ix) + { + if self.in_class_error.contains(&obj) { + in_runtime_error_method = true; + } else { + let at = fb.frames.len() - 1; + let (resume_pc, stmt_sp) = { + let fr = &fb.frames[at]; + let code = &self.proto(fr.module, fr.func).code; + let next = (fr.pc..code.len()).find(|&i| matches!(code[i], Instr::Stmt(_))).unwrap_or(code.len().saturating_sub(2)); + (next, fr.stmt_sp) + }; + fb.stack.truncate(stmt_sp); + fb.frames[at].pc = resume_pc; + return None; + } + } if let Some(step) = self.class_error_method(host, fb, &err, floor) { return Some(step); } @@ -2550,6 +2582,7 @@ impl Vm { } if floor == 0 && !fb.in_error_handler + && !in_runtime_error_method && !fb.frames.is_empty() && let Some(text) = self.on_error.clone() { @@ -2558,6 +2591,22 @@ impl Vm { Some(Step::Error(err)) } + /// The innermost frame, at or above `floor`, that is running some object's Error method - + /// its own, or an ancestor's copy `ERROR#n` - when the error was raised in that very frame, + /// with the object whose method it is. + fn running_error_method(&self, fb: &Fiber, floor: usize) -> Option<(usize, u32)> { + let at = fb.frames.len().checked_sub(1)?; + if at < floor { + return None; + } + let fr = &fb.frames[at]; + let this = fr.this?; + let name = &self.proto(fr.module, fr.func).display_name; + let event = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase(); + let base = event.split('#').next().unwrap_or(&event); + (base == "ERROR").then_some((at, this.0)) + } + /// VFP hands an error raised inside a method to the object's own `Error` method, unless a /// TRY inside that same method catches it first. The method runs through the host like any /// other, and the failing method then carries on at its next statement. @@ -3843,7 +3892,7 @@ impl Vm { return Ok(Flow::Suspend(HostRequest::CallMethod { obj: h.0, name: name.clone(), - args: args.iter().map(JsonValue::from_value).collect(), + args: args.iter().map(JsonValue::from_arg).collect(), })); } Instr::PushWith => { @@ -4066,10 +4115,6 @@ impl Vm { fb.frames.last_mut().expect("frame").nodefault = true; fb.nodefault = true; } - Instr::DoDefault(argc) => { - pop_n(fb, *argc as usize)?; - fb.stack.push(Value::Logical(true)); - } Instr::TryPush { catch, finally } => { let frame = fb.frames.len() - 1; let env = env_index(fb, frame); @@ -10085,11 +10130,11 @@ impl BuiltinCtx for Ctx<'_> { } fn running_method(&self) -> String { - // a method is compiled under "OBJECT.EVENT"; DODEFAULT() is about the event + // the whole of what it was compiled as: DODEFAULT() needs the event, and which class's + // code is running, which is what says where to look above it let env = env_index(self.fiber, self.fiber.frames.len() - 1); let f = &self.fiber.frames[env]; - let name = self.vm.proto(f.module, f.func).display_name.clone(); - name.rsplit('.').next().unwrap_or(&name).to_string() + self.vm.proto(f.module, f.func).display_name.clone() } fn com_setting(&self, obj: u32) -> i64 { @@ -10174,7 +10219,9 @@ impl BuiltinCtx for Ctx<'_> { self.vm.load_name(self.fiber, upper_name) } fn last_error(&self) -> Option { - self.fiber.last_error.clone() + // ERROR() and MESSAGE() are the session's last error, not this fiber's: a class's Error + // method is run on a fiber of its own, and asks them about the error that sent it there + self.fiber.last_error.clone().or_else(|| self.vm.last_error.clone()) } fn on_error_command(&self) -> Option { self.vm.on_error.clone() diff --git a/crates/foxvm/src/wasm.rs b/crates/foxvm/src/wasm.rs index bad7598..c9e6d46 100644 --- a/crates/foxvm/src/wasm.rs +++ b/crates/foxvm/src/wasm.rs @@ -443,7 +443,8 @@ impl Host for JsHost { fn call_library(&mut self, library: u32, function: u32, args: &[Value]) -> Result { let missing = || RtError::new(RtError::API_LIBRARY_NOT_FOUND, "API library is not found."); let f = optional_method(&self.reads, "callLibrary").ok_or_else(missing)?; - let list: Vec = args.iter().map(JsonValue::from_value).collect(); + // `@cValue` crosses as the variable, for a library that declared the parameter "R" + let list: Vec = args.iter().map(JsonValue::from_arg).collect(); let answer = f .apply( self.reads.as_ref(), @@ -460,6 +461,16 @@ impl Host for JsHost { m => return Err(RtError::new(code, m)), } } + // what the library stored through _Store goes into the variables it was handed + if let Ok(refs) = field("refs").dyn_into::() { + for r in refs.iter() { + let index = js_sys::Reflect::get(&r, &"index".into()).ok().and_then(|i| i.as_f64()); + let value = js_sys::Reflect::get(&r, &"value".into()).unwrap_or(JsValue::UNDEFINED); + if let Some(Value::Ref(cell)) = index.and_then(|i| args.get(i as usize)) { + *cell.borrow_mut() = from_js(value); + } + } + } Ok(from_js(field("value"))) } diff --git a/crates/foxvm/tests/programs/byref_mdot.expected b/crates/foxvm/tests/programs/byref_mdot.expected new file mode 100644 index 0000000..c9dbb54 --- /dev/null +++ b/crates/foxvm/tests/programs/byref_mdot.expected @@ -0,0 +1,2 @@ + 42 after + 20 2 diff --git a/crates/foxvm/tests/programs/byref_mdot.prg b/crates/foxvm/tests/programs/byref_mdot.prg new file mode 100644 index 0000000..577df8d --- /dev/null +++ b/crates/foxvm/tests/programs/byref_mdot.prg @@ -0,0 +1,23 @@ +* COVERS: @ +* +* `@m.name` passes the memory variable by reference, as `@name` does: CodeMine's own support +* library writes every by-reference argument that way. Measured in Visual FoxPro 9. +LOCAL lnValue, lcText +lnValue = 1 +lcText = "before" += setboth(@m.lnValue, @m.lcText) +? lnValue, lcText + +* and in a call written as a statement's value, through several of them at once +lnValue = 1 +? countup(@m.lnValue), lnValue + +FUNCTION setboth(tnValue, tcText) + tnValue = 42 + tcText = "after" +ENDFUNC + +FUNCTION countup(tnValue) + tnValue = tnValue + 1 + RETURN tnValue * 10 +ENDFUNC diff --git a/docs/language-coverage.md b/docs/language-coverage.md index 2934ed2..a329f44 100644 --- a/docs/language-coverage.md +++ b/docs/language-coverage.md @@ -30,7 +30,7 @@ Every element of the Visual FoxPro language, and what happens when a program use | directive | 7 | 0 | 0 | 0 | 0 | 7 | | systemvar | 49 | 0 | 0 | 0 | 0 | 49 | -1534 element(s) are exercised by a test. +1535 element(s) are exercised by a test. ## command diff --git a/native/fllhost/fllhost.c b/native/fllhost/fllhost.c index ee5892c..3835a3b 100644 --- a/native/fllhost/fllhost.c +++ b/native/fllhost/fllhost.c @@ -179,6 +179,8 @@ typedef struct { #define API_PUTSTR 2 #define API_RETDATESTR 10 #define API_RETVAL 11 +#define API_STORE 29 /* _Store(Locator *, Value *): write a variable passed by reference */ +#define API_LOAD 30 /* _Load(Locator *, Value *): read one */ #define API_PUTVALUE 64 #define API_ERROR 83 #define API_USERERROR 84 @@ -226,6 +228,27 @@ static int g_errNo; static char g_errText[512]; static char g_missing[256]; +/* + * The variables a call was handed by reference, by parameter position. A library is given a + * Locator for each - `cmRegGetValue(nRoot, cKey, @cValue)` is declared "I,C,R" - and reads and + * writes the variable through _Load and _Store. What it stored goes back with the answer, and + * the runtime writes it into the variable. A character value is kept as its bytes, because the + * handle a library stores from is its own to free the moment _Store returns. + */ +static Value g_refs[MAX_PARMS]; +static char *g_refText[MAX_PARMS]; +static unsigned g_refTextLen[MAX_PARMS]; +static int g_refIs[MAX_PARMS], g_refStored[MAX_PARMS]; + +static void ref_keep_text(unsigned i, const char *p, unsigned len) { + char *grown = (char *)realloc(g_refText[i], len + 1); + if (!grown) return; + g_refText[i] = grown; + if (p && len) memcpy(grown, p, len); + grown[len] = '\0'; + g_refTextLen[i] = len; +} + static void say(const char *bytes, size_t len) { if (g_outLen + len + 1 > g_outCap) { size_t want = (g_outLen + len + 1) * 2; @@ -328,6 +351,36 @@ static void FASTCALL host_api(ApiCall *c) { c->a = 0; break; } + case API_STORE: + case API_LOAD: { + Locator *loc = (Locator *)c->a; + Value *v = (Value *)c->b; + unsigned i = loc ? (unsigned)loc->l_NTI - 1 : MAX_PARMS; + if (!v || i >= MAX_PARMS || !g_refIs[i] || !g_current) { + c->a = (unsigned)-1; + break; + } + if (c->code == API_STORE) { + g_refs[i] = *v; + if (v->ev_type == 'C' || v->ev_type == 'H') { + g_refs[i].ev_type = 'C'; + ref_keep_text(i, (const char *)deref_handle(g_current, v->ev_handle), v->ev_length); + } + g_refStored[i] = 1; + } else { + *v = g_refs[i]; + /* a character value comes with a handle of its own, which the library frees */ + if (v->ev_type == 'C') { + unsigned handle = 0; + void *p = alloc_handle(g_current, g_refTextLen[i] + 1, &handle); + if (p) memcpy(p, g_refText[i] ? g_refText[i] : "", g_refTextLen[i] + 1); + v->ev_handle = handle; + v->ev_length = g_refTextLen[i]; + } + } + c->a = 0; + break; + } case API_ERROR: { /* The library's own stub longjmps out of the function as soon as this returns, so the number is all there is to keep. */ @@ -803,7 +856,15 @@ static void coerce(Value *v, char want) { switch (want) { case 'I': if (v->ev_type == 'N') { - v->ev_long = (long)v->ev_real; + /* A number past the top of a signed long wraps round, as it does in Visual FoxPro: a + registry root is written 2147483650 for HKEY_LOCAL_MACHINE (0x80000002), and + CodeMine's cmRegGetValue reads HKLM with it (measured). A plain cast of a double that + large is undefined, and this compiler makes it 0x80000000 - HKEY_CLASSES_ROOT. */ + if (v->ev_real >= 2147483648.0 && v->ev_real < 4294967296.0) { + v->ev_long = (long)(unsigned long)v->ev_real; + } else { + v->ev_long = (long)v->ev_real; + } } else if (v->ev_type == 'L') { v->ev_long = v->ev_length ? 1 : 0; } else if (v->ev_type != 'I') { @@ -998,6 +1059,26 @@ static void do_call(void) { memset(&r->pCount, 0, sizeof(short) + sizeof(FoxParameter) * MAX_PARMS); r->pCount = (short)argc; for (i = 0; i < argc; i++) { + g_refIs[i] = g_refStored[i] = 0; + /* 'R' is a variable passed by reference: its value follows, and the library gets a Locator */ + if (g_inPos < g_inLen && g_in[g_inPos] == 'R') { + g_inPos++; + if (!read_parameter(lib, &g_refs[i], error)) { + fail(error); + return; + } + if (g_refs[i].ev_type == 'C') { + ref_keep_text(i, (const char *)deref_handle(lib, g_refs[i].ev_handle), g_refs[i].ev_length); + free_handle(lib, g_refs[i].ev_handle); + g_refs[i].ev_handle = 0; + } + g_refIs[i] = 1; + memset(&r->p[i], 0, sizeof(FoxParameter)); + r->p[i].loc.l_type = 'R'; + r->p[i].loc.l_where = -1; + r->p[i].loc.l_NTI = (USHORT)(i + 1); + continue; + } if (!read_parameter(lib, &r->p[i].val, error)) { for (; i > 0; i--) if (r->p[i - 1].val.ev_type == 'C') free_handle(lib, r->p[i - 1].val.ev_handle); @@ -1005,10 +1086,13 @@ static void do_call(void) { return; } coerce(&r->p[i].val, wanted_type(lib->funcs[index]->parmTypes, i)); + /* a value where the function declared a reference is refused before it runs: the library + would read a Locator out of it. Measured: error 9, "Data type mismatch". */ + if (wanted_type(lib->funcs[index]->parmTypes, i) == 'R') g_errNo = 9; } g_current = lib; - if (!perform(lib, lib->funcs[index], error)) { + if (g_errNo == 0 && !perform(lib, lib->funcs[index], error)) { g_current = NULL; fail(error); return; @@ -1017,7 +1101,7 @@ static void do_call(void) { /* what the library was given is the host's to let go of; what it handed back was taken while the library still had it */ for (i = 0; i < argc; i++) - if (r->p[i].val.ev_type == 'C') free_handle(lib, r->p[i].val.ev_handle); + if (!g_refIs[i] && r->p[i].val.ev_type == 'C') free_handle(lib, r->p[i].val.ev_handle); put_u8(0); if (g_hasRet) { @@ -1030,6 +1114,22 @@ static void do_call(void) { put_i32(g_errNo); put_text(g_errText); put_text(g_missing); + /* what the library stored in the variables it was handed by reference */ + { + unsigned stored = 0; + for (i = 0; i < argc; i++) stored += g_refIs[i] && g_refStored[i]; + put_u16(stored); + for (i = 0; i < argc; i++) { + if (!(g_refIs[i] && g_refStored[i])) continue; + put_u16(i); + if (g_refs[i].ev_type == 'C') { + put_u8('C'); + put_bytes32(g_refText[i] ? g_refText[i] : "", g_refTextLen[i]); + } else { + write_value(lib, &g_refs[i]); + } + } + } } static void do_unload(void) { diff --git a/scripts/build-fllhost.mjs b/scripts/build-fllhost.mjs index de98ee0..3319014 100644 --- a/scripts/build-fllhost.mjs +++ b/scripts/build-fllhost.mjs @@ -118,9 +118,11 @@ const samples = join( if (!existsSync(join(samples, 'pro_ext.h'))) { console.log('[build-fllhost] no Visual FoxPro API samples here: no sample libraries built'); } else { + // and one of our own, for what neither sample does: a variable passed by reference for (const [source, name] of [ - ['hello.c', 'hello.fll'], - ['reverse.c', 'reverse.fll'], + [join(samples, 'hello.c'), 'hello.fll'], + [join(samples, 'reverse.c'), 'reverse.fll'], + [join(root, 'tests', 'fll', 'refparm.c'), 'refparm.fll'], ]) { cl( [ @@ -139,7 +141,7 @@ if (!existsSync(join(samples, 'pro_ext.h'))) { '/D', 'NDEBUG', `/I${samples}`, - join(samples, source), + source, `/Fe${join(sampleDir, name)}`, '/link', `/LIBPATH:${samples}`, diff --git a/src/renderer/src/runtime/classLibraries.ts b/src/renderer/src/runtime/classLibraries.ts index ebe52b2..2e276b0 100644 --- a/src/renderer/src/runtime/classLibraries.ts +++ b/src/renderer/src/runtime/classLibraries.ts @@ -45,6 +45,8 @@ export class ClassLibraries { constructor( private readonly readTable: (path: string) => Promise, private readonly search: readonly string[] = [], + /** Told when a library a class stands on cannot be found, so an object is not silently short of its parent's code. */ + private readonly onMissing: (library: string, from: string) => void = () => {}, ) {} /** @@ -92,7 +94,12 @@ export class ClassLibraries { const table = await this.readTable(path); // a class in the file may stand on one in another file, and an object of it needs the whole // lineage; the importer follows those references itself, given somewhere to look - const { libraries } = await loadClassLibraries(table, dirname(path), this.readTable, [dirname(path), ...this.search]); + const { libraries, missing } = await loadClassLibraries(table, dirname(path), this.readTable, [dirname(path), ...this.search]); + // a class standing on another in the same file names its own library, which is not missing + const own = basename(path).toLowerCase(); + for (const library of missing) { + if (basename(library.replace(/\\/g, '/')).toLowerCase() !== own) this.onMissing(library, path); + } const doc = importClassLibraryDocument(table, name, libraries).doc; this.read.set(path.toLowerCase(), doc); return { path, alias: alias || name, doc }; diff --git a/src/renderer/src/runtime/session.ts b/src/renderer/src/runtime/session.ts index 66603dc..16be494 100644 --- a/src/renderer/src/runtime/session.ts +++ b/src/renderer/src/runtime/session.ts @@ -20,7 +20,7 @@ import { baseName, CompileFailure, formHeaderRefs, formMethodSources, requireByt import type { MenuDocument, MenuItem } from '@shared/menu/schema'; import type { FormCursor, FormDocument, FormNode, FormRelation } from '@shared/form/schema'; import { formsetDocumentNames } from '@shared/form/formset'; -import { displayValue, type VmValue } from '@shared/runtime/values'; +import { argValue, displayValue, type VmValue } from '@shared/runtime/values'; import { buildClassInstance } from '@shared/runtime/classInstance'; import { isNonVisualBaseClass, resolveInheritance, type VfpClassDef } from '@shared/runtime/classDef'; import { classAsNode } from '@shared/classlib/schema'; @@ -418,9 +418,33 @@ export const useSessionStore = create((set, get) => { * the class's own name, and every object of it runs that module. The key holds the file as * well as the class, because two libraries may each have a class of the same name. */ + /** The folders the project's class libraries are in, each once, in the order the project lists them. */ + function projectClassFolders(): string[] { + const project = useProjectStore.getState(); + const folders: string[] = []; + for (const item of project.doc?.items ?? []) { + if (item.kind !== 'class') continue; + const folder = dirname(project.resolvePath(item.path)); + if (!folders.some((f) => f.toLowerCase() === folder.toLowerCase())) folders.push(folder); + } + return folders; + } + const classLibraries = new ClassLibraries(async (given) => { const path = await reach(at(given)); if (await getApi().files.exists(path).catch(() => false)) return readVfpTable(path); + // A class library of the project is found by name wherever in the project it sits, as it + // is once the project is built and every file is inside the application: `SET CLASSLIB TO + // AppMain` from a program in `source\` names `source\appmain.vcx`. The project lists the + // library as what the import made of it; the .vcx it was made from is beside that. + const stemOf = (p: string) => basename(p).replace(/\.[^.]*$/, '').toLowerCase(); + const wanted = stemOf(given); + const project = useProjectStore.getState(); + const item = project.doc?.items.find((i) => i.kind === 'class' && stemOf(i.path) === wanted); + if (item) { + const vcx = project.resolvePath(item.path).replace(/\.[^./\\]+$/, '.vcx'); + if (await getApi().files.exists(vcx).catch(() => false)) return readVfpTable(await reach(vcx)); + } // A Foundation Class is named by file alone - `SET CLASSLIB TO _base` - because Visual // FoxPro finds those on its own search path. Copies of them ship with FoxDev for exactly // that reason, and the folder holding them is the last place looked, as it is when a @@ -430,7 +454,14 @@ export const useSessionStore = create((set, get) => { // the product reports a class library it cannot find as a missing file, not as a class // that does not exist - measured, error 1 with the name it was given throw new HostError(1, `File '${given}' does not exist.`); - }, ffcDir === '' ? [] : [ffcDir]); + }, + // A class names the library it stands on by a path from wherever the developer's copy sat, + // and that seldom leads anywhere now: `..\..\common50\cmapp.vcx` from `source\`. The + // project's own class libraries say where those files really are, as they did when the + // project was imported, so their folders are looked in before the Foundation Classes. + [...projectClassFolders(), ...(ffcDir === '' ? [] : [ffcDir])], + (library, from) => print({ kind: 'error', text: `${basename(from)}: class library "${library}" was not found; its classes are missing what they inherit from it` }), + ); const classModules = new Map(); const scheduler = new Scheduler(vm, { perform: (request, ctx) => perform(request, ctx) }, { @@ -459,6 +490,25 @@ export const useSessionStore = create((set, get) => { }); useDebugStore.getState().attach(vm, scheduler); + /** + * A method of an object of a program's class: the class's own code, or the nearest class + * above it that has some. Each class's methods are compiled under the class's own name, so + * one that a class inherits without overriding is found under its parent's - measured, an + * object of `cc AS cb` runs cb's Greet, and cb's object runs ca's Who. + */ + function dispatchUpClasses(module: number, className: string, obj: RuntimeObject, event: string, args: VmValue[]) { + const classes = definedClasses(module); + const seen = new Set(); + let name = className; + while (name !== '' && !seen.has(name.toLowerCase())) { + seen.add(name.toLowerCase()); + const outcome = scheduler.dispatchClass(module, name, obj.path(), event, obj.handle, args); + if (outcome !== null) return outcome; + name = classes.find((c) => c.name.toLowerCase() === name.toLowerCase())?.baseClass ?? ''; + } + return null; + } + desktop.dispatch = (obj, event, args) => { // a control's code lives in its form's module; a formset's own lives in the formset's, // because each form of a formset is a document, and so is compiled, of its own @@ -471,7 +521,7 @@ export const useSessionStore = create((set, get) => { // of a library - keys them by the path from the document's own root. Which of the two a // module is, is a question about the module, not about whether the object has a class. const own = owner.className && definedClasses(owner.module).length > 0 - ? scheduler.dispatchClass(owner.module, owner.className, obj.path(), event, obj.handle, args ?? []) + ? dispatchUpClasses(owner.module, owner.className, obj, event, args ?? []) : scheduler.dispatch(owner.module, obj.path(), event, obj.handle, args ?? []); const bound = desktop.boundHandlers(obj.handle, event); @@ -817,23 +867,43 @@ export const useSessionStore = create((set, get) => { case 'CallMethod': { const target = desktop.object(request.obj); - if (target?.hasClassMethod(request.name)) { + // FoxPro code of the object's own - a program's class, or a class out of a library - + // is given the arguments as they came, so a variable passed with @ is the variable + if (target && (target.hasClassMethod(request.name) || target.hasOwnMethod(request.name))) { const outcome = desktop.dispatch(target, request.name, request.args); if (outcome === null) return null; return outcome instanceof Promise ? outcome.then((o) => o.value) : outcome.value; } - const result = desktop.callMethod(request.obj, request.name, request.args); + // everything else is answered here, and wants values + const result = desktop.callMethod(request.obj, request.name, request.args.map(argValue)); if (result === undefined) throw new HostError(1925, `Unknown member ${request.name.toUpperCase()}.`); return result; } - // DODEFAULT(): the method of the same name on the class this one was built from. The - // classes of a module are each compiled under their own name, so what this has to find - // is the first class above this one that has the method at all. + // DODEFAULT(): the next class up that has code for the method being run, called with the + // same THIS. Measured in Visual FoxPro 9: the arguments go with it, its answer is the + // answer, a class with no code for the method is passed over, and past the last one the + // answer is .T. Where to start looking is the class whose code is running, not the + // object's: an inherited method that calls up must not find itself again. case 'CallParentMethod': { const target = desktop.object(request.obj); const form = target?.form(); - if (!target || !form || form.module < 0 || !form.className) return false; + if (!target || !form || form.module < 0) return true; + const settle = (outcome: ReturnType) => + outcome instanceof Promise ? outcome.then((o) => o.value) : outcome!.value; + + // An object of a class library carries its ancestors' code in its own module: the + // import kept an overridden method's earlier versions as `Init#1`, `Init#2`, nearest + // first. The next one up from the version running is the parent's. + const running = /^(.*?)(?:#(\d+))?$/.exec(request.method)!; + const event = running[1]!; + const depth = Number(running[2] ?? 0); + const copy = scheduler.dispatch(form.module, target.path(), `${event}#${depth + 1}`, target.handle, request.args); + if (copy !== null) return settle(copy); + + // A class of a program: each class's methods are compiled under the class's own name, + // so the chain is walked from the class that wrote the running code. + if (!form.className) return true; const classes = classesFor(form.module, { name: form.className, baseClass: '', @@ -842,27 +912,19 @@ export const useSessionStore = create((set, get) => { methods: [], module: form.module, }); - let name = form.className.toLowerCase(); + const writer = (request.from ?? '').split('.')[0]!.toLowerCase(); + let name = classes.some((c) => c.name.toLowerCase() === writer) ? writer : form.className.toLowerCase(); const seen = new Set(); while (name !== '' && !seen.has(name)) { seen.add(name); const above = classes.find((c) => c.name.toLowerCase() === name)?.baseClass ?? ''; const parent = classes.find((c) => c.name.toLowerCase() === above.toLowerCase()); - if (!parent) return false; + if (!parent) return true; name = parent.name.toLowerCase(); - const outcome = scheduler.dispatchClass( - form.module, - parent.name, - target.path(), - request.method, - target.handle, - request.args, - ); - if (outcome !== null) { - return outcome instanceof Promise ? outcome.then((o) => o.value) : outcome.value; - } + const outcome = scheduler.dispatchClass(form.module, parent.name, target.path(), event, target.handle, request.args); + if (outcome !== null) return settle(outcome); } - return false; + return true; } case 'AddProperty': @@ -1076,7 +1138,9 @@ export const useSessionStore = create((set, get) => { if (!built.nonVisual) showDesktop(); const instance = desktop.instantiate(built.form, definition.module ?? -1, { - className: definition.name, + // measured: a program's class answers for its Class with the first letter capital + // and the rest small, however DEFINE CLASS wrote it - `mYthingHere` is Mythinghere + className: definition.name.charAt(0).toUpperCase() + definition.name.slice(1).toLowerCase(), nonVisual: built.nonVisual, args: request.args, }); diff --git a/src/shared/runtime/host.ts b/src/shared/runtime/host.ts index bc548ae..73cfb00 100644 --- a/src/shared/runtime/host.ts +++ b/src/shared/runtime/host.ts @@ -57,7 +57,11 @@ export interface HostReads { * sharper reason: a program calls one from the middle of an expression the VM is already * evaluating, where there is nothing to suspend. */ - callLibrary?(library: number, fn: number, args: VmValue[]): { ok: true; value: VmValue } | { ok: false; code: number; message: string }; + callLibrary?( + library: number, + fn: number, + args: VmValue[], + ): { ok: true; value: VmValue; refs?: { index: number; value: VmValue }[] } | { ok: false; code: number; message: string }; unloadLibrary?(library: number): void; } @@ -141,7 +145,7 @@ export type HostRequest = | { kind: 'NewDocument'; what: string; path: string } | { kind: 'Build'; what: string; target: string; from: string[]; recompile: boolean } | { kind: 'Compile'; what: string; files: string; all: boolean; encrypt: boolean; nodebug: boolean } - | { kind: 'CallParentMethod'; obj: number; method: string; args: VmValue[] } + | { kind: 'CallParentMethod'; obj: number; method: string; args: VmValue[]; from?: string } | { kind: 'Sql'; what: number; handle: number; text: string; extra: string } | { kind: 'Environment'; name: string } | { kind: 'EditMemo'; alias: string; field: string; text: string; noedit: boolean; nowait: boolean } diff --git a/src/shared/runtime/libraryHost.ts b/src/shared/runtime/libraryHost.ts index 6875450..b2b475c 100644 --- a/src/shared/runtime/libraryHost.ts +++ b/src/shared/runtime/libraryHost.ts @@ -38,7 +38,9 @@ export type LibraryValue = | { kind: 'number'; num: number } | { kind: 'logical'; flag: boolean } | { kind: 'date'; text: string } - | { kind: 'datetime'; text: string }; + | { kind: 'datetime'; text: string } + /** A variable passed with @: the library reads and writes it through _Load and _Store. */ + | { kind: 'ref'; value: LibraryValue }; export interface LibraryLoad { /** How this library is named in later calls. */ @@ -60,6 +62,8 @@ export interface LibraryCall { errorText: string; /** API functions it asked for that this host does not have, as their numbers. */ missing: string; + /** What it stored in the variables it was handed by reference, by argument position. */ + refs: { index: number; value: LibraryValue }[]; } export interface LibraryHost { @@ -174,6 +178,9 @@ class Reply { u8(): number { return this.view.getUint8(this.at++); } + remaining(): number { + return this.bytes.byteLength - this.at; + } u16(): number { const v = this.view.getUint16(this.at, true); this.at += 2; @@ -358,7 +365,10 @@ export function createLibraryHost(dirs: string[]): LibraryHost { call(library, fn, args) { const frame = new Frame().u8(2).u16(library).u16(fn).u16(args.length); - for (const a of args) { + for (const arg of args) { + // a variable passed by reference is its value with an 'R' in front + if (arg.kind === 'ref') frame.u8(0x52); + const a = arg.kind === 'ref' ? arg.value : arg; switch (a.kind) { case 'string': frame.u8(0x43).bytes32(a.text); @@ -375,13 +385,20 @@ export function createLibraryHost(dirs: string[]): LibraryHost { } } const reply = ok(exchange(frame.done())); - return { + const answer: LibraryCall = { value: reply.value(), output: reply.bytes32(), error: reply.i32(), errorText: reply.text(), missing: reply.text(), + refs: [], }; + // a host built before references were carried ends its reply here + if (reply.remaining() >= 2) { + const count = reply.u16(); + for (let i = 0; i < count; i++) answer.refs.push({ index: reply.u16(), value: reply.value() }); + } + return answer; }, unload(library) { diff --git a/src/shared/runtime/objectModel.ts b/src/shared/runtime/objectModel.ts index 92be2f6..17e851b 100644 --- a/src/shared/runtime/objectModel.ts +++ b/src/shared/runtime/objectModel.ts @@ -33,6 +33,8 @@ import type { LibraryHost, LibraryValue } from './libraryHost'; * empty parameter. */ function toLibraryValue(v: VmValue): LibraryValue { + // `@cValue`: the library is handed the variable, and what it stores comes back to it + if (typeof v === 'object' && v !== null && '$ref' in v) return { kind: 'ref', value: toLibraryValue(v.$val) }; if (typeof v === 'string') return { kind: 'string', text: v }; if (typeof v === 'number') return { kind: 'number', num: v }; if (typeof v === 'boolean') return { kind: 'logical', flag: v }; @@ -1993,7 +1995,7 @@ export class Desktop implements HostReads { library: number, fn: number, args: VmValue[], - ): { ok: true; value: VmValue } | { ok: false; code: number; message: string } { + ): { ok: true; value: VmValue; refs: { index: number; value: VmValue }[] } | { ok: false; code: number; message: string } { if (!this.libraries) return { ok: false, code: 1726, message: 'API library is not found.' }; let answer; try { @@ -2005,7 +2007,11 @@ export class Desktop implements HostReads { // _Error(n) raises the product's error n; _UserError(text) is 1098, in the library's words if (answer.error > 0) return { ok: false, code: answer.error, message: '' }; if (answer.error < 0) return { ok: false, code: 1098, message: answer.errorText }; - return { ok: true, value: fromLibraryValue(answer.value) }; + return { + ok: true, + value: fromLibraryValue(answer.value), + refs: (answer.refs ?? []).map((r) => ({ index: r.index, value: fromLibraryValue(r.value) })), + }; } unloadLibrary(library: number): void { @@ -2676,7 +2682,10 @@ export class Desktop implements HostReads { const type = baseClassToControlType(info.baseClass); const nonVisual = isNonVisualBaseClass(info.baseClass) || type === null; - if (type === 'Form' || nonVisual) { + // A non-visual class standing on its own is an object of its own; one added to a container - + // `THIS.NewObject('oContextMenu', 'cmContextMenuManager')`, a Custom - is a member like any + // control, when its base class is one a form can hold + if (type === 'Form' || (nonVisual && !(into && type !== null))) { // a form is a window: it is not a member of anything, which is what the product says too if (into) throw new HostError(1733, `Class definition ${info.className.toUpperCase()} is not found.`); const instance = this.instantiate(node, module, { className: spellClass(info.className), nonVisual, noshow: true, arrays: info.arrays }); diff --git a/src/shared/runtime/programSource.ts b/src/shared/runtime/programSource.ts index 038b033..6b0de0f 100644 --- a/src/shared/runtime/programSource.ts +++ b/src/shared/runtime/programSource.ts @@ -128,7 +128,9 @@ export function formMethodSources(doc: FormDocument): MethodSourceInput[] { const events = getObjectDescriptor(node).events; for (const [event, source] of Object.entries(node.methods)) { if (!source.trim()) continue; - const params = events.find((e) => e.name.toLowerCase() === event.toLowerCase())?.params ?? ''; + // `Init#1` is an ancestor's copy of Init, which DODEFAULT() reaches, and takes Init's parameters + const copied = event.replace(/#\d+$/, '').toLowerCase(); + const params = events.find((e) => e.name.toLowerCase() === copied)?.params ?? ''; const key = (objectPath === '' ? event : `${objectPath}.${event}`).toLowerCase(); const include = vfp?.includes?.[key] ?? vfp?.include ?? ''; out.push({ objectPath, event, params, source, include: headerStem(include) }); diff --git a/src/shared/runtime/values.ts b/src/shared/runtime/values.ts index 98818a8..2cc67a1 100644 --- a/src/shared/runtime/values.ts +++ b/src/shared/runtime/values.ts @@ -3,7 +3,22 @@ * primitives travel as themselves, objects as handles, dates as ISO text, arrays by value. */ -export type VmValue = null | boolean | number | string | VmObjectRef | VmFuncRef | VmDate | VmDateTime | VmArray; +export type VmValue = null | boolean | number | string | VmObjectRef | VmFuncRef | VmDate | VmDateTime | VmArray | VmVarRef; + +/** + * A variable passed by reference to a method: the cell it lives in, and its value. Code of the + * program's that the host runs is given the cell back, so it writes the caller's variable; + * anything else only reads `$val`. + */ +export interface VmVarRef { + $ref: number; + $val: VmValue; +} + +/** An argument as a method the host answers itself wants it: the value, not the variable. */ +export function argValue(v: VmValue): VmValue { + return typeof v === 'object' && v !== null && '$ref' in v ? v.$val : v; +} export interface VmObjectRef { $obj: number; diff --git a/src/shared/vfp/importForm.ts b/src/shared/vfp/importForm.ts index 9e14a59..5d50e5f 100644 --- a/src/shared/vfp/importForm.ts +++ b/src/shared/vfp/importForm.ts @@ -200,13 +200,17 @@ const ENDPROC_LINE = /^\s*ENDPROC\s*$/i; * for events that only exist in the designer) are dropped. */ export function parseVfpMethods(memo: string): Record { - const out: Record = {}; + // Every definition, in the order written. A class chain's memos arrive joined, the class the + // chain starts from first, so one name written twice is an override: the last is the one the + // object runs, and the ones before it are what DODEFAULT() reaches. Those are kept as + // `Name#1` for the nearest ancestor, `Name#2` for the one above it, and so on. + const defined: { name: string; source: string }[] = []; let name: string | null = null; let body: string[] = []; const flush = () => { if (name === null) return; const source = trimBlankEdges(body).join('\n'); - if (source.trim() !== '') out[name] = source; + if (source.trim() !== '') defined.push({ name, source }); name = null; body = []; }; @@ -222,9 +226,31 @@ export function parseVfpMethods(memo: string): Record { } } flush(); + + const levels = new Map(); + for (const d of defined) { + const key = d.name.toLowerCase(); + const list = levels.get(key); + if (list) list.push(d); + else levels.set(key, [d]); + } + const out: Record = {}; + for (const list of levels.values()) { + const latest = list[list.length - 1]!; + out[latest.name] = latest.source; + for (let depth = 1; depth < list.length; depth++) out[`${latest.name}#${depth}`] = list[list.length - 1 - depth]!.source; + } return out; } +/** An ancestor's copy of an overridden method, `Init#1`, as the event it is a copy of. */ +export function ancestorEvent(name: string): { event: string; depth: number } { + const hash = name.lastIndexOf('#'); + if (hash < 0) return { event: name, depth: 0 }; + const depth = Number(name.slice(hash + 1)); + return Number.isInteger(depth) && depth > 0 ? { event: name.slice(0, hash), depth } : { event: name, depth: 0 }; +} + /** * Which of a memo's procedures are the object's own, and which belong to something inside it. * @@ -523,26 +549,44 @@ function applyClass(row: Row, resolved: ResolvedClass): [Row, Row[]] { return undefined; }; - const own: string[] = []; - for (const entry of parseVfpProperties(row.properties)) { - const target = entry.name.includes('.') ? addressed(entry.name) : undefined; - if (target) target.member.properties = `${target.member.properties}\n${target.rest} = ${entry.raw}`; - else own.push(`${entry.name} = ${entry.raw}`); - } + // An ancestor between this class and the one that made a member writes its overrides the same + // way - `frmInstall` sets `pgfSteps.Page2.Name` on a pageframe `frmWizard` made - so every + // memo in the chain is routed, oldest first, for a later line to win as it does in one memo. + const routed = (properties: string): string => { + const kept: string[] = []; + for (const entry of parseVfpProperties(properties)) { + const target = entry.name.includes('.') ? addressed(entry.name) : undefined; + if (target) target.member.properties = `${target.member.properties}\n${target.rest} = ${entry.raw}`; + else kept.push(`${entry.name} = ${entry.raw}`); + } + return kept.join('\n'); + }; + const inherited = resolved.chain.map((r) => routed(withoutName(r.properties))); + const own = routed(row.properties); // The merged memo holds procedures out of several files, and each of them is compiled with the // header its own file named - so which file each one came from has to be written down before // the memos become one string. - const methodIncludes: Record = {}; + // An ancestor's copy of an overridden method - `INIT#1` - was written in its own class's + // file, so it carries that file's header rather than the one the override came with. + const writers = new Map(); for (const from of [...resolved.chain, row]) { - for (const name of Object.keys(parseVfpMethods(from.methods))) methodIncludes[name.toUpperCase()] = from.include; + for (const name of Object.keys(parseVfpMethods(from.methods))) { + const key = name.toUpperCase(); + writers.set(key, [...(writers.get(key) ?? []), from.include]); + } + } + const methodIncludes: Record = {}; + for (const [name, includes] of writers) { + methodIncludes[name] = includes[includes.length - 1]!; + for (let depth = 1; depth < includes.length; depth++) methodIncludes[`${name}#${depth}`] = includes[includes.length - 1 - depth]!; } const instance: Row = { ...row, methodIncludes, // a class's own Name is the class's, never the instance's: that comes from OBJNAME - properties: [...resolved.chain.map((r) => withoutName(r.properties)), own.join('\n')].filter((p) => p.trim() !== '').join('\n'), + properties: [...inherited, own].filter((p) => p.trim() !== '').join('\n'), methods: [...resolved.chain.map((r) => r.methods), row.methods].filter((m) => m.trim() !== '').join('\n'), custom: [...resolved.chain.map((r) => r.custom), row.custom].filter((c) => c.trim() !== '').join('\n'), }; @@ -1383,6 +1427,11 @@ function applyProperties( // is one of the designer's own notes, which describe the file rather than the object if (!HOUSEKEEPING.has(entry.name.toLowerCase()) && !entry.name.startsWith('_')) { props[entry.name] = entry.value; + // A property of the class's own keeps its text unquoted - `cAppName = ShutterDesign` is a + // string - so only one written in parentheses is an expression, worked out when the object + // is made: CodeMine's `nHkeyMachineRoot = ((2^31) + 2)` is a number to the registry calls + // that are handed it, or they read nothing. The text stays until then. + if (isParenthesised(entry.raw)) expressions[`${objectPath}.${entry.name}`] = entry.raw.trim(); } reserved[`${objectPath}.${entry.name}`] = entry.raw; continue; @@ -1398,6 +1447,18 @@ function applyProperties( */ const HOUSEKEEPING = new Set(['docreate']); +/** `(expr)`, the whole of it inside one pair of parentheses. */ +function isParenthesised(raw: string): boolean { + const t = raw.trim(); + if (!t.startsWith('(') || !t.endsWith(')')) return false; + let depth = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] === '(') depth++; + else if (t[i] === ')' && --depth === 0 && i < t.length - 1) return false; + } + return depth === 0; +} + /** VFP writes property names in any case; the registry declares one exact spelling. */ function declaredPropertyName(descriptor: ObjectDescriptor, name: string): string | undefined { const lower = name.toLowerCase(); @@ -1408,8 +1469,11 @@ function declaredPropertyName(descriptor: ObjectDescriptor, name: string): strin function normaliseMethods(methods: Record, descriptor: ObjectDescriptor): Record { const out: Record = {}; for (const [name, source] of Object.entries(methods)) { - const lower = name.toLowerCase(); - out[descriptor.events.find((e) => e.name.toLowerCase() === lower)?.name ?? name] = source; + // an ancestor's copy is spelled as the event it is a copy of, and keeps its depth + const { event, depth } = ancestorEvent(name); + const lower = event.toLowerCase(); + const spelled = descriptor.events.find((e) => e.name.toLowerCase() === lower)?.name ?? event; + out[depth > 0 ? `${spelled}#${depth}` : spelled] = source; } return out; } diff --git a/tests/fll/refparm.c b/tests/fll/refparm.c new file mode 100644 index 0000000..4617697 --- /dev/null +++ b/tests/fll/refparm.c @@ -0,0 +1,59 @@ +/* + * A library that writes to variables passed by reference, which neither of Microsoft's API + * samples does. Built by scripts/build-fllhost.mjs beside them, with the same toolchain, and + * measured against Visual FoxPro 9 in tests/fll/samples.test.ts. + * + * SWAPREF(cNew, @cVar) stores cNew in cVar and answers with what cVar held before + * BUMPREF(@nVar) adds one to nVar and answers .T. + * SETREF(@uVar) stores the integer 42 in uVar, whatever it held + * INTOF(n) answers the integer the library was handed for n, declared "I" + */ + +#include + +void FAR SwapRef(ParamBlk FAR *parm) { + Value old; + _Load(&parm->p[1].loc, &old); + _Store(&parm->p[1].loc, &parm->p[0].val); + if (old.ev_type == 'C') { + _RetChar(""); + if (_SetHandSize(old.ev_handle, old.ev_length + 1)) { + ((char FAR *)_HandToPtr(old.ev_handle))[old.ev_length] = '\0'; + _RetChar((char FAR *)_HandToPtr(old.ev_handle)); + } + _FreeHand(old.ev_handle); + } else { + _RetLogical(0); + } +} + +void FAR BumpRef(ParamBlk FAR *parm) { + Value v; + _Load(&parm->p[0].loc, &v); + if (v.ev_type == 'N') v.ev_real += 1; + else if (v.ev_type == 'I') v.ev_long += 1; + _Store(&parm->p[0].loc, &v); + _RetLogical(1); +} + +void FAR SetRef(ParamBlk FAR *parm) { + Value v; + v.ev_type = 'I'; + v.ev_width = 10; + v.ev_long = 42; + _Store(&parm->p[0].loc, &v); + _RetLogical(1); +} + +void FAR IntOf(ParamBlk FAR *parm) { + _RetInt(parm->p[0].val.ev_long, 11); +} + +FoxInfo myFoxInfo[] = { + {"SWAPREF", (FPFI)SwapRef, 2, "C,R"}, + {"BUMPREF", (FPFI)BumpRef, 1, "R"}, + {"SETREF", (FPFI)SetRef, 1, "R"}, + {"INTOF", (FPFI)IntOf, 1, "I"}, +}; + +FoxTable _FoxTable = {(FoxTable FAR *)0, sizeof(myFoxInfo) / sizeof(FoxInfo), myFoxInfo}; diff --git a/tests/fll/samples.test.ts b/tests/fll/samples.test.ts index 18196a9..df2a4d7 100644 --- a/tests/fll/samples.test.ts +++ b/tests/fll/samples.test.ts @@ -29,7 +29,8 @@ import { loadFoxVm } from '../../src/wasm/foxvm/loader'; const HOST = resolve('resources/native/win32/fllhost.exe'); const HELLO = resolve('tests/fll/build/hello.fll'); const REVERSE = resolve('tests/fll/build/reverse.fll'); -const have = existsSync(HOST) && existsSync(HELLO) && existsSync(REVERSE); +const REFPARM = resolve('tests/fll/build/refparm.fll'); +const have = existsSync(HOST) && existsSync(HELLO) && existsSync(REVERSE) && existsSync(REFPARM); /** * How `SET("LIBRARY")` names a path. Measured in Visual FoxPro 9: capitals, and quoted when the @@ -129,6 +130,35 @@ SET LIBRARY TO ${REVERSE} ADDITIVE expect(said.at(-1)).toBe(named(REVERSE)); }, 60_000); + it('hands a variable passed with @ to a parameter declared R, and keeps what it stored', async () => { + // refparm.c is ours, not Microsoft's: neither sample takes a reference. Measured in Visual + // FoxPro 9 with the same build of it; CodeMine's cmRegGetValue ("I,C,R") is the real case. + const said = await run(`SET LIBRARY TO ${REFPARM} +ON ERROR ? "err", ERROR() +cVar = "old" +x = SWAPREF("new", @cVar) +? "swap", x, cVar +nVar = 5 +? "bump", BUMPREF(@nVar), nVar, VARTYPE(nVar) +uVar = "text" +? "set", SETREF(@uVar), uVar, VARTYPE(uVar) +cVar = "kept" +x = SWAPREF("new", cVar) +? "byval", cVar`); + const printed = said.slice(1).filter((l) => !l.startsWith('Error ')); + expect(printed.map((l) => l.replace(/\s+/g, ' ').trim())).toEqual(['swap old new', 'bump .T. 6 N', 'set .T. 42 N', 'err 9', 'byval kept']); + }, 60_000); + + it('hands a number to a parameter declared I the way the product does, past the top of a long too', async () => { + // measured with refparm.fll's INTOF: a registry root is written 2147483650 for + // HKEY_LOCAL_MACHINE, and the library has to be handed 0x80000002 for it + const said = await run(`SET LIBRARY TO ${REFPARM} +? "a", INTOF(5), INTOF(-7), INTOF(2147483647) +? "b", INTOF(2147483648), INTOF(2147483650), INTOF(4294967295) +? "c", INTOF(3.7), INTOF(-3.7)`); + expect(said.slice(1).map((l) => l.replace(/\s+/g, ' ').trim())).toEqual(['a 5 -7 2147483647', 'b -2147483648 -2147483646 -1', 'c 3 -3']); + }, 60_000); + it('a name with no extension is a .fll', async () => { const said = await run(`SET LIBRARY TO ${REVERSE.replace(/\.fll$/, '')} ? "[" + Reverse("ab") + "]"`); diff --git a/tests/runtime/defineClass.test.ts b/tests/runtime/defineClass.test.ts index ed67ef6..c88652b 100644 --- a/tests/runtime/defineClass.test.ts +++ b/tests/runtime/defineClass.test.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { setApi } from '@renderer/api/foxdev'; import { createMemoryApi } from '@renderer/api/memoryApi'; import { useSessionStore } from '@renderer/runtime/session'; @@ -396,6 +396,82 @@ describe('DEFINE CLASS', () => { expect(printed).toEqual(["caught here: Variable 'NOSUCHVARIABLE' is not found."]); }); + it('carries on past an error raised inside an Error method, however it was reached', async () => { + // Measured in Visual FoxPro 9: an ERROR raised in an Error method - called directly, or + // reached as an ancestor's through DODEFAULT() - goes neither to Error again nor to ON + // ERROR; the method carries on at its next line. This was the loop in CodeMine's handler. + await useSessionStore.getState().execute( + source, + [ + 'ON ERROR ? "ON ERROR got", ERROR()', + 'o = CREATEOBJECT("cerr")', + 'o.Error(1098, "direct", 1)', + '? "count", o.nCount', + 'o = CREATEOBJECT("cchild")', + 'o.Error(1098, "direct", 1)', + '? "count", o.nCount', + 'RETURN', + '', + 'DEFINE CLASS cerr AS Custom', + ' nCount = 0', + ' PROCEDURE Error(nError, cMethod, nLine)', + ' THIS.nCount = THIS.nCount + 1', + ' ? "in Error", THIS.nCount', + ' IF THIS.nCount < 4', + ' ERROR "raised inside Error"', + ' ENDIF', + ' ? "after ERROR", THIS.nCount', + ' ENDPROC', + 'ENDDEFINE', + '', + 'DEFINE CLASS cchild AS cerr', + ' PROCEDURE Error(nError, cMethod, nLine)', + ' ? "child Error"', + ' DODEFAULT(nError, cMethod, nLine)', + ' ENDPROC', + 'ENDDEFINE', + ].join('\n'), + ); + const printed = useSessionStore.getState().output.filter((o) => o.kind === 'output').map((o) => o.text.replace(/\s+/g, ' ').trim()); + expect(printed).toEqual(['in Error 1', 'after ERROR 1', 'count 1', 'child Error', 'in Error 1', 'after ERROR 1', 'count 1']); + }); + + it('stops at the error dialog for an error inside an Error method the runtime called', async () => { + // Measured in Visual FoxPro 9: when the runtime runs Error because a method of the object + // failed, an error inside Error is not skipped. It goes to the default handler - the dialog, + // where vfp9.exe waits - and ON ERROR is not asked. + const finished = useSessionStore.getState().execute( + source, + [ + 'ON ERROR ? "ON ERROR got", ERROR()', + 'o = CREATEOBJECT("c2")', + 'o.Boom()', + '? "back"', + 'RETURN', + '', + 'DEFINE CLASS c2 AS Custom', + ' PROCEDURE Boom', + ' ? "boom", nosuchvar', + ' ENDPROC', + ' PROCEDURE Error(nError, cMethod, nLine)', + ' ? "in Error", nError', + ' ERROR "raised inside Error"', + ' ? "after ERROR"', + ' ENDPROC', + 'ENDDEFINE', + ].join('\n'), + ); + await vi.waitFor(() => expect(useSessionStore.getState().errorReport).not.toBeNull()); + + const report = useSessionStore.getState().errorReport; + expect(report?.error.code).toBe(1098); + expect(report?.error.program).toBe('C2.ERROR'); + const printed = useSessionStore.getState().output.filter((o) => o.kind === 'output').map((o) => o.text.replace(/\s+/g, ' ').trim()); + expect(printed.filter((t) => t !== '')).toEqual(['in Error 12']); + report?.resolve('cancel'); + await finished; + }); + it('does not hand an error inside the Error method back to itself', async () => { await useSessionStore.getState().execute( source, @@ -423,4 +499,117 @@ describe('DEFINE CLASS', () => { const printed = useSessionStore.getState().output.filter((o) => o.kind === 'output').map((o) => o.text); expect(printed).toEqual(["handled twice: Variable 'SECONDMISSING' is not found."]); }); + + it('runs the next class up with DODEFAULT(), as Visual FoxPro 9 measured it', async () => { + // Every expected line was printed by vfp9.exe running this same program. The one thing left + // out is `ca::Greet()` called from a method of another name, which still runs the parent's + // version of the running method. + await useSessionStore.getState().execute( + source, + [ + "o = CREATEOBJECT(\"cb\")", + "? \"1\", o.Greet(\"x\")", + "o = CREATEOBJECT(\"cc\")", + "? \"2\", o.Greet(\"y\")", + "o = CREATEOBJECT(\"cf\")", + "? \"3\", o.Greet(\"z\")", + "o = CREATEOBJECT(\"cd\")", + "? \"4\", o.Greet()", + "? \"5\", o.Who()", + "o = CREATEOBJECT(\"cb\")", + "? \"6\", o.Who()", + "? \"7\", o.nInits", + "o = CREATEOBJECT(\"cstmt\")", + "? \"8\", o.cLog", + "o = CREATEOBJECT(\"cscope\")", + "? \"9\", o.Greet(\"s\")", + "o = CREATEOBJECT(\"cnoret\")", + "? \"11\", o.Greet()", + "RETURN", + "", + "DEFINE CLASS ca AS Custom", + " nInits = 0", + " FUNCTION Init", + " THIS.nInits = THIS.nInits + 1", + " ENDFUNC", + " FUNCTION Greet(p)", + " RETURN \"a\" + p", + " ENDFUNC", + " FUNCTION Who", + " RETURN \"who=\" + THIS.Class", + " ENDFUNC", + "ENDDEFINE", + "", + "DEFINE CLASS cb AS ca", + " FUNCTION Init", + " THIS.nInits = THIS.nInits + 10", + " RETURN DODEFAULT()", + " ENDFUNC", + " FUNCTION Greet(p)", + " RETURN \"b<\" + DODEFAULT(p + \"!\") + \">\"", + " ENDFUNC", + "ENDDEFINE", + "", + "DEFINE CLASS cc AS cb", + "ENDDEFINE", + "", + "DEFINE CLASS ce AS ca", + "ENDDEFINE", + "", + "DEFINE CLASS cf AS ce", + " FUNCTION Greet(p)", + " RETURN \"f\" + DODEFAULT(p)", + " ENDFUNC", + "ENDDEFINE", + "", + "DEFINE CLASS cd AS Custom", + " FUNCTION Greet", + " RETURN DODEFAULT()", + " ENDFUNC", + " FUNCTION Who", + " RETURN \"cd-\" + TRANSFORM(DODEFAULT())", + " ENDFUNC", + "ENDDEFINE", + "", + "DEFINE CLASS cstmt AS ca", + " cLog = \"\"", + " FUNCTION Greet(p)", + " THIS.cLog = THIS.cLog + \"stmt;\"", + " DODEFAULT(p)", + " RETURN \"done\"", + " ENDFUNC", + " FUNCTION Init", + " THIS.Greet(\"q\")", + " THIS.cLog = THIS.cLog + TRANSFORM(THIS.nInits)", + " ENDFUNC", + "ENDDEFINE", + "", + "DEFINE CLASS cscope AS ca", + " FUNCTION Greet(p)", + " RETURN \"s\" + ca::Greet(p)", + " ENDFUNC", + "ENDDEFINE", + "", + "DEFINE CLASS cnoret AS ca", + " FUNCTION Greet(p)", + " DODEFAULT(\"n\")", + " ENDFUNC", + "ENDDEFINE", + ].join('\n'), + ); + const printed = useSessionStore.getState().output.filter((o) => o.kind === 'output').map((o) => o.text.trimEnd()); + expect(printed).toEqual([ + '1 b', + '2 b', + '3 faz', + '4 .T.', + '5 cd-.T.', + '6 who=Cb', + '7 11', + '8 stmt;0', + '9 sas', + '11 .T.', + ]); + expect(useSessionStore.getState().output.filter((o) => o.kind === 'error')).toEqual([]); + }); }); diff --git a/tests/shared/vfpForm.test.ts b/tests/shared/vfpForm.test.ts index 6e8c82d..4740349 100644 --- a/tests/shared/vfpForm.test.ts +++ b/tests/shared/vfpForm.test.ts @@ -197,6 +197,31 @@ describe('parseVfpMethods', () => { ].join('\n'), ); }); + it("keeps an overridden method's earlier versions for DODEFAULT to reach", () => { + // a class chain's memos arrive joined, the class the chain starts from first: the last + // definition is what runs, and the ones before it are its ancestors', nearest first + const memo = [ + 'PROCEDURE Init', + 'grand = 1', + 'ENDPROC', + 'PROCEDURE Click', + 'only = 1', + 'ENDPROC', + 'PROCEDURE init', + 'parent = 1', + 'ENDPROC', + 'PROCEDURE INIT', + 'own = 1', + 'RETURN DODEFAULT()', + 'ENDPROC', + ].join('\n'); + expect(parseVfpMethods(memo)).toEqual({ + INIT: 'own = 1\nRETURN DODEFAULT()', + 'INIT#1': 'parent = 1', + 'INIT#2': 'grand = 1', + Click: 'only = 1', + }); + }); it('ends a body at the next PROCEDURE when ENDPROC is missing', () => { const methods = parseVfpMethods('PROCEDURE Init\r\nx = 1\r\nPROCEDURE Click\r\ny = 2\r\nENDPROC'); expect(methods).toEqual({ Init: 'x = 1', Click: 'y = 2' }); @@ -684,6 +709,73 @@ describe('importClassLibrary', () => { expect(pages[1]!.children!.map((c) => [c.type, c.name])).toEqual([['Label', 'Label1']]); }); + it('keeps the pages a middle class names when a third class inherits them', () => { + // CodeMine's install dialog: frmWizardDialog makes the pageframe, frmInstallDialog names its + // pages from the form row and puts controls on them, and the application's own dialog + // inherits all of that with no rows of its own. The middle class's dotted page names have + // to reach the pageframe, or its controls have no page to go on. + const base = row({ PLATFORM: 'WINDOWS', CLASS: 'form', BASECLASS: 'form', OBJNAME: 'wizard', PARENT: '', PROPERTIES: props('Name = "wizard"') }); + const frame = row({ + PLATFORM: 'WINDOWS', + CLASS: 'pageframe', + BASECLASS: 'pageframe', + OBJNAME: 'pgfSteps', + PARENT: 'wizard', + PROPERTIES: props('PageCount = 1', 'Name = "pgfSteps"'), + }); + const middle = row({ + PLATFORM: 'WINDOWS', + CLASS: 'wizard', + BASECLASS: 'form', + OBJNAME: 'install', + PARENT: '', + PROPERTIES: props('Name = "install"', 'pgfSteps.PageCount = 2', 'pgfSteps.Page1.Name = "pagRegistration"', 'pgfSteps.Page2.Name = "pagPaths"'), + }); + const onPaths = row({ + PLATFORM: 'WINDOWS', + CLASS: 'label', + BASECLASS: 'label', + OBJNAME: 'lblLocal', + PARENT: 'install.pgfSteps.pagPaths', + PROPERTIES: props('Name = "lblLocal"'), + }); + const leaf = row({ + PLATFORM: 'WINDOWS', + CLASS: 'install', + BASECLASS: 'form', + OBJNAME: 'appinstall', + PARENT: '', + PROPERTIES: props('Name = "appinstall"', 'pgfSteps.Height = 200'), + }); + + const classes = importClassLibrary(table(HEADER_ROW, base, frame, middle, onPaths, leaf), 'dialogs'); + const imported = classes.find((c) => c.className === 'appinstall')!.imported; + expect(imported.warnings).toEqual([]); + + const frameNode = imported.doc.form.children[0]!; + expect(frameNode.props).toMatchObject({ Height: 200 }); + const pages = frameNode.children!; + expect(pages.map((p) => p.name)).toEqual(['pagRegistration', 'pagPaths']); + expect(pages[1]!.children!.map((c) => c.name)).toEqual(['lblLocal']); + expect(imported.doc.form.children).toHaveLength(1); + }); + + it("works out a class's own property written in parentheses, and keeps an unquoted one as text", () => { + // CodeMine's cmApplicationManager: `nhkeymachineroot = ((2^31) + 2)` is HKEY_LOCAL_MACHINE to + // every registry call handed it, while `cappname = ShutterDesign` is a string + const app = row({ + PLATFORM: 'WINDOWS', + CLASS: 'custom', + BASECLASS: 'custom', + OBJNAME: 'appmanager', + PARENT: '', + PROPERTIES: props('nhkeymachineroot = ((2^31) + 2)', 'cappname = ShutterDesign', 'cpair = (a) + (b)', 'Name = "appmanager"'), + }); + const imported = importClassLibrary(table(HEADER_ROW, app), 'apps').find((c) => c.className === 'appmanager')!.imported; + expect(imported.doc.meta?.vfp?.expressions).toEqual({ 'appmanager.nhkeymachineroot': '((2^31) + 2)' }); + expect(imported.doc.form.props['cappname']).toBe('ShutterDesign'); + }); + it('imports a Collection member the way it imports a Custom one', () => { const holder = row({ PLATFORM: 'WINDOWS',