Skip to main content

retex_engine/
init.rs

1//! Engine construction, IniTeX initialization and the job driver.
2
3use std::collections::HashMap;
4use std::rc::Rc;
5
6use crate::Engine;
7use crate::cmd::Cmd;
8use crate::cond::CondState;
9use crate::eqtb::{Eqtb, Meaning};
10use crate::error::{ErrorState, Interaction, JumpOut};
11use crate::files::FileState;
12use crate::fonts::{FontInfo, FontTable};
13use crate::hash::{CsId, CsTable, NO_CS};
14use crate::input::{InputState, LineSource};
15use crate::maincontrol::Mode;
16use crate::nodes::Nodes;
17use crate::primitives_gen::{self as pg, Group};
18use crate::print::{Printer, Selector};
19use crate::save::SaveStack;
20use crate::scan::ValLevel;
21use crate::token::Token;
22
23/// Well-known control sequences.
24#[derive(Clone, Copy, Debug, Default)]
25pub struct KnownCs {
26    /// `\par`.
27    pub par: CsId,
28    /// `\inaccessible` (`frozen_protection`).
29    pub frozen_protection: CsId,
30    /// Frozen `\cr`.
31    pub frozen_cr: CsId,
32    /// Frozen `\endgroup`.
33    pub frozen_end_group: CsId,
34    /// Frozen `\right`.
35    pub frozen_right: CsId,
36    /// Frozen `\fi`.
37    pub frozen_fi: CsId,
38    /// Frozen `\endtemplate` (`end_template`).
39    pub frozen_end_template: CsId,
40    /// Frozen `\endtemplate` (`endv`).
41    pub frozen_endv: CsId,
42    /// Frozen `\relax`.
43    pub frozen_relax: CsId,
44    /// `\endwrite`.
45    pub end_write: CsId,
46    /// `\notexpanded:` (`frozen_dont_expand`).
47    pub frozen_dont_expand: CsId,
48    /// Frozen `\special`.
49    pub frozen_special: CsId,
50    /// Frozen `\nullfont`.
51    pub frozen_null_font: CsId,
52    /// The marker ending a local control loop.
53    pub end_local: CsId,
54}
55
56/// Options for a run.
57#[derive(Clone, Debug)]
58pub struct Options {
59    /// IniTeX mode.
60    pub ini: bool,
61    /// Initial interaction mode.
62    pub interaction: Interaction,
63    /// `-jobname`.
64    pub job_name: Option<String>,
65    /// `-output-directory`.
66    pub output_dir: Option<std::path::PathBuf>,
67    /// The first line: the remaining command-line arguments joined.
68    pub first_line: String,
69    /// `-file-line-error`.
70    pub file_line_error: bool,
71    /// Seconds since the epoch for `\time`, `\day`, … (from
72    /// `SOURCE_DATE_EPOCH` or the clock).
73    pub epoch: Option<i64>,
74    /// `-utc`: use UTC instead of local time for the job's date.
75    pub utc: bool,
76    /// Shell escape: 0 disabled, 1 enabled, 2 restricted to the commands
77    /// in `shell_escape_commands` (LuaTeX's `status.shell_escape`).
78    pub shell_escape: u8,
79    /// `max_print_line`, `error_line`, `half_error_line`.
80    pub line_lengths: (usize, usize, usize),
81    /// `-halt-on-error`.
82    pub halt_on_error: bool,
83    /// `-output-comment`: the DVI preamble comment (else LuaTeX's dated
84    /// one).
85    pub output_comment: Option<String>,
86    /// Initial values of integer parameters, set after the format is
87    /// loaded (`-output-format` sets `\outputmode`, `-draftmode`
88    /// `\draftmode`, `-synctex` `\synctex`).
89    pub int_params: Vec<(u32, i32)>,
90}
91
92impl Default for Options {
93    fn default() -> Self {
94        Options {
95            ini: true,
96            interaction: Interaction::ErrorStop,
97            job_name: None,
98            output_dir: None,
99            first_line: String::new(),
100            file_line_error: false,
101            epoch: None,
102            utc: false,
103            shell_escape: 2,
104            line_lengths: (79, 79, 50),
105            halt_on_error: false,
106            output_comment: None,
107            int_params: Vec::new(),
108        }
109    }
110}
111
112/// Outcome of a run.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum Outcome {
115    /// `\end` was reached.
116    Ended,
117    /// `\dump` was reached in IniTeX mode.
118    Dumped,
119    /// The run was aborted by a fatal error.
120    Aborted,
121}
122
123impl Engine {
124    /// A fresh IniTeX engine writing to `term`.
125    pub fn new(options: &Options, term: Box<dyn std::io::Write>) -> Engine {
126        let mut printer = Printer::new(term);
127        printer.max_print_line = options.line_lengths.0;
128        printer.error_line = options.line_lengths.1;
129        printer.half_error_line = options.line_lengths.2;
130        let mut e = Engine {
131            hash: CsTable::new(),
132            eqtb: Eqtb::initex(),
133            save: SaveStack::default(),
134            input: InputState::default(),
135            printer,
136            err: ErrorState::default(),
137            cond: CondState::default(),
138            files: FileState::default(),
139            kpse: None,
140            lua: None,
141            cur_cmd: Cmd::Relax,
142            cur_chr: 0,
143            cur_cs: NO_CS,
144            cur_tok: Token(0),
145            cur_val: 0,
146            cur_glue: crate::glue::Glue::ZERO,
147            cur_toks: None,
148            cur_val_level: ValLevel::Int,
149            cs: KnownCs::default(),
150            ini_version: options.ini,
151            shell_escape: options.shell_escape,
152            format_ident: String::new(),
153            format_name: String::new(),
154            math_124: false,
155            math_op_nucleus: false,
156            job_name: options.job_name.clone().map(String::into_bytes),
157            after_token: Token(0),
158            expand_depth: 0,
159            mode: Mode::Vertical,
160            shown_mode: Mode::None,
161            nodes: Nodes::new(),
162            nest: Vec::new(),
163            fonts: FontTable::default(),
164            long_state: Cmd::Call,
165            def_ref_cs: NO_CS,
166            runaway_list: Vec::new(),
167            last_cs_name: NO_CS,
168            frozen_prims: HashMap::new(),
169            marks: crate::typeset::Marks::default(),
170            input_read_finished: false,
171            long_help_seen: false,
172            dead_cycles: 0,
173            insert_penalties: 0,
174            last_badness: 0,
175            single_mac_param: false,
176            pack_dir: -1,
177            random_seed: 0,
178            randoms: retex_arith::random::Randoms::new(0),
179            last_x_pos: 0,
180            last_y_pos: 0,
181            mag_set: 0,
182            font_dimen_loc: None,
183            depth_threshold: 0,
184            breadth_max: 0,
185            par_token_cs: NO_CS,
186            dump_requested: false,
187            exec_context: false,
188            one_step: false,
189            origins: None,
190            profiler: None,
191            start_ints: options.int_params.clone(),
192            output_comment: options.output_comment.clone(),
193            start_time: crate::clock::StartTime::default(),
194            page: crate::typeset::PageState::default(),
195            lua_functions: HashMap::new(),
196            format_hook: None,
197            finish_hook: None,
198            deferred_writes: Vec::new(),
199            text_dirs: Vec::new(),
200            outer_text_dirs: Vec::new(),
201            outer_math_dirs: Vec::new(),
202            pending_forms: Vec::new(),
203            vf_frames: Vec::new(),
204            form_objects: Vec::new(),
205            written_sink: crate::files::WrittenSink::default(),
206            math_glues: Vec::new(),
207            math_params_initialized: false,
208            synctex: Default::default(),
209            ts: crate::typeset::TsState::default(),
210            total_pages: 0,
211            colorstack_values: HashMap::new(),
212            pdf: crate::pdfext::PdfState::default(),
213            languages: Vec::new(),
214            backend: None,
215            dvi: Default::default(),
216            local_level: 0,
217            lua_scanned_box: None,
218            lua_scan_waiting: false,
219            backend_cs: HashMap::new(),
220            pending_lua_tokens: None,
221            diagnostics: None,
222            prompt_hook: None,
223            progress_hook: None,
224            diagnostics_shown: 0,
225            prompted: false,
226            output_written: None,
227            arg_scratch: Vec::new(),
228            param_scratch: Vec::new(),
229            write_paths: Vec::new(),
230            snapshot_cs: None,
231            snapshot_hook: None,
232            page_hook: None,
233            final_hook: None,
234            begun_level: None,
235            fp_cache: None,
236            edit_request: None,
237            images: Vec::new(),
238            last_image_index: 0,
239            last_image_pages: 0,
240            svg_fonts_set: false,
241            box_resources: Vec::new(),
242            last_box_index: 0,
243            input_line_for_lua: 0,
244        };
245        e.err.interaction = options.interaction;
246        e.err.file_line_error = options.file_line_error;
247        e.err.halt_on_error = options.halt_on_error;
248        e.files.output_dir = options.output_dir.clone();
249        e.files.command_line = options.first_line.clone().into_bytes();
250        e.init_params(options.epoch, options.utc);
251        e.init_known_cs();
252        e.fonts.fonts.push(FontInfo::null());
253        e.fonts.id_cs.push(e.cs.frozen_null_font);
254        e.push_nest(Mode::Vertical);
255        e.page.init(&mut e.nodes);
256        // The contribution list is the list of the outer vertical mode.
257        let ch = e.nest[0].head;
258        e.nodes.free(e.page.contrib_head);
259        e.page.contrib_head = ch;
260        e.ts.font_in_short_display = -1;
261        if options.ini {
262            e.install_group(Group::Tex, b"");
263            e.install_group(Group::Core, b"");
264        }
265        e.err.history = crate::error::History::Spotless;
266        e
267    }
268
269    /// Sets IniTeX parameter defaults from LuaTeX's recorded values.
270    fn init_params(&mut self, epoch: Option<i64>, utc: bool) {
271        let data = include_str!("../data/luatex-initex-params.txt");
272        for line in data.lines() {
273            let mut it = line.splitn(3, ' ');
274            let (Some(cmd), Some(name), Some(value)) = (it.next(), it.next(), it.next()) else {
275                continue;
276            };
277            let Some(p) = crate::primitives::lookup(name.as_bytes()) else {
278                continue;
279            };
280            match cmd {
281                "assign_int" => {
282                    if let Ok(v) = value.parse::<i32>() {
283                        self.eqtb.int_par[p.chr as usize].val = v;
284                    }
285                }
286                "assign_dimen" => {
287                    if let Ok(v) = value.parse::<i32>() {
288                        self.eqtb.dimen_par[p.chr as usize].val = v;
289                    }
290                }
291                _ => {}
292            }
293        }
294        // Date and time (local, or UTC with `-utc`).
295        self.start_time = crate::clock::StartTime::new(epoch, utc);
296        self.set_date_and_time();
297        self.random_seed = crate::clock::startup_random_seed();
298        self.randoms.init(self.random_seed);
299    }
300
301    /// Sets `\time`, `\day`, `\month` and `\year` to the job's start
302    /// (`fix_date_and_time`), also after a format brought its own values.
303    fn set_date_and_time(&mut self) {
304        let c = self.start_time.civil();
305        use pg::assign_int as ip;
306        self.eqtb.int_par[ip::TIME as usize].val = (c.hour * 60 + c.minute) as i32;
307        self.eqtb.int_par[ip::DAY as usize].val = c.day as i32;
308        self.eqtb.int_par[ip::MONTH as usize].val = c.month as i32;
309        self.eqtb.int_par[ip::YEAR as usize].val = c.year as i32;
310    }
311
312    fn init_known_cs(&mut self) {
313        let h = &mut self.hash;
314        let par = h.lookup_or_insert(b"par");
315        let frozen = |h: &mut CsTable, n: &str| h.new_frozen(n.as_bytes());
316        let cs = KnownCs {
317            par,
318            frozen_protection: frozen(h, "inaccessible"),
319            frozen_cr: frozen(h, "cr"),
320            frozen_end_group: frozen(h, "endgroup"),
321            frozen_right: frozen(h, "right"),
322            frozen_fi: frozen(h, "fi"),
323            frozen_end_template: frozen(h, "endtemplate"),
324            frozen_endv: frozen(h, "endtemplate"),
325            frozen_relax: frozen(h, "relax"),
326            end_write: frozen(h, "endwrite"),
327            frozen_dont_expand: frozen(h, "notexpanded:"),
328            frozen_special: frozen(h, "special"),
329            frozen_null_font: frozen(h, "nullfont"),
330            end_local: frozen(h, "endlocalcontrol"),
331        };
332        self.cs = cs;
333        let set = |e: &mut Engine, id: CsId, m: Meaning| {
334            e.eqtb.ensure_cs(id);
335            e.eqtb.cs[id.0 as usize].val = m;
336            e.eqtb.cs[id.0 as usize].level = crate::eqtb::LEVEL_ONE;
337        };
338        let prim = |name: &str| {
339            let p = crate::primitives::lookup(name.as_bytes()).expect("primitive");
340            Meaning::prim(p.cmd, p.chr)
341        };
342        set(self, cs.frozen_cr, prim("cr"));
343        set(self, cs.frozen_end_group, prim("endgroup"));
344        // Observed: the `\right` that LuaTeX inserts for a missing one
345        // acts as `\Umiddle`, so the formula stays open (`$\left($$`
346        // repeats the error until there are 100).
347        set(self, cs.frozen_right, prim("Umiddle"));
348        set(self, cs.frozen_fi, prim("fi"));
349        set(self, cs.frozen_relax, prim("relax"));
350        set(self, cs.frozen_special, prim("special"));
351        set(self, cs.frozen_null_font, Meaning::prim(Cmd::SetFont, 0));
352        set(
353            self,
354            cs.frozen_end_template,
355            Meaning::prim(Cmd::EndTemplate, 0),
356        );
357        set(self, cs.frozen_endv, Meaning::prim(Cmd::Endv, 0));
358        set(
359            self,
360            cs.frozen_dont_expand,
361            Meaning::prim(Cmd::DontExpand, 0),
362        );
363        set(self, cs.end_write, Meaning::prim(Cmd::OuterCall, 0));
364        set(self, cs.frozen_protection, Meaning::UNDEFINED);
365        set(
366            self,
367            cs.end_local,
368            Meaning::prim(Cmd::Extension, crate::misc::ext::END_LOCAL_CONTROL),
369        );
370        self.par_token_cs = par;
371    }
372
373    /// Defines all primitives of `group` with the given name prefix.
374    pub fn install_group(&mut self, group: Group, prefix: &[u8]) {
375        let prims: Vec<_> = crate::primitives::group(group).collect();
376        for p in prims {
377            self.define_primitive(prefix, p.name.as_bytes());
378        }
379    }
380
381    /// Defines primitive `name` as `prefix name`, unless that control
382    /// sequence already has a meaning (`tex.enableprimitives`). Returns
383    /// whether it was defined.
384    pub fn define_primitive(&mut self, prefix: &[u8], name: &[u8]) -> bool {
385        let Some(p) = crate::primitives::lookup(name) else {
386            return false;
387        };
388        if p.cmd == Cmd::UndefinedCs {
389            return false;
390        }
391        let mut full = prefix.to_vec();
392        full.extend_from_slice(name);
393        let cs = self.hash.lookup_or_insert(&full);
394        if self.eqtb.meaning(cs).cmd != Cmd::UndefinedCs {
395            return false;
396        }
397        self.eqtb.ensure_cs(cs);
398        self.eqtb.cs[cs.0 as usize].val = Meaning::prim(p.cmd, p.chr);
399        self.eqtb.cs[cs.0 as usize].level = crate::eqtb::LEVEL_ONE;
400        true
401    }
402
403    /// Runs a complete job: reads the first line, executes until `\end`
404    /// or `\dump`, and cleans up.
405    pub fn run(&mut self) -> Outcome {
406        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
407            self.run_inner();
408        }));
409        match result {
410            Ok(()) => {
411                if self.dump_requested {
412                    Outcome::Dumped
413                } else {
414                    Outcome::Ended
415                }
416            }
417            Err(payload) => {
418                if payload.downcast_ref::<JumpOut>().is_some() {
419                    Outcome::Aborted
420                } else {
421                    std::panic::resume_unwind(payload);
422                }
423            }
424        }
425    }
426
427    fn run_inner(&mut self) {
428        for (i, v) in std::mem::take(&mut self.start_ints) {
429            self.eqtb.set(
430                crate::eqtb::Loc::IntPar(i),
431                crate::eqtb::Val::Int(i64::from(v)),
432                crate::eqtb::LEVEL_ONE,
433            );
434            if i == crate::primitives_gen::assign_int::SYNCTEX {
435                self.synctex.option = Some(v);
436            }
437        }
438        self.synctex_changed();
439        let banner = format!(
440            "This is retex, Version {} (LuaTeX 1.22.0 compatible){}",
441            env!("CARGO_PKG_VERSION"),
442            if self.ini_version { "  (INITEX)" } else { "" }
443        );
444        self.printer.term_raw(banner.as_bytes());
445        self.printer.term_raw(b"\n");
446        let shell = self.shell_escape_banner();
447        if !shell.is_empty() {
448            self.printer.term_raw(shell.as_bytes());
449            self.printer.term_raw(b"\n");
450        }
451        self.printer.term_offset = 0;
452        self.printer.update_terminal();
453        // The first line goes into a terminal level.
454        let first = self.files.command_line.clone();
455        self.begin_file_reading(LineSource::Terminal, Vec::new(), false);
456        if let Some(crate::input::Level::Text(t)) = self.input.stack.last_mut() {
457            t.buf = crate::utf8x::chars(&first);
458            t.loc = 0;
459            t.state = crate::input::LexState::NewLine;
460        }
461        let elc = self.eqtb.int(pg::assign_int::ENDLINECHAR);
462        if (0..=0x10_FFFF).contains(&elc)
463            && let Some(crate::input::Level::Text(t)) = self.input.stack.last_mut()
464        {
465            t.buf.push(elc as u32);
466        }
467        self.printer.new_line_char = self.eqtb.int(pg::assign_int::NEWLINECHAR);
468        self.printer.selector = if self.err.interaction == Interaction::Batch {
469            Selector::NoPrint
470        } else {
471            Selector::TermOnly
472        };
473        let starts_with_escape = first
474            .first()
475            .is_some_and(|&b| self.eqtb.catcode(u32::from(b)) == 0);
476        if !first.is_empty() && !starts_with_escape {
477            self.start_input();
478        }
479        self.history_spotless();
480        self.main_control();
481        self.final_cleanup();
482        if self.dump_requested {
483            self.store_fmt_file();
484        }
485        self.close_files_and_terminate();
486    }
487
488    fn history_spotless(&mut self) {
489        if self.err.history == crate::error::History::FatalErrorStop {
490            self.err.history = crate::error::History::Spotless;
491        }
492    }
493
494    /// Writes the format file (`store_fmt_file`, §1302).
495    pub fn store_fmt_file(&mut self) {
496        if self.save.cur_level > crate::eqtb::LEVEL_ONE {
497            self.print_err("You can't dump inside a group");
498            self.help(&["`{...\\dump}' is a no-no."]);
499            self.fatal_error("*** (cannot \\dump inside a group)");
500        }
501        // The pre_dump callback runs first; LaTeX uses it to precompile
502        // data (e.g. Unicode tables) into lua.bytecode.
503        if let Some(h) = self.lua.clone()
504            && let Err(msg) = h.call_simple(self, "pre_dump")
505        {
506            self.lua_error(&msg);
507        }
508        let job =
509            String::from_utf8_lossy(self.job_name.as_deref().unwrap_or(b"texput")).into_owned();
510        let c = self.start_time.civil();
511        self.format_ident = format!(" (format={job} {}.{}.{})", c.year, c.month, c.day);
512        self.format_name = job.clone();
513        let mut path = std::path::PathBuf::from(format!("{job}.fmt"));
514        if let Some(dir) = &self.files.output_dir {
515            path = dir.join(path);
516        }
517        self.printer.selector = if self.err.interaction == Interaction::Batch {
518            Selector::LogOnly
519        } else {
520            Selector::TermAndLog
521        };
522        self.printer.print_nl("Beginning to dump on file ");
523        self.printer.print_bytes(path.to_string_lossy().as_bytes());
524        self.printer.print_nl("");
525        let ident = self.format_ident.clone();
526        self.printer.print_str(&ident);
527        let engine_section = self.dump_engine();
528        let lua_section = match self.lua.clone() {
529            Some(h) => h.dump(self),
530            None => Vec::new(),
531        };
532        if crate::timing::enabled() {
533            eprintln!(
534                "[format] {:>10} bytes  Lua (lua.bytecode)",
535                lua_section.len()
536            );
537        }
538        let origins = self.dump_origins();
539        let mut sections: Vec<(&str, &[u8])> =
540            vec![("engine", &engine_section), ("lua", &lua_section)];
541        if let Some(o) = &origins {
542            sections.push(("origins", o));
543        }
544        let data = crate::format::assemble(&sections);
545        self.printer.print_nl("");
546        self.printer.print_int(self.hash.len() as i64);
547        self.printer.print_str(" multiletter control sequences");
548        self.printer.print_nl("");
549        self.printer.print_int(self.fonts.fonts.len() as i64 - 1);
550        self.printer.print_str(" preloaded fonts");
551        if let Err(e) = std::fs::write(&path, &data) {
552            self.print_err("I can't write on file `");
553            self.printer.print_bytes(path.to_string_lossy().as_bytes());
554            self.printer.print_str("'.");
555            self.help_owned(vec![e.to_string()]);
556            self.error();
557        }
558    }
559
560    /// Loads a format file into a freshly created (non-IniTeX) engine.
561    pub fn load_format(&mut self, data: &[u8]) -> Result<(), crate::format::FormatError> {
562        let sections = crate::format::disassemble(data)?;
563        crate::timing::mark("format: sections checked");
564        for (name, bytes) in &sections {
565            if *name == "engine" {
566                self.undump_engine(bytes)?;
567            }
568        }
569        // The date of the job, not of the format.
570        self.set_date_and_time();
571        // Formats written by earlier versions have a frozen `\right` that
572        // acts as `\right`.
573        let p = crate::primitives::lookup(b"Umiddle").expect("primitive");
574        let id = self.cs.frozen_right;
575        self.eqtb.ensure_cs(id);
576        self.eqtb.cs[id.0 as usize].val = Meaning::prim(p.cmd, p.chr);
577        crate::timing::mark("format: engine state loaded");
578        if let Some(h) = self.lua.clone() {
579            for (name, bytes) in &sections {
580                if *name == "lua" {
581                    h.undump(self, bytes)
582                        .map_err(|_| crate::format::FormatError::Corrupt("lua section"))?;
583                }
584            }
585        }
586        crate::timing::mark("format: Lua state loaded");
587        self.format_name = self
588            .format_ident
589            .trim_start_matches(" (format=")
590            .split(' ')
591            .next()
592            .unwrap_or("")
593            .to_owned();
594        self.math_124 = self
595            .latex_release()
596            .is_some_and(|d| d.as_slice() >= b"2026-06-01".as_slice());
597        for (name, bytes) in &sections {
598            if *name == "origins" {
599                self.undump_origins(bytes)?;
600            }
601        }
602        Ok(())
603    }
604
605    /// The release of a LaTeX format (`\fmtversion` when `\fmtname` is
606    /// `LaTeX2e`), as `YYYY-MM-DD` (or `YYYY/MM/DD`, normalized).
607    ///
608    /// LuaTeX 1.24 (TeX Live 2026) typesets math differently from 1.22
609    /// (TeX Live 2025): the italic correction of a character nucleus is
610    /// always added ([`Engine::math_124`]). A format records no engine
611    /// version, but its LaTeX release tells which TeX Live, hence which
612    /// LuaTeX, it belongs to: LaTeX 2026-06 came with TeX Live 2026.
613    pub fn latex_release(&self) -> Option<Vec<u8>> {
614        let body = |name: &[u8]| -> Option<Vec<u8>> {
615            let cs = self.hash.lookup(name)?;
616            let mac = self.eqtb.meaning(cs).mac.as_ref()?;
617            let tokens = mac.tokens.strip_prefix(&[crate::token::Token::END_MATCH])?;
618            tokens
619                .iter()
620                .map(|t| (!t.is_cs() && t.chr() < 0x80).then_some(t.chr() as u8))
621                .collect()
622        };
623        if body(b"fmtname")? != b"LaTeX2e" {
624            return None;
625        }
626        let v = body(b"fmtversion")?;
627        let v: Vec<u8> = v
628            .iter()
629            .map(|&c| if c == b'/' { b'-' } else { c })
630            .collect();
631        (v.len() == 10).then_some(v)
632    }
633
634    /// Defines a macro from a string (for tests and bootstrapping).
635    pub fn define_macro_str(&mut self, name: &str, body: &str) {
636        let cs = self.hash.lookup_or_insert(name.as_bytes());
637        let mut toks = vec![Token::END_MATCH];
638        toks.extend(crate::the::str_toks(body.as_bytes()));
639        self.eqtb.ensure_cs(cs);
640        self.eqtb.cs[cs.0 as usize].val = Meaning {
641            cmd: Cmd::Call,
642            chr: 0,
643            mac: Some(Rc::new(crate::token::Macro::new(false, Rc::from(toks)))),
644        };
645    }
646}