Skip to main content

visi_core/core/vba/
mod.rs

1#[doc(hidden)]
2pub mod ast;
3pub(crate) mod builtin_names;
4#[doc(hidden)]
5pub mod builtins;
6pub(crate) mod color;
7#[doc(hidden)]
8pub mod host;
9#[doc(hidden)]
10pub mod interp;
11#[doc(hidden)]
12pub mod lexer;
13#[doc(hidden)]
14pub mod parser;
15pub(crate) mod resolve;
16#[doc(hidden)]
17pub mod value;
18
19use crate::{Error, ObjectKind};
20use serde::{Deserialize, Serialize};
21
22/// [`check_syntax`] result
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
24#[non_exhaustive]
25pub struct ModuleSyntax {
26    /// Every `Sub`, `Function` and `Property` in source order
27    pub procedures: Vec<String>,
28}
29
30/// Checks a VBA module's source for syntax errors.
31///
32/// ```
33/// use visi_core::core::check_syntax;
34/// assert!(check_syntax("Sub Hello()\n    MsgBox \"hi\"\nEnd Sub\n").is_ok());
35/// assert!(check_syntax("Sub Hello()\n").is_err());
36/// ```
37pub fn check_syntax(source: &str) -> Result<ModuleSyntax, Error> {
38    let empty = std::collections::HashSet::new();
39    check_source(source, None, &resolve::Scope::self_contained(&empty))
40}
41
42/// Check source that is part of a larger project
43/// where other modules may not be available.
44///
45/// ```
46/// use visi_core::core::{check_syntax, check_syntax_partial};
47/// // `DoWork` is declared by some other module of the project.
48/// let src = "Sub Caller()\n    DoWork 1\nEnd Sub\n";
49/// assert!(check_syntax(src).is_err());
50/// assert!(check_syntax_partial(src).is_ok());
51/// assert!(check_syntax_partial("Sub Caller()\n").is_err());
52/// ```
53pub fn check_syntax_partial(source: &str) -> Result<ModuleSyntax, Error> {
54    let empty = std::collections::HashSet::new();
55    check_source(source, None, &resolve::Scope::partial(&empty))
56}
57
58fn check_source(
59    source: &str,
60    module_name: Option<&str>,
61    scope: &resolve::Scope<'_>,
62) -> Result<ModuleSyntax, Error> {
63    let to_err = |e: parser::ParseError| Error::VbaSyntax {
64        message: e.message,
65        module: module_name.map(str::to_string),
66        line: e.pos.line,
67        column: e.pos.col,
68    };
69    let module = parser::parse_module(source).map_err(to_err)?;
70    resolve::check_module(&module, scope).map_err(to_err)?;
71    Ok(ModuleSyntax {
72        procedures: module.procedures().iter().map(|p| p.name.clone()).collect(),
73    })
74}
75
76/// The outcome of running a VBA procedure
77#[derive(Debug, Clone, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct RunOutcome {
80    /// `TypeName()`
81    pub type_name: String,
82    /// `CStr()`, or `None` where VBA itself cannot
83    /// stringify it (`Null`).
84    pub value: Option<String>,
85    /// Whether the run changed the workbook
86    pub mutated: bool,
87}
88
89fn parse_args(args: &[&str]) -> Vec<value::Variant> {
90    args.iter()
91        .map(|a| match value::parse_vba_number(a) {
92            Ok(n) if !a.trim().is_empty() => {
93                value::Variant::from_literal(n, a.contains('.') || a.contains(['e', 'E']))
94            }
95            _ => value::Variant::Str((*a).to_string()),
96        })
97        .collect()
98}
99
100fn to_outcome(result: value::Variant, mutated: bool, interp: &interp::Interpreter) -> RunOutcome {
101    RunOutcome {
102        type_name: interp.type_name_of(&result),
103        value: result.to_vba_string().ok(),
104        mutated,
105    }
106}
107
108fn parse_or_error(source: &str, module: Option<&str>) -> Result<ast::Module, Error> {
109    parser::parse_module(source).map_err(|e| Error::VbaSyntax {
110        message: e.message,
111        module: module.map(str::to_string),
112        line: e.pos.line,
113        column: e.pos.col,
114    })
115}
116
117fn to_runtime_error(e: value::VbaError) -> Error {
118    Error::VbaRuntime {
119        message: e.description,
120        number: e.number,
121    }
122}
123
124impl crate::core::WorkbookManager {
125    /// Runs one of this workbook's own VBA procedures
126    pub fn run_macro(
127        &mut self,
128        module: Option<&str>,
129        procedure: &str,
130        args: &[&str],
131    ) -> Result<RunOutcome, Error> {
132        let args = parse_args(args);
133
134        let interp = if let Some(project) = &self.vba_project {
135            if let Some(name) = module
136                && project.find_module(name).is_none()
137            {
138                let available = project.modules.iter().map(|m| m.name.clone()).collect();
139                return Err(Error::not_found_among(
140                    ObjectKind::VbaModule,
141                    name,
142                    available,
143                ));
144            }
145            interp::Interpreter::from_project(project, module).map_err(to_runtime_error)?
146        } else {
147            let source = self.macro_source_for(module, procedure)?;
148            let parsed = parse_or_error(&source, module)?;
149            interp::Interpreter::new(parsed)
150        };
151
152        let host = host::Host::new(self).map_err(to_runtime_error)?;
153        let mut interp = interp.with_host(host);
154
155        let result = interp.run(procedure, args);
156        interp.finish();
157        let mutated = interp.mutated();
158        let result = result.map_err(to_runtime_error)?;
159        Ok(to_outcome(result, mutated, &interp))
160    }
161
162    /// Runs startup macro events (`Workbook_Open` in `ThisWorkbook` then `Auto_Open` in standard modules).
163    pub fn run_open_events(&mut self) -> Result<RunOutcome, Error> {
164        let interp = if let Some(project) = &self.vba_project {
165            interp::Interpreter::from_project(project, None).map_err(to_runtime_error)?
166        } else {
167            return Err(Error::not_found(
168                ObjectKind::VbaModule,
169                "Workbook_Open or Auto_Open",
170            ));
171        };
172
173        let host = host::Host::new(self).map_err(to_runtime_error)?;
174        let mut interp = interp.with_host(host);
175
176        interp.run_open_events().map_err(to_runtime_error)?;
177        interp.finish();
178        let mutated = interp.mutated();
179        Ok(RunOutcome {
180            type_name: "Empty".to_string(),
181            value: Some(String::new()),
182            mutated,
183        })
184    }
185
186    fn macro_source_for(&self, module: Option<&str>, procedure: &str) -> Result<String, Error> {
187        let project = self
188            .vba_project
189            .as_ref()
190            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, module.unwrap_or(procedure)))?;
191        let available = || project.modules.iter().map(|m| m.name.clone()).collect();
192        if let Some(name) = module {
193            return project
194                .find_module(name)
195                .map(|m| m.source.clone())
196                .ok_or_else(|| Error::not_found_among(ObjectKind::VbaModule, name, available()));
197        }
198        project
199            .modules
200            .iter()
201            .find(|m| {
202                parser::parse_module(&m.source).is_ok_and(|module| {
203                    module
204                        .procedures()
205                        .iter()
206                        .any(|p| p.name.eq_ignore_ascii_case(procedure))
207                })
208            })
209            .map(|m| m.source.clone())
210            .ok_or_else(|| {
211                Error::not_found_among(
212                    ObjectKind::VbaModule,
213                    format!("a module declaring '{procedure}'"),
214                    available(),
215                )
216            })
217    }
218}
219
220/// Parses `source` and runs one of its procedures
221///
222/// ```
223/// use visi_core::core::run_macro;
224/// let src = "Function Add2(a, b)\n    Add2 = a + b\nEnd Function\n";
225/// let out = run_macro(src, "Add2", &["1", "2"]).unwrap();
226/// assert_eq!(out.type_name, "Integer");
227/// assert_eq!(out.value.as_deref(), Some("3"));
228/// ```
229pub fn run_macro(source: &str, procedure: &str, args: &[&str]) -> Result<RunOutcome, Error> {
230    let module = parser::parse_module(source).map_err(|e| Error::VbaSyntax {
231        message: e.message,
232        module: None,
233        line: e.pos.line,
234        column: e.pos.col,
235    })?;
236    let mut interp = interp::Interpreter::new(module);
237    let result = interp
238        .run(procedure, parse_args(args))
239        .map_err(to_runtime_error)?;
240
241    Ok(to_outcome(result, false, &interp))
242}
243
244impl VbaModule {
245    /// Checks this module's source, naming it in any error
246    pub fn check_syntax(&self) -> Result<ModuleSyntax, Error> {
247        let empty = std::collections::HashSet::new();
248        check_source(
249            &self.source,
250            Some(&self.name),
251            &resolve::Scope::partial(&empty),
252        )
253    }
254}
255
256/// What kind [`VbaModule`] is, which decides how it binds to
257/// the workbook.
258#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
259pub enum VbaModuleKind {
260    /// A `.bas`-equivalent module with no host object binding.
261    Standard,
262    /// A `.cls`-equivalent module).
263    /// TODO: fuzz this against real Excel
264    Class,
265    /// `ThisWorkbook` or a worksheet's code-behind module
266    Document,
267}
268
269/// A single VBA module's editable content plus the opaque bytes needed to
270/// keep Excel happy on export.
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
272pub struct VbaModule {
273    /// VB_Name -- must satisfy [`validate_vba_module_name`]
274    pub name: String,
275    /// What kind of module this is
276    pub kind: VbaModuleKind,
277    /// Plain VBA source text
278    pub source: String,
279    /// Required iff `kind == Document`
280    pub bound_sheet_id: Option<u64>,
281    /// Opaque bytes forming the pre-TextOffset "p-code prefix" of this
282    /// module's stream. Has nothing to do with the actual content of the
283    /// module
284    #[serde(default)]
285    pub prefix_bytes: Vec<u8>,
286    /// The module stream's MODULECOOKIE record (`0x002C`) value. MS-OVBA
287    /// documents this as implementation-specific and ignorable on read.
288    /// Preserved here so an imported module's original value survives re-export.
289    #[serde(default = "default_module_cookie")]
290    pub module_cookie: u16,
291    /// This module stream's already-compressed source, as read back
292    /// verbatim from an imported file. Is `None` (empty cache) for a freshly
293    /// created module.
294    #[serde(default)]
295    pub cached_compressed_source: Option<Vec<u8>>,
296}
297
298fn default_module_cookie() -> u16 {
299    0xFFFF
300}
301
302impl VbaModule {
303    /// Whether this is a document module -- `ThisWorkbook` or a worksheet's
304    /// code-behind -- as opposed to a standard or class module.
305    pub fn is_document(&self) -> bool {
306        self.kind == VbaModuleKind::Document
307    }
308}
309
310/// VBA project for a workbook
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312pub struct VbaProject {
313    /// Project ID GUID, e.g. `"{7B4E3A2C-1F5D-4A6B-9C8E-2D3F4A5B6C7D}"`.
314    pub project_id: String,
315    /// The project's modules, in no particular order
316    pub modules: Vec<VbaModule>,
317    /// The full original `vbaProject.bin` bytes this project was imported
318    /// from `vba_synth::synthetic_raw_donor()`'s from-scratch bytes -- export's
319    /// patch base. See `vba_xlsx.rs`.
320    #[serde(default)]
321    pub raw_donor: Vec<u8>,
322    /// P-code prefix bytes to donate to the first module
323    /// (weird vibe-coded workaround in order to create a valid module).
324    #[serde(default)]
325    pub seed_prefix_bytes: Vec<u8>,
326    /// Same donation scheme as `seed_prefix_bytes`
327    #[serde(default = "default_module_cookie")]
328    pub seed_module_cookie: u16,
329    /// See [`vba_xlsx::build_project_stream`] for why these must be preserved
330    /// rather than dropped.
331    #[serde(default)]
332    pub protection_lines: Option<String>,
333}
334
335impl VbaProject {
336    /// Create an empty project
337    pub fn new_empty() -> Self {
338        VbaProject {
339            project_id: new_project_guid(),
340            modules: Vec::new(),
341            raw_donor: crate::core::vba_synth::synthetic_raw_donor(),
342            seed_prefix_bytes: crate::core::vba_synth::synthetic_module_prefix(),
343            seed_module_cookie: default_module_cookie(),
344            protection_lines: None,
345        }
346    }
347
348    /// Finds a module by name, matched case-insensitively
349    pub fn find_module(&self, name: &str) -> Option<&VbaModule> {
350        self.modules
351            .iter()
352            .find(|m| m.name.eq_ignore_ascii_case(name))
353    }
354
355    /// [`VbaProject::find_module`], mutably.
356    pub fn find_module_mut(&mut self, name: &str) -> Option<&mut VbaModule> {
357        self.modules
358            .iter_mut()
359            .find(|m| m.name.eq_ignore_ascii_case(name))
360    }
361
362    /// Whether a module of this name already exists, matched
363    /// case-insensitively.
364    pub fn module_name_taken(&self, name: &str) -> bool {
365        self.find_module(name).is_some()
366    }
367
368    /// Checks every module, resolving names against the **whole project**
369    pub fn check_modules(&self) -> Vec<(String, Result<ModuleSyntax, Error>)> {
370        self.check_modules_scoped(true)
371    }
372
373    /// [`check_modules`](Self::check_modules) for a project that may contain
374    /// other modules
375    pub fn check_modules_partial(&self) -> Vec<(String, Result<ModuleSyntax, Error>)> {
376        self.check_modules_scoped(false)
377    }
378
379    fn check_modules_scoped(&self, complete: bool) -> Vec<(String, Result<ModuleSyntax, Error>)> {
380        let mut declared: std::collections::HashSet<String> = std::collections::HashSet::new();
381        let parsed: Vec<_> = self
382            .modules
383            .iter()
384            .map(|m| (m, parser::parse_module(&m.source).ok()))
385            .collect();
386        for (_, module) in &parsed {
387            if let Some(module) = module {
388                declared.extend(resolve::declared_names(module));
389            }
390        }
391
392        parsed
393            .iter()
394            .map(|(m, _)| {
395                let scope = resolve::Scope {
396                    external: &declared,
397                    complete_project: complete,
398                };
399                (
400                    m.name.clone(),
401                    check_source(&m.source, Some(&m.name), &scope),
402                )
403            })
404            .collect()
405    }
406}
407
408fn new_project_guid() -> String {
409    let hi = crate::core::engine::generate_unique_id();
410    let lo = crate::core::engine::generate_unique_id();
411    format!(
412        "{{{:08X}-{:04X}-{:04X}-{:04X}-{:012X}}}",
413        (hi >> 32) as u32,
414        (hi >> 16) as u16,
415        hi as u16,
416        (lo >> 48) as u16,
417        lo & 0xFFFF_FFFF_FFFF,
418    )
419}
420
421/// VBA identifiers: must start with a letter, contain only letters/digits/
422/// underscore, and be at most 31 characters (the real VBE module-name
423/// limit).
424pub fn validate_vba_module_name(name: &str) -> Result<(), String> {
425    let trimmed = name.trim();
426    if trimmed.is_empty() {
427        return Err("Module name cannot be empty".to_string());
428    }
429    if trimmed.chars().count() > 31 {
430        return Err(format!(
431            "Module name '{}' exceeds VBA's 31-character limit",
432            name
433        ));
434    }
435    let first = trimmed.chars().next().unwrap();
436    if !first.is_alphabetic() {
437        return Err(format!("Module name '{}' must start with a letter", name));
438    }
439    if !trimmed.chars().all(|c| c.is_alphanumeric() || c == '_') {
440        return Err(format!(
441            "Module name '{}' may only contain letters, digits, and underscores",
442            name
443        ));
444    }
445    Ok(())
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn sample_project() -> VbaProject {
453        VbaProject {
454            project_id: "{00000000-0000-0000-0000-000000000000}".to_string(),
455            modules: vec![
456                VbaModule {
457                    name: "ThisWorkbook".to_string(),
458                    kind: VbaModuleKind::Document,
459                    source: "Attribute VB_Name = \"ThisWorkbook\"\r\n".to_string(),
460                    bound_sheet_id: None,
461                    prefix_bytes: vec![0xAA; 16],
462                    module_cookie: 0xFFFF,
463                    cached_compressed_source: None,
464                },
465                VbaModule {
466                    name: "Module1".to_string(),
467                    kind: VbaModuleKind::Standard,
468                    source: "Attribute VB_Name = \"Module1\"\r\nSub Foo()\r\nEnd Sub\r\n"
469                        .to_string(),
470                    bound_sheet_id: None,
471                    prefix_bytes: vec![0xBB; 16],
472                    module_cookie: 0xFFFF,
473                    cached_compressed_source: None,
474                },
475            ],
476            raw_donor: Vec::new(),
477            seed_prefix_bytes: Vec::new(),
478            seed_module_cookie: 0xFFFF,
479            protection_lines: None,
480        }
481    }
482
483    #[test]
484    fn validate_name_rules() {
485        assert!(validate_vba_module_name("Module1").is_ok());
486        assert!(validate_vba_module_name("_Bad").is_err());
487        assert!(validate_vba_module_name("1Bad").is_err());
488        assert!(validate_vba_module_name("").is_err());
489        assert!(validate_vba_module_name("Has Space").is_err());
490        assert!(validate_vba_module_name("Has-Dash").is_err());
491        assert!(validate_vba_module_name(&"A".repeat(32)).is_err());
492        assert!(validate_vba_module_name(&"A".repeat(31)).is_ok());
493    }
494
495    #[test]
496    fn find_module_case_insensitive() {
497        let project = sample_project();
498        assert!(project.find_module("module1").is_some());
499        assert!(project.find_module("MODULE1").is_some());
500        assert!(project.find_module("Module2").is_none());
501    }
502
503    #[test]
504    fn module_name_taken_case_insensitive() {
505        let project = sample_project();
506        assert!(project.module_name_taken("module1"));
507        assert!(!project.module_name_taken("Module2"));
508    }
509
510    fn project_of(sources: &[(&str, &str)]) -> VbaProject {
511        let mut project = sample_project();
512        project.modules = sources
513            .iter()
514            .map(|(name, source)| VbaModule {
515                name: (*name).to_string(),
516                kind: VbaModuleKind::Standard,
517                source: (*source).to_string(),
518                bound_sheet_id: None,
519                prefix_bytes: vec![0xBB; 16],
520                module_cookie: 0xFFFF,
521                cached_compressed_source: None,
522            })
523            .collect();
524        project
525    }
526
527    const CALLER: &str = "Public Sub Caller()\n    DoWork 1\nEnd Sub\n";
528    const CALLEE: &str = "Public Sub DoWork(n As Long)\nEnd Sub\n";
529
530    #[test]
531    fn partial_scope_accepts_a_call_into_source_not_supplied() {
532        assert!(check_syntax(CALLER).is_err());
533        assert!(check_syntax_partial(CALLER).is_ok());
534
535        let dup = "Sub Test()\n    Dim x As Long\n    Dim x As Long\nEnd Sub\n";
536        assert!(check_syntax(dup).is_err());
537        assert!(check_syntax_partial(dup).is_err());
538    }
539
540    #[test]
541    fn check_modules_resolves_across_siblings() {
542        let project = project_of(&[("Module1", CALLER), ("Module2", CALLEE)]);
543        for (name, result) in project.check_modules() {
544            assert!(result.is_ok(), "{name} should be clean: {result:?}");
545        }
546
547        let alone = project_of(&[("Module1", CALLER)]);
548        let results = alone.check_modules();
549        assert_eq!(results.len(), 1);
550        match &results[0].1 {
551            Err(Error::VbaSyntax {
552                message, module, ..
553            }) => {
554                assert!(message.contains("DoWork"), "{message}");
555                assert_eq!(module.as_deref(), Some("Module1"));
556            }
557            other => panic!("expected a syntax error, got {other:?}"),
558        }
559
560        assert!(alone.check_modules_partial()[0].1.is_ok());
561    }
562
563    #[test]
564    fn set_source_leaves_prefix_bytes_untouched() {
565        let mut project = sample_project();
566        let original_prefix = project.find_module("Module1").unwrap().prefix_bytes.clone();
567        project.find_module_mut("Module1").unwrap().source =
568            "Attribute VB_Name = \"Module1\"\r\nSub Bar()\r\nEnd Sub\r\n".to_string();
569        assert_eq!(
570            project.find_module("Module1").unwrap().prefix_bytes,
571            original_prefix
572        );
573    }
574}