Skip to main content

visi_core/core/vba/
mod.rs

1//! VBA macro project data model.
2//!
3//! A `VbaProject` is workbook-level (like `Chart`/`PivotTable`), not
4//! sheet-scoped like `ExcelTable`, since it's a single `vbaProject.bin` part
5//! per workbook holding potentially many modules, some of which (document
6//! modules) happen to bind to individual sheets.
7//!
8//! Unlike tables/pivots, round-tripping this through xlsx doesn't mean
9//! re-deriving every byte from these fields on export: `raw_donor` holds the
10//! `vbaProject.bin` bytes export (`vba_xlsx.rs`) patches only what changed
11//! into, rather than synthesizing a full CFB container from scratch every
12//! time. For a project imported from a real file, that's the file's own
13//! original bytes (preserving whatever PROJECTREFERENCES it already had --
14//! e.g. MSForms, Office -- which this codebase doesn't yet synthesize). For
15//! a brand-new project, `VbaProject::new_empty` builds `raw_donor` (and the
16//! per-module `prefix_bytes` new modules borrow) entirely synthetically via
17//! `vba_synth.rs`, with no real Excel-authored file involved. See
18//! `vba_xlsx.rs` and `vba_synth.rs` for why that used to require one, and
19//! the design notes in this crate's VBA feature plan for the full rationale
20//! (proven via a scratchpad proof-of-concept against real Excel).
21
22// The syntax layer. These are `#[doc(hidden)] pub` for the same reason
23// `ovba` and `vba_xlsx` are: `visi-core/fuzz`'s `vba_parse` target needs to
24// reach `parse_module` from outside the crate. The supported surface is
25// [`check_syntax`] and [`ModuleSyntax`] below, which is what `core`'s
26// `pub use` list carries -- the AST is an implementation detail until the
27// interpreter phases need it, and pinning its shape now would be a semver
28// commitment made a phase too early.
29#[doc(hidden)]
30pub mod ast;
31#[doc(hidden)]
32pub mod builtins;
33pub(crate) mod color;
34#[doc(hidden)]
35pub mod host;
36#[doc(hidden)]
37pub mod interp;
38#[doc(hidden)]
39pub mod lexer;
40#[doc(hidden)]
41pub mod parser;
42#[doc(hidden)]
43pub mod value;
44
45use crate::{Error, ObjectKind};
46use serde::{Deserialize, Serialize};
47
48/// What [`check_syntax`] found in a module that parsed.
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50#[non_exhaustive]
51pub struct ModuleSyntax {
52    /// The names of every `Sub`, `Function` and `Property` declared, in source
53    /// order. Procedures inside a `#If` branch are all included: which branch
54    /// is live depends on `#Const` values, which parsing alone cannot decide.
55    pub procedures: Vec<String>,
56}
57
58/// Checks a VBA module's source for syntax errors.
59///
60/// This is Phase 0 of the plan in `docs/vba-macro-support.md`: it answers
61/// whether the source *parses*, and nothing more. It does not resolve names,
62/// check types, or evaluate anything, so it will accept a module that fails
63/// at run time -- and, being an independent implementation, it may still
64/// differ from Excel's own compiler at the edges.
65///
66/// ```
67/// use visi_core::core::check_syntax;
68/// assert!(check_syntax("Sub Hello()\n    MsgBox \"hi\"\nEnd Sub\n").is_ok());
69/// assert!(check_syntax("Sub Hello()\n").is_err());
70/// ```
71pub fn check_syntax(source: &str) -> Result<ModuleSyntax, Error> {
72    let module = parser::parse_module(source).map_err(|e| Error::VbaSyntax {
73        message: e.message,
74        module: None,
75        line: e.pos.line,
76        column: e.pos.col,
77    })?;
78    Ok(ModuleSyntax {
79        procedures: module.procedures().iter().map(|p| p.name.clone()).collect(),
80    })
81}
82
83/// The outcome of running a VBA procedure: its return value, rendered the way
84/// VBA would render it, plus the subtype name `TypeName()` reports.
85///
86/// Both halves matter. An interpreter that computes the right number with the
87/// wrong subtype has a real bug -- `1 + 1` is an `Integer` and `1 / 1` is a
88/// `Double` -- so the differential fuzzer compares the type as well as the
89/// value.
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub struct RunOutcome {
93    /// `TypeName()` of the returned value.
94    pub type_name: String,
95    /// `CStr()` of the returned value, or `None` where VBA itself cannot
96    /// stringify it (`Null`).
97    pub value: Option<String>,
98    /// Whether the run changed the workbook.
99    ///
100    /// Always `false` from [`run_macro`], which has no workbook to change.
101    /// From [`crate::core::WorkbookManager::run_macro`] this is what tells a caller
102    /// whether it has something worth saving -- and, for the `visi` CLI,
103    /// whether discarding the result silently would be a data loss rather
104    /// than a no-op.
105    pub mutated: bool,
106}
107
108/// Turns command-line argument text into the `Variant`s a procedure receives.
109///
110/// Arguments arrive as text -- they come from a CLI or a fuzz harness -- and
111/// are given the type VBA would give the same literal, so `-a 1` is an
112/// `Integer` and `-a 1.5` a `Double`.
113fn parse_args(args: &[&str]) -> Vec<value::Variant> {
114    args.iter()
115        .map(|a| match value::parse_vba_number(a) {
116            Ok(n) if !a.trim().is_empty() => {
117                value::Variant::from_literal(n, a.contains('.') || a.contains(['e', 'E']))
118            }
119            _ => value::Variant::Str((*a).to_string()),
120        })
121        .collect()
122}
123
124fn to_outcome(result: value::Variant, mutated: bool) -> RunOutcome {
125    RunOutcome {
126        type_name: result.type_name().to_string(),
127        value: result.to_vba_string().ok(),
128        mutated,
129    }
130}
131
132fn parse_or_error(source: &str, module: Option<&str>) -> Result<ast::Module, Error> {
133    parser::parse_module(source).map_err(|e| Error::VbaSyntax {
134        message: e.message,
135        module: module.map(str::to_string),
136        line: e.pos.line,
137        column: e.pos.col,
138    })
139}
140
141fn to_runtime_error(e: value::VbaError) -> Error {
142    Error::VbaRuntime {
143        message: e.description,
144        number: e.number,
145    }
146}
147
148impl crate::core::WorkbookManager {
149    /// Runs one of this workbook's own VBA procedures **against** this
150    /// workbook.
151    ///
152    /// Phase 2 of `docs/vba-macro-support.md`, and the entry point that
153    /// separates it from Phase 1: the interpreter borrows the workbook for
154    /// the duration, so a macro can read and write cells, walk the sheets,
155    /// and call worksheet functions. [`run_macro`] stays as the text-only
156    /// form -- it is what `visi_core.run_macro` and `fuzz/fuzz_vba.py` drive,
157    /// and a macro that touches no workbook has no reason to need one.
158    ///
159    /// `module` picks which module to take the procedure from; `None`
160    /// searches every module for one that declares it, which is the common
161    /// single-module case. Resolving it here rather than in each caller is
162    /// deliberate: the CLI and the Python bindings would otherwise each have
163    /// their own copy of the rule, and only `fuzz/test_backend_parity.py`
164    /// would notice them drifting apart.
165    ///
166    /// **This executes code the workbook's author wrote.** Nothing calls it
167    /// implicitly -- not loading a file, not evaluating formulas, and not a
168    /// `Workbook_Open` handler. See the security posture in the feature plan.
169    ///
170    /// The workbook is left recalculated, so a caller that saves afterwards
171    /// writes the values the macro itself would have read.
172    pub fn run_macro(
173        &mut self,
174        module: Option<&str>,
175        procedure: &str,
176        args: &[&str],
177    ) -> Result<RunOutcome, Error> {
178        let source = self.macro_source_for(module, procedure)?;
179        let parsed = parse_or_error(&source, module)?;
180        let args = parse_args(args);
181
182        let host = host::Host::new(self).map_err(to_runtime_error)?;
183        let mut interp = interp::Interpreter::new(parsed).with_host(host);
184        let result = interp.run(procedure, args);
185        // The recalculation runs whether or not the procedure succeeded: a
186        // macro that wrote three cells and then raised has still written
187        // them, and leaving the workbook holding stale computed values would
188        // make the failure look like corruption.
189        interp.finish();
190        let mutated = interp.mutated();
191        let result = result.map_err(to_runtime_error)?;
192        Ok(to_outcome(result, mutated))
193    }
194
195    /// The source text to run, resolving `module` the way
196    /// [`WorkbookManager::run_macro`] documents.
197    fn macro_source_for(&self, module: Option<&str>, procedure: &str) -> Result<String, Error> {
198        let project = self
199            .vba_project
200            .as_ref()
201            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, module.unwrap_or(procedure)))?;
202        let available = || project.modules.iter().map(|m| m.name.clone()).collect();
203        if let Some(name) = module {
204            return project
205                .find_module(name)
206                .map(|m| m.source.clone())
207                .ok_or_else(|| Error::not_found_among(ObjectKind::VbaModule, name, available()));
208        }
209        project
210            .modules
211            .iter()
212            // A module that does not parse is skipped rather than fatal: it
213            // cannot be the one declaring the procedure, and reporting its
214            // syntax error here would blame the wrong module entirely.
215            .find(|m| {
216                m.check_syntax().is_ok_and(|s| {
217                    s.procedures
218                        .iter()
219                        .any(|p| p.eq_ignore_ascii_case(procedure))
220                })
221            })
222            .map(|m| m.source.clone())
223            .ok_or_else(|| {
224                Error::not_found_among(
225                    ObjectKind::VbaModule,
226                    format!("a module declaring '{procedure}'"),
227                    available(),
228                )
229            })
230    }
231}
232
233/// Parses `source` and runs one of its procedures.
234///
235/// Phase 1 of `docs/vba-macro-support.md`: expressions, control flow,
236/// `Sub`/`Function` calls and `On Error`. There is **no host object model**,
237/// so anything touching a workbook raises a run-time error naming what it
238/// was rather than silently doing nothing.
239///
240/// Execution is bounded -- a statement budget stops a runaway loop and a
241/// depth limit stops unbounded recursion -- because this runs source the
242/// caller did not necessarily write.
243///
244/// ```
245/// use visi_core::core::run_macro;
246/// let src = "Function Add2(a, b)\n    Add2 = a + b\nEnd Function\n";
247/// let out = run_macro(src, "Add2", &["1", "2"]).unwrap();
248/// assert_eq!(out.type_name, "Integer");
249/// assert_eq!(out.value.as_deref(), Some("3"));
250/// ```
251pub fn run_macro(source: &str, procedure: &str, args: &[&str]) -> Result<RunOutcome, Error> {
252    let module = parser::parse_module(source).map_err(|e| Error::VbaSyntax {
253        message: e.message,
254        module: None,
255        line: e.pos.line,
256        column: e.pos.col,
257    })?;
258    let result = interp::Interpreter::new(module)
259        .run(procedure, parse_args(args))
260        .map_err(to_runtime_error)?;
261
262    Ok(to_outcome(result, false))
263}
264
265impl VbaModule {
266    /// Checks this module's source, naming it in any error.
267    ///
268    /// The name matters more than it looks: a workbook can hold many modules
269    /// and `visi macro check` reports on all of them, so an error that does
270    /// not say which one it came from is close to useless.
271    pub fn check_syntax(&self) -> Result<ModuleSyntax, Error> {
272        check_syntax(&self.source).map_err(|e| match e {
273            Error::VbaSyntax {
274                message,
275                line,
276                column,
277                ..
278            } => Error::VbaSyntax {
279                message,
280                module: Some(self.name.clone()),
281                line,
282                column,
283            },
284            other => other,
285        })
286    }
287}
288
289/// What kind of VBA module a [`VbaModule`] is, which decides how it binds to
290/// the workbook.
291#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
292pub enum VbaModuleKind {
293    /// A `.bas`-equivalent module with no host object binding.
294    Standard,
295    /// A `.cls`-equivalent module (not validated end-to-end against real
296    /// Excel yet -- see the feature plan's open-risk notes).
297    Class,
298    /// `ThisWorkbook` or a worksheet's code-behind module. Must correspond
299    /// 1:1 with an existing sheet (or the workbook itself) via
300    /// `bound_sheet_id`, mirroring Excel's own codeName wiring.
301    Document,
302}
303
304/// A single VBA module's editable content plus the opaque bytes needed to
305/// keep Excel happy on export.
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
307pub struct VbaModule {
308    /// VB_Name -- must satisfy `validate_vba_module_name`.
309    pub name: String,
310    /// What kind of module this is, and so how it binds to the workbook.
311    pub kind: VbaModuleKind,
312    /// Plain VBA source text (no compression, no Attribute-line management
313    /// beyond what the caller writes -- callers are expected to include the
314    /// `Attribute VB_Name = "..."` line themselves, matching how real
315    /// Excel-authored module streams are shaped).
316    pub source: String,
317    /// Required iff `kind == Document`: the sheet this module's code
318    /// belongs to (or `None`/ignored for `ThisWorkbook`, which isn't tied to
319    /// a specific sheet). Kept as a stable id (not a name) so sheet renames
320    /// don't silently orphan the binding -- deliberately NOT cascaded the
321    /// other direction (renaming this module does not rename the sheet, and
322    /// vice versa; Excel allows the two names to diverge).
323    pub bound_sheet_id: Option<u64>,
324    /// Opaque bytes forming the pre-TextOffset "p-code prefix" of this
325    /// module's stream. Never reparsed or validated by this codebase --
326    /// proven (via the POC) that its *content* doesn't need to correspond
327    /// to this module's actual source, only its presence matters, as long
328    /// as it's shaped the way real Excel's module loader expects (a
329    /// naively zero-filled placeholder of the same length is NOT enough).
330    /// For an imported module these are the real bytes read back from the
331    /// original file; for a module created in this codebase they're
332    /// `vba_synth::synthetic_module_prefix()`'s from-scratch, self-consistent
333    /// zero-procedure cache -- see that module's doc comment.
334    #[serde(default)]
335    pub prefix_bytes: Vec<u8>,
336    /// The module stream's MODULECOOKIE record (`0x002C`) value. MS-OVBA
337    /// documents this as implementation-specific and ignorable on read, but
338    /// this codebase used to blindly overwrite every module's (including
339    /// untouched, imported ones') cookie with a hardcoded `0xFFFF` on every
340    /// export -- discovered while investigating why every workbook this
341    /// codebase produces failed `has vb project` in real Excel, by diffing
342    /// a re-exported real donor project's `dir` stream against the
343    /// original's record-by-record and finding this was the one place real
344    /// data was being discarded and replaced rather than round-tripped
345    /// verbatim. Preserved here instead so an imported module's original
346    /// value survives re-export.
347    #[serde(default = "default_module_cookie")]
348    pub module_cookie: u16,
349    /// This module stream's already-compressed source, as read back
350    /// verbatim from an imported file -- `None` for a module created fresh
351    /// in this session (nothing to cache yet). `set_vba_module_source`
352    /// clears this whenever `source` is replaced. Export reuses the cached
353    /// bytes instead of recompressing `source` from scratch for every
354    /// module untouched by the CRUD operation that triggered the save.
355    #[serde(default)]
356    pub cached_compressed_source: Option<Vec<u8>>,
357}
358
359fn default_module_cookie() -> u16 {
360    0xFFFF
361}
362
363impl VbaModule {
364    /// Whether this is a document module -- `ThisWorkbook` or a worksheet's
365    /// code-behind -- as opposed to a standard or class module.
366    pub fn is_document(&self) -> bool {
367        self.kind == VbaModuleKind::Document
368    }
369}
370
371/// A workbook's VBA project: its modules plus the raw material needed to
372/// patch (not rebuild from scratch) a `vbaProject.bin` on export.
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374pub struct VbaProject {
375    /// Project ID GUID, e.g. `"{7B4E3A2C-1F5D-4A6B-9C8E-2D3F4A5B6C7D}"`.
376    /// Must stay internally consistent with `protection_lines` -- never
377    /// mutated after import/creation, so it always is. If `CMG`/`DPB`/`GC`
378    /// protection-state lines are ever made independently settable, they
379    /// must correspond to this exact ID or Excel reports the whole project
380    /// "unviewable" (a real finding from the POC, not a hypothetical).
381    pub project_id: String,
382    /// The project's modules, in no particular order. Names are unique
383    /// case-insensitively.
384    pub modules: Vec<VbaModule>,
385    /// The full original `vbaProject.bin` bytes this project was imported
386    /// from, or (for a project created fresh in this session)
387    /// `vba_synth::synthetic_raw_donor()`'s from-scratch bytes -- export's
388    /// patch base. See `vba_xlsx.rs`.
389    #[serde(default)]
390    pub raw_donor: Vec<u8>,
391    /// P-code prefix bytes to donate to the first module ever added to a
392    /// project that started with none -- kept separate from `modules`
393    /// rather than as a phantom placeholder module, so it never shows up in
394    /// `list_vba_modules`/export. Once a project has at least one real
395    /// module, new modules instead borrow prefix bytes from an existing
396    /// one, and this field goes unused.
397    #[serde(default)]
398    pub seed_prefix_bytes: Vec<u8>,
399    /// `VbaModule::module_cookie` to donate to the first module ever added
400    /// to a project that started with none -- same donation scheme as
401    /// `seed_prefix_bytes`, see there for why.
402    #[serde(default = "default_module_cookie")]
403    pub seed_module_cookie: u16,
404    /// The donor's original `PROJECT` stream `CMG=`/`DPB=`/`GC=` lines
405    /// (joined with `\r\n`), reproduced verbatim on export -- `None` for a
406    /// project created fresh in this session, which never had any. See
407    /// `vba_xlsx::build_project_stream` for why these must be preserved
408    /// rather than dropped.
409    #[serde(default)]
410    pub protection_lines: Option<String>,
411}
412
413impl VbaProject {
414    /// A brand-new, empty VBA project with no real Excel-authored file
415    /// behind it anywhere -- `raw_donor` and `seed_prefix_bytes` are built
416    /// by `vba_synth` entirely from scratch. See `vba_synth`'s doc comment
417    /// for why that's now possible.
418    pub fn new_empty() -> Self {
419        VbaProject {
420            project_id: new_project_guid(),
421            modules: Vec::new(),
422            raw_donor: crate::core::vba_synth::synthetic_raw_donor(),
423            seed_prefix_bytes: crate::core::vba_synth::synthetic_module_prefix(),
424            seed_module_cookie: default_module_cookie(),
425            protection_lines: None,
426        }
427    }
428
429    /// Finds a module by name, matched case-insensitively as VBA does.
430    pub fn find_module(&self, name: &str) -> Option<&VbaModule> {
431        self.modules
432            .iter()
433            .find(|m| m.name.eq_ignore_ascii_case(name))
434    }
435
436    /// [`VbaProject::find_module`], mutably.
437    pub fn find_module_mut(&mut self, name: &str) -> Option<&mut VbaModule> {
438        self.modules
439            .iter_mut()
440            .find(|m| m.name.eq_ignore_ascii_case(name))
441    }
442
443    /// Whether a module of this name already exists, matched
444    /// case-insensitively.
445    pub fn module_name_taken(&self, name: &str) -> bool {
446        self.find_module(name).is_some()
447    }
448}
449
450/// A GUID-shaped project id (`{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}`) for
451/// a brand-new project, built from two `generate_unique_id()` draws rather
452/// than duplicating its getrandom/fallback logic.
453fn new_project_guid() -> String {
454    let hi = crate::core::engine::generate_unique_id();
455    let lo = crate::core::engine::generate_unique_id();
456    format!(
457        "{{{:08X}-{:04X}-{:04X}-{:04X}-{:012X}}}",
458        (hi >> 32) as u32,
459        (hi >> 16) as u16,
460        hi as u16,
461        (lo >> 48) as u16,
462        lo & 0xFFFF_FFFF_FFFF,
463    )
464}
465
466/// VBA identifiers: must start with a letter, contain only letters/digits/
467/// underscore, and be at most 31 characters (the real VBE module-name
468/// limit).
469pub fn validate_vba_module_name(name: &str) -> Result<(), String> {
470    let trimmed = name.trim();
471    if trimmed.is_empty() {
472        return Err("Module name cannot be empty".to_string());
473    }
474    if trimmed.chars().count() > 31 {
475        return Err(format!(
476            "Module name '{}' exceeds VBA's 31-character limit",
477            name
478        ));
479    }
480    let first = trimmed.chars().next().unwrap();
481    if !first.is_alphabetic() {
482        return Err(format!("Module name '{}' must start with a letter", name));
483    }
484    if !trimmed.chars().all(|c| c.is_alphanumeric() || c == '_') {
485        return Err(format!(
486            "Module name '{}' may only contain letters, digits, and underscores",
487            name
488        ));
489    }
490    Ok(())
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    fn sample_project() -> VbaProject {
498        VbaProject {
499            project_id: "{00000000-0000-0000-0000-000000000000}".to_string(),
500            modules: vec![
501                VbaModule {
502                    name: "ThisWorkbook".to_string(),
503                    kind: VbaModuleKind::Document,
504                    source: "Attribute VB_Name = \"ThisWorkbook\"\r\n".to_string(),
505                    bound_sheet_id: None,
506                    prefix_bytes: vec![0xAA; 16],
507                    module_cookie: 0xFFFF,
508                    cached_compressed_source: None,
509                },
510                VbaModule {
511                    name: "Module1".to_string(),
512                    kind: VbaModuleKind::Standard,
513                    source: "Attribute VB_Name = \"Module1\"\r\nSub Foo()\r\nEnd Sub\r\n"
514                        .to_string(),
515                    bound_sheet_id: None,
516                    prefix_bytes: vec![0xBB; 16],
517                    module_cookie: 0xFFFF,
518                    cached_compressed_source: None,
519                },
520            ],
521            raw_donor: Vec::new(),
522            seed_prefix_bytes: Vec::new(),
523            seed_module_cookie: 0xFFFF,
524            protection_lines: None,
525        }
526    }
527
528    #[test]
529    fn validate_name_rules() {
530        assert!(validate_vba_module_name("Module1").is_ok());
531        assert!(validate_vba_module_name("_Bad").is_err());
532        assert!(validate_vba_module_name("1Bad").is_err());
533        assert!(validate_vba_module_name("").is_err());
534        assert!(validate_vba_module_name("Has Space").is_err());
535        assert!(validate_vba_module_name("Has-Dash").is_err());
536        assert!(validate_vba_module_name(&"A".repeat(32)).is_err());
537        assert!(validate_vba_module_name(&"A".repeat(31)).is_ok());
538    }
539
540    #[test]
541    fn find_module_case_insensitive() {
542        let project = sample_project();
543        assert!(project.find_module("module1").is_some());
544        assert!(project.find_module("MODULE1").is_some());
545        assert!(project.find_module("Module2").is_none());
546    }
547
548    #[test]
549    fn module_name_taken_case_insensitive() {
550        let project = sample_project();
551        assert!(project.module_name_taken("module1"));
552        assert!(!project.module_name_taken("Module2"));
553    }
554
555    #[test]
556    fn set_source_leaves_prefix_bytes_untouched() {
557        let mut project = sample_project();
558        let original_prefix = project.find_module("Module1").unwrap().prefix_bytes.clone();
559        project.find_module_mut("Module1").unwrap().source =
560            "Attribute VB_Name = \"Module1\"\r\nSub Bar()\r\nEnd Sub\r\n".to_string();
561        assert_eq!(
562            project.find_module("Module1").unwrap().prefix_bytes,
563            original_prefix
564        );
565    }
566}