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/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/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..a3d4917 100644 --- a/src/shared/runtime/host.ts +++ b/src/shared/runtime/host.ts @@ -141,7 +141,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/objectModel.ts b/src/shared/runtime/objectModel.ts index 92be2f6..d91734e 100644 --- a/src/shared/runtime/objectModel.ts +++ b/src/shared/runtime/objectModel.ts @@ -2676,7 +2676,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/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',