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}