Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su

---

## [1.0.0] — unreleased — feature-complete

> Parity milestone. Accumulates on `release/v1.0.0`; tagged when it merges to `main`.

### Added
- **`?` / `??` print command** (#2) — evaluate an expression (or a comma-separated
list) and print the result. Strings print unquoted, booleans as `.T.`/`.F.`, and
numbers right-justified in a 10-wide field (dBASE III numeric display). A bare
`?` prints a blank line. `??` is accepted; its "no leading newline" semantics are
not expressible in the line-based web terminal, so it shares `?`'s formatting.

---

## [0.8.0] — 2026-06-27 — More built-in functions

### Added
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area
### Variables & I/O
| Command | What it does |
|---|---|
| `? <expr>[, <expr>...]` | Evaluate expression(s) and print; numbers right-justified, bare `?` prints a blank line. `??` accepted (shares `?` formatting in the web terminal) |
| `STORE <val> TO <var>` | Assign a variable; booleans display as `.T.`/`.F.` |
| `INPUT "prompt" TO <var>` | Collect keyboard input (shows pending @SAY fields + prompt) |
| `@ r,c SAY "text" GET <var>` | Define a form field |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ WebBase-III supports **unlimited work areas** — each independently holding a t

| Command | What it does |
|---|---|
| `? <expr>[, <expr>...]` | Evaluate expression(s) and print the result (numbers right-justified; bare `?` prints a blank line; `??` also accepted) |
| `STORE <val> TO <var>` | Assign a variable |
| `INPUT "prompt" TO <var>` | Collect keyboard input |
| `@ r,c SAY "text" GET <var>` | Define a form field |
Expand Down
22 changes: 22 additions & 0 deletions src/interpreter/Executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export class Executor implements IndexCommandsHost {
case 'LIST_AREAS': return this.doListAreas();
case 'LIST_COLS': return this.doListCols(node.cols);
case 'BROWSE': return { output: [], action: 'BROWSE' };
case 'PRINT': return this.doPrint(node.exprs);
case 'CLEAR': return { output: [{ text: '', cls: 'clear' }] };
case 'QUIT': return { output: [], action: 'QUIT' };
case 'HELP': return this.doHelp();
Expand Down Expand Up @@ -531,6 +532,17 @@ export class Executor implements IndexCommandsHost {
return { output: [{ text: `${varName} = ${fmtVal(v)}`, cls: 'info' }] };
}

// dBASE ? / ?? — evaluate expression(s) and print the result. A bare ? prints a
// blank line. Multiple comma-separated expressions are joined by a single space.
// (?? "no leading newline" semantics aren't expressible in the line-based web
// terminal, so ?? shares ?'s formatting.)
private async doPrint(exprs: Expr[]): Promise<ExecResult> {
await this.refreshRecCount();
if (exprs.length === 0) return { output: [{ text: '', cls: 'info' }] };
const text = exprs.map(e => fmtPrint(this.evalExpr(e))).join(' ');
return { output: [{ text, cls: 'info' }] };
}

private doInput(prompt: string, varName: string): ExecResult {
this.vars.set(varName, this.vars.get(varName) ?? '');
const pending = [...this.pendingForm];
Expand Down Expand Up @@ -1044,6 +1056,16 @@ function fmtVal(v: unknown): string {
return String(v);
}

// Formatting for the ? / ?? print command: strings unquoted, booleans as .T./.F.,
// numbers right-justified in a 10-wide field (dBASE III numeric display, matching
// STR()'s default width).
function fmtPrint(v: unknown): string {
if (typeof v === 'boolean') return v ? '.T.' : '.F.';
if (typeof v === 'number') return String(v).padStart(10);
if (v === null || v === undefined) return '';
return String(v);
}

function q(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}
7 changes: 7 additions & 0 deletions src/interpreter/Lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export class Lexer {
continue;
}

// dBASE print command: ? (with leading newline) and ?? (no newline)
if (ch === '?') {
if (this.src[this.p + 1] === '?') { this.emit('OP', '??'); }
else { this.emit('OP', '?'); }
continue;
}

if (ch === '@') { this.emit('AT', '@'); continue; }
if (ch === ',') { this.emit('COMMA', ','); continue; }
if (ch === ';') { this.emit('SEMI', ';'); continue; }
Expand Down
18 changes: 18 additions & 0 deletions src/interpreter/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type ASTNode =
| { type: 'LIST_REPORTS' }
| { type: 'DELETE_REPORT'; name: string }
| { type: 'BROWSE' }
| { type: 'PRINT'; exprs: Expr[]; newline: boolean }
| { type: 'CLEAR' }
| { type: 'QUIT' }
| { type: 'HELP' }
Expand Down Expand Up @@ -105,6 +106,9 @@ export class Parser {

if (t.type === 'AT') return this.parseAt();

// dBASE ? / ?? print command (OP tokens, not keywords)
if (t.type === 'OP' && (t.val === '?' || t.val === '??')) return this.parsePrint();

if (t.type !== 'KW' && t.type !== 'ID') {
const raw = t.val; this.adv(); this.skipLine();
return { type: 'UNKNOWN', raw };
Expand Down Expand Up @@ -568,6 +572,20 @@ export class Parser {

// ── Helpers ─────────────────────────────────────────────────────────────

private parsePrint(): ASTNode {
const newline = this.adv().val === '?'; // consume ? (newline) or ?? (no newline)
const exprs: Expr[] = [];
const atEnd = () => {
const ty = this.peek().type;
return ty === 'NL' || ty === 'SEMI' || ty === 'EOF';
};
if (!atEnd()) {
exprs.push(this.expr());
while (this.peek().type === 'COMMA') { this.adv(); exprs.push(this.expr()); }
}
return { type: 'PRINT', exprs, newline };
}

private peek(): Token { return this.toks[this.p] ?? { type: 'EOF', val: '', line: 0, col: 0 }; }
private prev(): Token { return this.toks[this.p - 1] ?? { type: 'EOF', val: '', line: 0, col: 0 }; }
private adv(): Token { const t = this.peek(); if (!this.end()) this.p++; return t; }
Expand Down
55 changes: 55 additions & 0 deletions tests/Print.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { Session } from '../server/Session';
import type { ServerMessage } from '../src/shared/types.js';

async function run(text: string): Promise<string[]> {
const sent: ServerMessage[] = [];
const session = new Session((m) => sent.push(m));
await session.handleMessage({ type: 'command', text });
const lines: string[] = [];
for (const m of sent) {
if (m.type === 'output') {
for (const l of (m as { lines: { text: string }[] }).lines) lines.push(l.text);
}
}
return lines;
}

describe('? / ?? print command', () => {
it('evaluates and prints an arithmetic expression, right-justified', async () => {
const lines = await run('? 2 + 2');
const hit = lines.find(l => l.trim() === '4');
expect(hit).toBeDefined();
expect(hit!.startsWith(' ')).toBe(true); // numeric output is right-justified
});

it('prints a string result without quotes', async () => {
const lines = await run('? UPPER("hello")');
expect(lines.some(l => l === 'HELLO')).toBe(true);
});

it('prints multiple comma-separated expressions joined by a space', async () => {
const lines = await run('? "a", "b"');
expect(lines.some(l => l === 'a b')).toBe(true);
});

it('prints a blank line for a bare ?', async () => {
const lines = await run('?');
expect(lines.some(l => l === '')).toBe(true);
});

it('renders booleans as .T./.F.', async () => {
const lines = await run('? 1 = 1');
expect(lines.some(l => l === '.T.')).toBe(true);
});

it('?? prints its expression', async () => {
const lines = await run('?? "x"');
expect(lines.some(l => l === 'x')).toBe(true);
});

it('does not treat ? as an unknown command', async () => {
const lines = await run('? 7');
expect(lines.some(l => /unknown|unrecognized|\*\*/i.test(l))).toBe(false);
});
});