From a32f8a6e4a42008c931731a2aaaf6e25aadba247 Mon Sep 17 00:00:00 2001 From: Jeff Roberts Date: Wed, 23 Sep 2026 19:26:26 -0500 Subject: [PATCH] Start-up environment: VERSION(2), SET LIBRARY paths, POPUP(), #INCLUDE loops Found running a CodeMine application (Shutter Ace) from the IDE; measured in Visual FoxPro 9 first. - VERSION(2) answers 2 in the development environment and 0 in a built application, and is a number; programs branch on it to find their source. The IDE says which it is through a RUNTIME setting. - SET LIBRARY TO codemine.fll with no folder means the one beside the application - the project folder - not the library host's own directory. - POPUP(cName) answers whether a popup of that name is defined, as well as POPUP() naming the active one. - A header file that includes itself, or two that include each other, are read once each rather than forever; the project source finds a header by name wherever in the project it sits. Co-Authored-By: Claude Opus 5.5 --- crates/foxvm/src/builtins/menu.rs | 11 ++++- crates/foxvm/src/builtins/system.rs | 17 +++++-- crates/foxvm/src/parser.rs | 28 ++++++++--- crates/foxvm/src/value.rs | 4 ++ crates/foxvm/src/wasm.rs | 1 + crates/foxvm/tests/builtins_system.rs | 7 +++ crates/foxvm/tests/directives.rs | 9 ++++ .../tests/programs/ref_popup_named.expected | 5 ++ .../foxvm/tests/programs/ref_popup_named.prg | 14 ++++++ docs/language-reference.md | 2 +- src/renderer/src/runtime/projectSource.ts | 48 ++++++++++++++----- src/renderer/src/runtime/session.ts | 17 ++++++- src/renderer/src/shell/IdeLayout.tsx | 1 + 13 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 crates/foxvm/tests/programs/ref_popup_named.expected create mode 100644 crates/foxvm/tests/programs/ref_popup_named.prg diff --git a/crates/foxvm/src/builtins/menu.rs b/crates/foxvm/src/builtins/menu.rs index 8900e35..9928e29 100644 --- a/crates/foxvm/src/builtins/menu.rs +++ b/crates/foxvm/src/builtins/menu.rs @@ -132,7 +132,14 @@ fn f_pad(c: &mut dyn BuiltinCtx, _a: Vec) -> Result) -> Result { +/// POPUP([cMenuName]). With no argument, the popup that is active. With one, whether a popup of +/// that name is defined - measured: a logical, the name compared without regard to case and +/// without trimming, and a number there error 11. +fn f_popup(c: &mut dyn BuiltinCtx, a: Vec) -> Result { + if let Some(named) = a.first() { + let Value::Str(name) = named.deref() else { return Err(RtError::function_arg_invalid()) }; + return value(Value::Logical(c.menus().popup(&name).is_some())); + } let menus = c.menus(); let name = if menus.active_popup.is_empty() { &menus.last_popup } else { &menus.active_popup }; value(Value::str(name.to_ascii_uppercase())) @@ -218,7 +225,7 @@ pub fn specs() -> Vec { spec("MRKBAR", 2, 2, f_mrkbar), spec("MRKPAD", 2, 2, f_mrkpad), spec("PAD", 0, 0, f_pad), - spec("POPUP", 0, 0, f_popup), + spec("POPUP", 0, 1, f_popup), spec("PRMBAR", 2, 2, f_prmbar), spec("PRMPAD", 2, 2, f_prmpad), spec("PROMPT", 0, 0, f_prompt), diff --git a/crates/foxvm/src/builtins/system.rs b/crates/foxvm/src/builtins/system.rs index f9012e5..2b65f3b 100644 --- a/crates/foxvm/src/builtins/system.rs +++ b/crates/foxvm/src/builtins/system.rs @@ -220,11 +220,22 @@ fn f_forcepath(_c: &mut dyn BuiltinCtx, a: Vec) -> Result) -> Result { +/// VERSION([nExpression]). Measured in Visual FoxPro 9: 2 is a number, the edition - 2 in the +/// development environment and 0 in the runtime - and 5 is the number 900, which is what a +/// program compares against before it uses anything new in version 9. 3 is the language, "00" +/// for English, and 0 or anything past 5 is error 11. The product's name in 1 and the plain +/// form is this runtime's own. +fn f_version(c: &mut dyn BuiltinCtx, a: Vec) -> Result { + if a.is_empty() { + return ok(Value::str(format!("FoxDev Studio Runtime {}", crate::VERSION))); + } match opt_int(&a, 0, 1)? { + 1 => ok(Value::str(format!("FoxDev Studio Runtime {}", crate::VERSION))), + 2 => ok(Value::number(if c.settings().runtime_only { 0.0 } else { 2.0 })), + 3 => ok(Value::str("00")), 4 => ok(Value::str(crate::VERSION)), - 5 => ok(Value::number(0.0)), - _ => ok(Value::str(format!("FoxDev Studio Runtime {}", crate::VERSION))), + 5 => ok(Value::number(900.0)), + _ => Err(RtError::function_arg_invalid()), } } diff --git a/crates/foxvm/src/parser.rs b/crates/foxvm/src/parser.rs index 2c0b0d2..a01d269 100644 --- a/crates/foxvm/src/parser.rs +++ b/crates/foxvm/src/parser.rs @@ -59,8 +59,9 @@ pub fn parse_method(src: &str) -> ParseOutput { pub fn parse_method_with(src: &str, headers: &std::collections::HashMap, include: &str) -> ParseOutput { let mut p = Parser::new(src, Mode::Method); p.headers = headers.clone(); - if let Some(text) = p.headers.get(&header_stem(include)).cloned() { - p.read_header(&text); + let stem = header_stem(include); + if let Some(text) = p.headers.get(&stem).cloned() { + p.read_header(&stem, &text); } let program = p.program(); ParseOutput { program, diagnostics: p.diags } @@ -442,6 +443,10 @@ struct Parser { src: Vec, /// The header files this program may include, by name without folder or extension. headers: std::collections::HashMap, + /// The headers being read right now, innermost last. A header that includes itself, or one + /// that includes it back, is read once: a second `#INCLUDE` of a header already open brings + /// nothing the first did not, and following it would never end. + headers_open: Vec, toks: Vec, /// Parallel to `toks`: false for tokens produced by `#DEFINE` expansion (never re-expanded). expandable: Vec, @@ -466,6 +471,7 @@ impl Parser { Parser { src: src.chars().collect(), headers: std::collections::HashMap::new(), + headers_open: Vec::new(), toks: lexed.tokens, expandable: vec![true; n], pos: 0, @@ -6992,7 +6998,7 @@ impl Parser { match self.headers.get(&stem) { Some(text) => { let text = text.clone(); - self.read_header(&text); + self.read_header(&stem, &text); } None => { let span = hash.span.to(name_tok.span); @@ -7016,7 +7022,16 @@ impl Parser { /// Reads a header file's constants into this parse: its `#DEFINE` lines, and the ones of /// any header it includes in turn. - fn read_header(&mut self, text: &str) { + fn read_header(&mut self, stem: &str, text: &str) { + if self.headers_open.iter().any(|open| open == stem) { + return; + } + self.headers_open.push(stem.to_string()); + self.read_header_text(text); + self.headers_open.pop(); + } + + fn read_header_text(&mut self, text: &str) { let tokens = crate::lexer::lex(text).tokens; let mut at = 0usize; while at < tokens.len() { @@ -7056,8 +7071,9 @@ impl Parser { _ => String::new(), }) .collect(); - if let Some(text) = self.headers.get(&header_stem(&file)).cloned() { - self.read_header(&text); + let stem = header_stem(&file); + if let Some(text) = self.headers.get(&stem).cloned() { + self.read_header(&stem, &text); } } _ => {} diff --git a/crates/foxvm/src/value.rs b/crates/foxvm/src/value.rs index 6bf5297..9101121 100644 --- a/crates/foxvm/src/value.rs +++ b/crates/foxvm/src/value.rs @@ -438,6 +438,9 @@ pub struct Settings { pub talk: bool, pub safety: bool, pub escape: bool, + /// Running as a built application rather than in the IDE: what `VERSION(2)` answers, 0 for + /// the runtime and 2 for the development edition. The IDE's player turns it on. + pub runtime_only: bool, /// SET DELETED ON hides deleted records from every movement command. pub deleted: bool, /// SET NEAR ON leaves the pointer at the nearest record when a SEEK misses, rather than at @@ -535,6 +538,7 @@ impl Default for Settings { talk: true, safety: true, escape: true, + runtime_only: false, deleted: false, near: false, unique: false, diff --git a/crates/foxvm/src/wasm.rs b/crates/foxvm/src/wasm.rs index 6cf9105..bad7598 100644 --- a/crates/foxvm/src/wasm.rs +++ b/crates/foxvm/src/wasm.rs @@ -870,6 +870,7 @@ impl FoxVm { "TALK" => s.talk = v.truthy().unwrap_or(true), "SAFETY" => s.safety = v.truthy().unwrap_or(true), "ESCAPE" => s.escape = v.truthy().unwrap_or(true), + "RUNTIME" => s.runtime_only = v.truthy().unwrap_or(false), "DECIMALS" => s.decimals = v.as_number().unwrap_or(2.0).clamp(0.0, 18.0) as u8, "DATE" => { if let Some(f) = v.as_str().ok().and_then(|w| crate::value::DateFormat::parse(&w)) { diff --git a/crates/foxvm/tests/builtins_system.rs b/crates/foxvm/tests/builtins_system.rs index 7fc5850..76b3280 100644 --- a/crates/foxvm/tests/builtins_system.rs +++ b/crates/foxvm/tests/builtins_system.rs @@ -132,6 +132,13 @@ fn file_requests() { fn environment_functions() { assert_eq!(text("VERSION", vec![]), format!("FoxDev Studio Runtime {}", foxvm::VERSION)); assert_eq!(text("VERSION", vec![n(4.0)]), foxvm::VERSION); + // measured: the edition and the version number are numbers, a program compares both, and + // the edition is the development one unless the host says this is a built application + assert_eq!(num("VERSION", vec![n(2.0)]), 2.0); + assert_eq!(text("VERSION", vec![n(3.0)]), "00"); + assert_eq!(num("VERSION", vec![n(5.0)]), 900.0); + assert_eq!(err("VERSION", vec![n(0.0)]).code, RtError::FUNCTION_ARG_INVALID); + assert_eq!(err("VERSION", vec![n(6.0)]).code, RtError::FUNCTION_ARG_INVALID); // a FoxPro program that guards on the Windows version reads OS(3) and OS(4) assert_eq!(text("OS", vec![]), "Windows 10.00"); // OS(2) is DBCS support, not the version - measured against the product empty on the code diff --git a/crates/foxvm/tests/directives.rs b/crates/foxvm/tests/directives.rs index 840c467..467d91b 100644 --- a/crates/foxvm/tests/directives.rs +++ b/crates/foxvm/tests/directives.rs @@ -57,6 +57,15 @@ fn an_included_header_brings_its_constants() { let text = dump(&out.program).lines().filter(|l| !l.contains("(directive")).collect::>().join(" "); assert_eq!(text, "(= X (+ 80 25))"); + // headers that include themselves, or each other, are read once each rather than forever + let mut looped = HashMap::new(); + looped.insert("SELF".to_string(), "#INCLUDE self.h\n#DEFINE A 1\n".to_string()); + looped.insert("PING".to_string(), "#INCLUDE pong.h\n#DEFINE B 2\n".to_string()); + looped.insert("PONG".to_string(), "#INCLUDE ping.h\n#DEFINE C 3\n".to_string()); + let out = parse_program_with("#INCLUDE self.h\n#INCLUDE ping.h\nx = A + B + C\n", &looped); + let text = dump(&out.program).lines().filter(|l| !l.contains("(directive")).collect::>().join(" "); + assert_eq!(text, "(= X (+ (+ 1 2) 3))"); + // and one that is not there is a warning, not an error let out = parse_program("#INCLUDE nowhere.h\nx = 1\n"); assert!(out.diagnostics.iter().all(|d| !d.is_error())); diff --git a/crates/foxvm/tests/programs/ref_popup_named.expected b/crates/foxvm/tests/programs/ref_popup_named.expected new file mode 100644 index 0000000..386b230 --- /dev/null +++ b/crates/foxvm/tests/programs/ref_popup_named.expected @@ -0,0 +1,5 @@ +before L .F. +after .T. .T. +padded .F. +released .F. +active C [] diff --git a/crates/foxvm/tests/programs/ref_popup_named.prg b/crates/foxvm/tests/programs/ref_popup_named.prg new file mode 100644 index 0000000..4502632 --- /dev/null +++ b/crates/foxvm/tests/programs/ref_popup_named.prg @@ -0,0 +1,14 @@ +* COVERS: POPUP +* +* POPUP(cMenuName) asks whether a popup of that name is defined, which is how an application +* that rebuilds its Window menu finds out whether it has one yet. Measured in Visual FoxPro 9. +? "before", TYPE([POPUP("fdvpop")]), POPUP("fdvpop") +DEFINE POPUP fdvpop +DEFINE BAR 1 OF fdvpop PROMPT "one" +? "after", POPUP("fdvpop"), POPUP("FDVPOP") +* the name is not trimmed +? "padded", POPUP(" fdvpop ") +RELEASE POPUPS fdvpop +? "released", POPUP("fdvpop") +* with no argument it is still the name of the popup that is active: none here +? "active", TYPE("POPUP()"), "[" + POPUP() + "]" diff --git a/docs/language-reference.md b/docs/language-reference.md index 8966146..7052efe 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -305,7 +305,7 @@ call fails with an explanation rather than "procedure not found". | PCOUNT | 0 | | PEMSTATUS | 3 | | PI | 0 | -| POPUP | 0 | +| POPUP | 0-1 | | PRIMARY | 0-2 | | PRINTSTATUS | 0 | | PRMBAR | 2 | diff --git a/src/renderer/src/runtime/projectSource.ts b/src/renderer/src/runtime/projectSource.ts index 67aeed6..b76ea07 100644 --- a/src/renderer/src/runtime/projectSource.ts +++ b/src/renderer/src/runtime/projectSource.ts @@ -15,6 +15,7 @@ import { baseName, formHeaderRefs, formMethodSources, + headerStem, requireBytes, type CompiledForm, type CompiledProgram, @@ -26,7 +27,7 @@ import { refreshed } from '../vfp/refreshImport'; import { useDocumentsStore, type OpenDocument } from '../stores/documentsStore'; import { useProjectStore } from '../stores/projectStore'; import { readHeaderFiles } from './headerFiles'; -import { compileForm, compileProgram, includedHeaders } from './vmBridge'; +import { compileForm, compileProgram, includedHeaderNames } from './vmBridge'; /** Finds an open document whose file name matches, ignoring directory and extension. */ function openDocumentNamed(name: string, kind: OpenDocument['kind']): OpenDocument | undefined { @@ -51,16 +52,36 @@ function projectItemPath(name: string, extensions: string[]): string | null { } /** - * The text of every header file a program includes, by name without folder or extension. A - * header the project does not have is left out: the compiler warns about it by name. + * The text of every header file a program includes, by name without folder or extension. + * + * A header the project lists is taken from there. The rest are looked for the way a form's are: + * beside the program, then in the default directory - the project folder - then among the + * Foundation Classes, following the headers they include in turn. A Visual FoxPro project + * rarely lists its `.h` files at all, so without the search every constant an application + * defines is an unknown name when the line that uses it runs. One found nowhere is left out, + * and the compiler warns about it by name. */ -async function headerFiles(source: string): Promise> { +async function headerFiles(source: string, dir?: string): Promise> { const out: Record = {}; - for (const stem of includedHeaders(source)) { - if (stem in out) continue; - const path = projectItemPath(stem, ['.h', '.prg']); - if (!path) continue; - out[stem] = await getApi().files.readText(path).catch(() => ''); + const unlisted: string[] = []; + for (const reference of includedHeaderNames(source)) { + const stem = headerStem(reference); + if (stem === '' || stem in out) continue; + // the extension the reference wrote is the file it means: `#include "CodeMine.h"` in + // codemine.prg is the header beside it, and never the program itself, whose text would + // include the header again + const file = reference.trim().split(/[\\/]/).pop() ?? ''; + const dot = file.lastIndexOf('.'); + const extensions = dot > 0 ? [file.slice(dot).toLowerCase()] : ['.h', '.prg']; + const path = projectItemPath(stem, extensions); + if (path) out[stem] = await getApi().files.readText(path).catch(() => ''); + else unlisted.push(reference); + } + if (unlisted.length > 0) { + const project = useProjectStore.getState(); + const dirs = [dir ?? '', project.path ? dirname(project.path) : '', await bundledClassLibraryDir()].filter((d) => d !== ''); + const found = await readHeaderFiles(unlisted, dirs.length > 0 ? dirs : ['']); + for (const [stem, text] of Object.entries(found)) if (!(stem in out)) out[stem] = text; } return out; } @@ -116,20 +137,25 @@ export function createProjectSource(): ProgramSource { let source: string; let key: string; let display = baseName(name); + let dir: string | undefined; if (open?.kind === 'program') { source = open.text; key = `open:${open.id}:${open.text.length}:${open.text.slice(0, 64)}`; - if (open.path) display = baseName(basename(open.path)); + if (open.path) { + display = baseName(basename(open.path)); + dir = dirname(open.path); + } } else { const path = projectItemPath(name, ['.prg']); if (!path) return null; source = await getApi().files.readText(path); key = `file:${source.length}:${source.slice(0, 64)}`; display = baseName(basename(path)); + dir = dirname(path); } - const headers = await headerFiles(source); + const headers = await headerFiles(source, dir); const cached = programs.get(display, `${key}:${Object.keys(headers).join(',')}`); if (cached) return cached; const bytes = requireBytes(display, compileProgram(source, display, headers)); diff --git a/src/renderer/src/runtime/session.ts b/src/renderer/src/runtime/session.ts index d810041..06ffb60 100644 --- a/src/renderer/src/runtime/session.ts +++ b/src/renderer/src/runtime/session.ts @@ -212,6 +212,11 @@ export const runtimeUi = { newDocument: async (kind: string, _path: string): Promise => { throw new Error(`CREATE ${kind.toUpperCase()} needs a designer, which the player has not`); }, + /** + * The development environment rather than a built application: `VERSION(2)` answers 2 here + * and 0 in the player, and programs branch on it to find their source. + */ + development: false, }; const showDesktop = () => runtimeUi.showDesktop(); @@ -335,9 +340,17 @@ export const useSessionStore = create((set, get) => { desktop.runLine = (text) => runSnippet(text, 'line').then(() => undefined); desktop.setVariable = (name, value) => vm.setGlobal(name, value); desktop.quitRequested = () => void get().cancel(); - // SET LIBRARY TO reaches the process that can load a 32-bit .fll; there is none in a browser - desktop.libraries = getApi().library; + // SET LIBRARY TO reaches the process that can load a 32-bit .fll; there is none in a browser. + // A library named without a folder is the program's default directory's, which here is the + // project folder - `SET LIBRARY TO codemine.fll` means the one beside the application. Left + // alone, that process would resolve it against its own working directory instead. + const libraryHost = getApi().library; + desktop.libraries = libraryHost && { + ...libraryHost, + load: (path: string) => libraryHost.load(isAbsolute(path) ? path : useProjectStore.getState().resolvePath(path)), + }; const vm = createVm(desktop); + vm.setSetting('RUNTIME', !runtimeUi.development); // _SAMPLES names the samples directory, which is HOME(2). A program that opens // _samples + "\Data\customer.dbf" reads it on its first line, so it is answered before // anything runs rather than while it does. diff --git a/src/renderer/src/shell/IdeLayout.tsx b/src/renderer/src/shell/IdeLayout.tsx index 3eb51e0..e0240f9 100644 --- a/src/renderer/src/shell/IdeLayout.tsx +++ b/src/renderer/src/shell/IdeLayout.tsx @@ -31,6 +31,7 @@ function setShell(patch: Partial) { registerIdeCommands({ toggle: (p) => setShell({ [p]: !shellState[p] }), get: (p) => shellState[p] }); // a form opened by any means (Run, a menu item, the Command Window) surfaces the desktop tab runtimeUi.showDesktop = () => void useDocumentsStore.getState().openDesktop(); +runtimeUi.development = true; // a program that stops has to be seen stopping, however the debugger window was left runtimeUi.showDebugger = () => setShell({ showDebugger: true }); // MODIFY COMMAND and BROWSE open a file in a tab: the same thing File > Open does