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/// What [`check_syntax`] found in a module that parsed.
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
24#[non_exhaustive]
25pub struct ModuleSyntax {
26    /// The names of every `Sub`, `Function` and `Property` declared, in source
27    /// order. Procedures inside a `#If` branch are all included: which branch
28    /// is live depends on `#Const` values, which parsing alone cannot decide.
29    pub procedures: Vec<String>,
30}
31
32/// Checks a VBA module's source for syntax errors.
33///
34/// Phase 0 of the plan in `docs/vba-macro-support.md`, plus the narrow
35/// name-resolution pass in [`resolve`]: it answers
36/// whether the source *compiles*, as far as parsing and resolving the names
37/// it can see will show. It does not check types or evaluate anything, so it
38/// will still accept a module that fails at run time -- and, being an
39/// independent implementation, may differ from Excel's compiler at the edges.
40///
41/// **`source` is treated as a self-contained project.** A name used with
42/// call syntax that resolves nowhere -- not in this module, not a VBA or
43/// Excel built-in -- is reported, which is right for a standalone `.bas` and
44/// for the single generated module the differential harness compiles, but
45/// would be wrong for one module of a larger project, where the name may
46/// live in a sibling. Use [`VbaProject::check_modules`] for that case -- it
47/// supplies each module the others' names -- or [`check_syntax_partial`]
48/// when the siblings are not available at all.
49///
50/// ```
51/// use visi_core::core::check_syntax;
52/// assert!(check_syntax("Sub Hello()\n    MsgBox \"hi\"\nEnd Sub\n").is_ok());
53/// assert!(check_syntax("Sub Hello()\n").is_err());
54/// ```
55pub fn check_syntax(source: &str) -> Result<ModuleSyntax, Error> {
56    let empty = std::collections::HashSet::new();
57    check_source(source, None, &resolve::Scope::self_contained(&empty))
58}
59
60/// [`check_syntax`] for source that is **one module of a larger project**
61/// whose other modules are not available.
62///
63/// Same parse and the same rules, with one exception: a name that resolves
64/// nowhere is accepted rather than reported, since a sibling module this
65/// call cannot see may well declare it. Everything the module's own text
66/// disproves -- a syntax error, a duplicate declaration, a plain local used
67/// as a call target -- is still reported.
68///
69/// This is strictly the weaker check, and is the scope
70/// [`VbaModule::check_syntax`] already uses. Prefer
71/// [`VbaProject::check_modules`] wherever the whole project is in hand;
72/// reach for this only when it genuinely is not, as for a `.bas` file cut
73/// out of a project that lives elsewhere.
74///
75/// ```
76/// use visi_core::core::{check_syntax, check_syntax_partial};
77/// // `DoWork` is declared by some other module of the project.
78/// let src = "Sub Caller()\n    DoWork 1\nEnd Sub\n";
79/// assert!(check_syntax(src).is_err());
80/// assert!(check_syntax_partial(src).is_ok());
81/// // A fragment is still held to what its own text shows.
82/// assert!(check_syntax_partial("Sub Caller()\n").is_err());
83/// ```
84pub fn check_syntax_partial(source: &str) -> Result<ModuleSyntax, Error> {
85    let empty = std::collections::HashSet::new();
86    check_source(source, None, &resolve::Scope::partial(&empty))
87}
88
89/// [`check_syntax`]'s body, with the resolution scope chosen by the caller.
90fn check_source(
91    source: &str,
92    module_name: Option<&str>,
93    scope: &resolve::Scope<'_>,
94) -> Result<ModuleSyntax, Error> {
95    let to_err = |e: parser::ParseError| Error::VbaSyntax {
96        message: e.message,
97        module: module_name.map(str::to_string),
98        line: e.pos.line,
99        column: e.pos.col,
100    };
101    let module = parser::parse_module(source).map_err(to_err)?;
102    resolve::check_module(&module, scope).map_err(to_err)?;
103    Ok(ModuleSyntax {
104        procedures: module.procedures().iter().map(|p| p.name.clone()).collect(),
105    })
106}
107
108/// The outcome of running a VBA procedure: its return value, rendered the way
109/// VBA would render it, plus the subtype name `TypeName()` reports.
110///
111/// Both halves matter. An interpreter that computes the right number with the
112/// wrong subtype has a real bug -- `1 + 1` is an `Integer` and `1 / 1` is a
113/// `Double` -- so the differential fuzzer compares the type as well as the
114/// value.
115#[derive(Debug, Clone, PartialEq, Eq)]
116#[non_exhaustive]
117pub struct RunOutcome {
118    /// `TypeName()` of the returned value.
119    pub type_name: String,
120    /// `CStr()` of the returned value, or `None` where VBA itself cannot
121    /// stringify it (`Null`).
122    pub value: Option<String>,
123    /// Whether the run changed the workbook.
124    ///
125    /// Always `false` from [`run_macro`], which has no workbook to change.
126    /// From [`crate::core::WorkbookManager::run_macro`] this is what tells a caller
127    /// whether it has something worth saving -- and, for the `visi` CLI,
128    /// whether discarding the result silently would be a data loss rather
129    /// than a no-op.
130    pub mutated: bool,
131}
132
133/// Turns command-line argument text into the `Variant`s a procedure receives.
134///
135/// Arguments arrive as text -- they come from a CLI or a fuzz harness -- and
136/// are given the type VBA would give the same literal, so `-a 1` is an
137/// `Integer` and `-a 1.5` a `Double`.
138fn parse_args(args: &[&str]) -> Vec<value::Variant> {
139    args.iter()
140        .map(|a| match value::parse_vba_number(a) {
141            Ok(n) if !a.trim().is_empty() => {
142                value::Variant::from_literal(n, a.contains('.') || a.contains(['e', 'E']))
143            }
144            _ => value::Variant::Str((*a).to_string()),
145        })
146        .collect()
147}
148
149fn to_outcome(result: value::Variant, mutated: bool, interp: &interp::Interpreter) -> RunOutcome {
150    RunOutcome {
151        type_name: interp.type_name_of(&result),
152        value: result.to_vba_string().ok(),
153        mutated,
154    }
155}
156
157fn parse_or_error(source: &str, module: Option<&str>) -> Result<ast::Module, Error> {
158    parser::parse_module(source).map_err(|e| Error::VbaSyntax {
159        message: e.message,
160        module: module.map(str::to_string),
161        line: e.pos.line,
162        column: e.pos.col,
163    })
164}
165
166fn to_runtime_error(e: value::VbaError) -> Error {
167    Error::VbaRuntime {
168        message: e.description,
169        number: e.number,
170    }
171}
172
173impl crate::core::WorkbookManager {
174    /// Runs one of this workbook's own VBA procedures **against** this
175    /// workbook.
176    ///
177    /// Phase 2 of `docs/vba-macro-support.md`, and the entry point that
178    /// separates it from Phase 1: the interpreter borrows the workbook for
179    /// the duration, so a macro can read and write cells, walk the sheets,
180    /// and call worksheet functions. [`run_macro`] stays as the text-only
181    /// form -- it is what `visi_core.run_macro` and `fuzz/fuzz_vba.py` drive,
182    /// and a macro that touches no workbook has no reason to need one.
183    ///
184    /// `module` picks which module to take the procedure from; `None`
185    /// searches every module for one that declares it, which is the common
186    /// single-module case. Resolving it here rather than in each caller is
187    /// Runs a VBA procedure in the workbook's project.
188    ///
189    /// The workbook is left recalculated, so a caller that saves afterwards
190    /// writes the values the macro itself would have read.
191    pub fn run_macro(
192        &mut self,
193        module: Option<&str>,
194        procedure: &str,
195        args: &[&str],
196    ) -> Result<RunOutcome, Error> {
197        let args = parse_args(args);
198
199        let interp = if let Some(project) = &self.vba_project {
200            if let Some(name) = module
201                && project.find_module(name).is_none()
202            {
203                let available = project.modules.iter().map(|m| m.name.clone()).collect();
204                return Err(Error::not_found_among(
205                    ObjectKind::VbaModule,
206                    name,
207                    available,
208                ));
209            }
210            interp::Interpreter::from_project(project, module).map_err(to_runtime_error)?
211        } else {
212            let source = self.macro_source_for(module, procedure)?;
213            let parsed = parse_or_error(&source, module)?;
214            interp::Interpreter::new(parsed)
215        };
216
217        let host = host::Host::new(self).map_err(to_runtime_error)?;
218        let mut interp = interp.with_host(host);
219
220        let result = interp.run(procedure, args);
221        interp.finish();
222        let mutated = interp.mutated();
223        let result = result.map_err(to_runtime_error)?;
224        Ok(to_outcome(result, mutated, &interp))
225    }
226
227    /// Runs startup macro events (`Workbook_Open` in `ThisWorkbook` then `Auto_Open` in standard modules).
228    pub fn run_open_events(&mut self) -> Result<RunOutcome, Error> {
229        let interp = if let Some(project) = &self.vba_project {
230            interp::Interpreter::from_project(project, None).map_err(to_runtime_error)?
231        } else {
232            return Err(Error::not_found(
233                ObjectKind::VbaModule,
234                "Workbook_Open or Auto_Open",
235            ));
236        };
237
238        let host = host::Host::new(self).map_err(to_runtime_error)?;
239        let mut interp = interp.with_host(host);
240
241        interp.run_open_events().map_err(to_runtime_error)?;
242        interp.finish();
243        let mutated = interp.mutated();
244        Ok(RunOutcome {
245            type_name: "Empty".to_string(),
246            value: Some(String::new()),
247            mutated,
248        })
249    }
250
251    /// The source text to run, resolving `module` the way
252    /// [`WorkbookManager::run_macro`] documents.
253    fn macro_source_for(&self, module: Option<&str>, procedure: &str) -> Result<String, Error> {
254        let project = self
255            .vba_project
256            .as_ref()
257            .ok_or_else(|| Error::not_found(ObjectKind::VbaModule, module.unwrap_or(procedure)))?;
258        let available = || project.modules.iter().map(|m| m.name.clone()).collect();
259        if let Some(name) = module {
260            return project
261                .find_module(name)
262                .map(|m| m.source.clone())
263                .ok_or_else(|| Error::not_found_among(ObjectKind::VbaModule, name, available()));
264        }
265        project
266            .modules
267            .iter()
268            .find(|m| {
269                parser::parse_module(&m.source).is_ok_and(|module| {
270                    module
271                        .procedures()
272                        .iter()
273                        .any(|p| p.name.eq_ignore_ascii_case(procedure))
274                })
275            })
276            .map(|m| m.source.clone())
277            .ok_or_else(|| {
278                Error::not_found_among(
279                    ObjectKind::VbaModule,
280                    format!("a module declaring '{procedure}'"),
281                    available(),
282                )
283            })
284    }
285}
286
287/// Parses `source` and runs one of its procedures.
288///
289/// Phase 1 of `docs/vba-macro-support.md`: expressions, control flow,
290/// `Sub`/`Function` calls and `On Error`. There is **no host object model**,
291/// so anything touching a workbook raises a run-time error naming what it
292/// was rather than silently doing nothing.
293///
294/// Execution is bounded -- a statement budget stops a runaway loop and a
295/// depth limit stops unbounded recursion -- because this runs source the
296/// caller did not necessarily write.
297///
298/// ```
299/// use visi_core::core::run_macro;
300/// let src = "Function Add2(a, b)\n    Add2 = a + b\nEnd Function\n";
301/// let out = run_macro(src, "Add2", &["1", "2"]).unwrap();
302/// assert_eq!(out.type_name, "Integer");
303/// assert_eq!(out.value.as_deref(), Some("3"));
304/// ```
305pub fn run_macro(source: &str, procedure: &str, args: &[&str]) -> Result<RunOutcome, Error> {
306    let module = parser::parse_module(source).map_err(|e| Error::VbaSyntax {
307        message: e.message,
308        module: None,
309        line: e.pos.line,
310        column: e.pos.col,
311    })?;
312    let mut interp = interp::Interpreter::new(module);
313    let result = interp
314        .run(procedure, parse_args(args))
315        .map_err(to_runtime_error)?;
316
317    Ok(to_outcome(result, false, &interp))
318}
319
320impl VbaModule {
321    /// Checks this module's source, naming it in any error.
322    ///
323    /// The name matters more than it looks: a workbook can hold many modules
324    /// and `visi macro check` reports on all of them, so an error that does
325    /// not say which one it came from is close to useless.
326    ///
327    /// A `VbaModule` does not know its project, so unlike the free
328    /// [`check_syntax`] this **cannot** conclude anything from a name it
329    /// fails to resolve -- a sibling module may well declare it. Reach for
330    /// [`VbaProject::check_modules`] when the project is available; it is
331    /// strictly the better check.
332    pub fn check_syntax(&self) -> Result<ModuleSyntax, Error> {
333        let empty = std::collections::HashSet::new();
334        check_source(
335            &self.source,
336            Some(&self.name),
337            &resolve::Scope::partial(&empty),
338        )
339    }
340}
341
342/// What kind of VBA module a [`VbaModule`] is, which decides how it binds to
343/// the workbook.
344#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
345pub enum VbaModuleKind {
346    /// A `.bas`-equivalent module with no host object binding.
347    Standard,
348    /// A `.cls`-equivalent module (not validated end-to-end against real
349    /// Excel yet -- see the feature plan's open-risk notes).
350    Class,
351    /// `ThisWorkbook` or a worksheet's code-behind module. Must correspond
352    /// 1:1 with an existing sheet (or the workbook itself) via
353    /// `bound_sheet_id`, mirroring Excel's own codeName wiring.
354    Document,
355}
356
357/// A single VBA module's editable content plus the opaque bytes needed to
358/// keep Excel happy on export.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360pub struct VbaModule {
361    /// VB_Name -- must satisfy `validate_vba_module_name`.
362    pub name: String,
363    /// What kind of module this is, and so how it binds to the workbook.
364    pub kind: VbaModuleKind,
365    /// Plain VBA source text (no compression, no Attribute-line management
366    /// beyond what the caller writes -- callers are expected to include the
367    /// `Attribute VB_Name = "..."` line themselves, matching how real
368    /// Excel-authored module streams are shaped).
369    pub source: String,
370    /// Required iff `kind == Document`: the sheet this module's code
371    /// belongs to (or `None`/ignored for `ThisWorkbook`, which isn't tied to
372    /// a specific sheet). Kept as a stable id (not a name) so sheet renames
373    /// don't silently orphan the binding -- deliberately NOT cascaded the
374    /// other direction (renaming this module does not rename the sheet, and
375    /// vice versa; Excel allows the two names to diverge).
376    pub bound_sheet_id: Option<u64>,
377    /// Opaque bytes forming the pre-TextOffset "p-code prefix" of this
378    /// module's stream. Never reparsed or validated by this codebase --
379    /// proven (via the POC) that its *content* doesn't need to correspond
380    /// to this module's actual source, only its presence matters, as long
381    /// as it's shaped the way real Excel's module loader expects (a
382    /// naively zero-filled placeholder of the same length is NOT enough).
383    /// For an imported module these are the real bytes read back from the
384    /// original file; for a module created in this codebase they're
385    /// `vba_synth::synthetic_module_prefix()`'s from-scratch, self-consistent
386    /// zero-procedure cache -- see that module's doc comment.
387    #[serde(default)]
388    pub prefix_bytes: Vec<u8>,
389    /// The module stream's MODULECOOKIE record (`0x002C`) value. MS-OVBA
390    /// documents this as implementation-specific and ignorable on read.
391    /// Preserved here so an imported module's original value survives re-export.
392    #[serde(default = "default_module_cookie")]
393    pub module_cookie: u16,
394    /// This module stream's already-compressed source, as read back
395    /// verbatim from an imported file -- `None` for a module created fresh
396    /// in this session (nothing to cache yet). `set_vba_module_source`
397    /// clears this whenever `source` is replaced. Export reuses the cached
398    /// bytes instead of recompressing `source` from scratch for every
399    /// module untouched by the CRUD operation that triggered the save.
400    #[serde(default)]
401    pub cached_compressed_source: Option<Vec<u8>>,
402}
403
404fn default_module_cookie() -> u16 {
405    0xFFFF
406}
407
408impl VbaModule {
409    /// Whether this is a document module -- `ThisWorkbook` or a worksheet's
410    /// code-behind -- as opposed to a standard or class module.
411    pub fn is_document(&self) -> bool {
412        self.kind == VbaModuleKind::Document
413    }
414}
415
416/// A workbook's VBA project: its modules plus the raw material needed to
417/// patch (not rebuild from scratch) a `vbaProject.bin` on export.
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
419pub struct VbaProject {
420    /// Project ID GUID, e.g. `"{7B4E3A2C-1F5D-4A6B-9C8E-2D3F4A5B6C7D}"`.
421    /// Must stay internally consistent with `protection_lines` -- never
422    /// mutated after import/creation, so it always is. If `CMG`/`DPB`/`GC`
423    /// protection-state lines are ever made independently settable, they
424    /// must correspond to this exact ID or Excel reports the whole project
425    /// "unviewable" (a real finding from the POC, not a hypothetical).
426    pub project_id: String,
427    /// The project's modules, in no particular order. Names are unique
428    /// case-insensitively.
429    pub modules: Vec<VbaModule>,
430    /// The full original `vbaProject.bin` bytes this project was imported
431    /// from, or (for a project created fresh in this session)
432    /// `vba_synth::synthetic_raw_donor()`'s from-scratch bytes -- export's
433    /// patch base. See `vba_xlsx.rs`.
434    #[serde(default)]
435    pub raw_donor: Vec<u8>,
436    /// P-code prefix bytes to donate to the first module ever added to a
437    /// project that started with none -- kept separate from `modules`
438    /// rather than as a phantom placeholder module, so it never shows up in
439    /// `list_vba_modules`/export. Once a project has at least one real
440    /// module, new modules instead borrow prefix bytes from an existing
441    /// one, and this field goes unused.
442    #[serde(default)]
443    pub seed_prefix_bytes: Vec<u8>,
444    /// `VbaModule::module_cookie` to donate to the first module ever added
445    /// to a project that started with none -- same donation scheme as
446    /// `seed_prefix_bytes`, see there for why.
447    #[serde(default = "default_module_cookie")]
448    pub seed_module_cookie: u16,
449    /// The donor's original `PROJECT` stream `CMG=`/`DPB=`/`GC=` lines
450    /// (joined with `\r\n`), reproduced verbatim on export -- `None` for a
451    /// project created fresh in this session, which never had any. See
452    /// `vba_xlsx::build_project_stream` for why these must be preserved
453    /// rather than dropped.
454    #[serde(default)]
455    pub protection_lines: Option<String>,
456}
457
458impl VbaProject {
459    /// A brand-new, empty VBA project with no real Excel-authored file
460    /// behind it anywhere -- `raw_donor` and `seed_prefix_bytes` are built
461    /// by `vba_synth` entirely from scratch. See `vba_synth`'s doc comment
462    /// for why that's now possible.
463    pub fn new_empty() -> Self {
464        VbaProject {
465            project_id: new_project_guid(),
466            modules: Vec::new(),
467            raw_donor: crate::core::vba_synth::synthetic_raw_donor(),
468            seed_prefix_bytes: crate::core::vba_synth::synthetic_module_prefix(),
469            seed_module_cookie: default_module_cookie(),
470            protection_lines: None,
471        }
472    }
473
474    /// Finds a module by name, matched case-insensitively as VBA does.
475    pub fn find_module(&self, name: &str) -> Option<&VbaModule> {
476        self.modules
477            .iter()
478            .find(|m| m.name.eq_ignore_ascii_case(name))
479    }
480
481    /// [`VbaProject::find_module`], mutably.
482    pub fn find_module_mut(&mut self, name: &str) -> Option<&mut VbaModule> {
483        self.modules
484            .iter_mut()
485            .find(|m| m.name.eq_ignore_ascii_case(name))
486    }
487
488    /// Whether a module of this name already exists, matched
489    /// case-insensitively.
490    pub fn module_name_taken(&self, name: &str) -> bool {
491        self.find_module(name).is_some()
492    }
493
494    /// Checks every module, resolving names against the **whole project**.
495    ///
496    /// This is the check to prefer wherever the project is in hand.
497    /// [`VbaModule::check_syntax`] sees one module and so has to accept any
498    /// name it cannot resolve, since a sibling may declare it; here the
499    /// siblings are known, so `x = arr(1)` with no `arr` anywhere is
500    /// reported the way Excel reports it -- Excel compiles a project, not a
501    /// file.
502    ///
503    /// Returns one entry per module, in `modules` order, pairing the
504    /// module's name with its result. A module whose *source* does not parse
505    /// still contributes whatever names it declares to the others, since a
506    /// parse failure in one module is not evidence about another.
507    pub fn check_modules(&self) -> Vec<(String, Result<ModuleSyntax, Error>)> {
508        self.check_modules_scoped(true)
509    }
510
511    /// [`check_modules`](Self::check_modules) for a project that is **not**
512    /// the whole story -- one whose procedures may live in a referenced
513    /// project this `VbaProject` does not model.
514    ///
515    /// Modules still resolve against each other; the only thing that
516    /// changes is that a name resolving nowhere is accepted rather than
517    /// reported, as in [`check_syntax_partial`]. Nothing in a workbook
518    /// records whether such a reference exists, so this is a caller's
519    /// assertion, not something to infer.
520    pub fn check_modules_partial(&self) -> Vec<(String, Result<ModuleSyntax, Error>)> {
521        self.check_modules_scoped(false)
522    }
523
524    /// The body both of the above share, `complete` being
525    /// [`resolve::Scope::complete_project`].
526    fn check_modules_scoped(&self, complete: bool) -> Vec<(String, Result<ModuleSyntax, Error>)> {
527        let mut declared: std::collections::HashSet<String> = std::collections::HashSet::new();
528        let parsed: Vec<_> = self
529            .modules
530            .iter()
531            .map(|m| (m, parser::parse_module(&m.source).ok()))
532            .collect();
533        for (_, module) in &parsed {
534            if let Some(module) = module {
535                declared.extend(resolve::declared_names(module));
536            }
537        }
538
539        parsed
540            .iter()
541            .map(|(m, _)| {
542                let scope = resolve::Scope {
543                    external: &declared,
544                    complete_project: complete,
545                };
546                (
547                    m.name.clone(),
548                    check_source(&m.source, Some(&m.name), &scope),
549                )
550            })
551            .collect()
552    }
553}
554
555/// A GUID-shaped project id (`{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}`) for
556/// a brand-new project, built from two `generate_unique_id()` draws rather
557/// than duplicating its getrandom/fallback logic.
558fn new_project_guid() -> String {
559    let hi = crate::core::engine::generate_unique_id();
560    let lo = crate::core::engine::generate_unique_id();
561    format!(
562        "{{{:08X}-{:04X}-{:04X}-{:04X}-{:012X}}}",
563        (hi >> 32) as u32,
564        (hi >> 16) as u16,
565        hi as u16,
566        (lo >> 48) as u16,
567        lo & 0xFFFF_FFFF_FFFF,
568    )
569}
570
571/// VBA identifiers: must start with a letter, contain only letters/digits/
572/// underscore, and be at most 31 characters (the real VBE module-name
573/// limit).
574pub fn validate_vba_module_name(name: &str) -> Result<(), String> {
575    let trimmed = name.trim();
576    if trimmed.is_empty() {
577        return Err("Module name cannot be empty".to_string());
578    }
579    if trimmed.chars().count() > 31 {
580        return Err(format!(
581            "Module name '{}' exceeds VBA's 31-character limit",
582            name
583        ));
584    }
585    let first = trimmed.chars().next().unwrap();
586    if !first.is_alphabetic() {
587        return Err(format!("Module name '{}' must start with a letter", name));
588    }
589    if !trimmed.chars().all(|c| c.is_alphanumeric() || c == '_') {
590        return Err(format!(
591            "Module name '{}' may only contain letters, digits, and underscores",
592            name
593        ));
594    }
595    Ok(())
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    fn sample_project() -> VbaProject {
603        VbaProject {
604            project_id: "{00000000-0000-0000-0000-000000000000}".to_string(),
605            modules: vec![
606                VbaModule {
607                    name: "ThisWorkbook".to_string(),
608                    kind: VbaModuleKind::Document,
609                    source: "Attribute VB_Name = \"ThisWorkbook\"\r\n".to_string(),
610                    bound_sheet_id: None,
611                    prefix_bytes: vec![0xAA; 16],
612                    module_cookie: 0xFFFF,
613                    cached_compressed_source: None,
614                },
615                VbaModule {
616                    name: "Module1".to_string(),
617                    kind: VbaModuleKind::Standard,
618                    source: "Attribute VB_Name = \"Module1\"\r\nSub Foo()\r\nEnd Sub\r\n"
619                        .to_string(),
620                    bound_sheet_id: None,
621                    prefix_bytes: vec![0xBB; 16],
622                    module_cookie: 0xFFFF,
623                    cached_compressed_source: None,
624                },
625            ],
626            raw_donor: Vec::new(),
627            seed_prefix_bytes: Vec::new(),
628            seed_module_cookie: 0xFFFF,
629            protection_lines: None,
630        }
631    }
632
633    #[test]
634    fn validate_name_rules() {
635        assert!(validate_vba_module_name("Module1").is_ok());
636        assert!(validate_vba_module_name("_Bad").is_err());
637        assert!(validate_vba_module_name("1Bad").is_err());
638        assert!(validate_vba_module_name("").is_err());
639        assert!(validate_vba_module_name("Has Space").is_err());
640        assert!(validate_vba_module_name("Has-Dash").is_err());
641        assert!(validate_vba_module_name(&"A".repeat(32)).is_err());
642        assert!(validate_vba_module_name(&"A".repeat(31)).is_ok());
643    }
644
645    #[test]
646    fn find_module_case_insensitive() {
647        let project = sample_project();
648        assert!(project.find_module("module1").is_some());
649        assert!(project.find_module("MODULE1").is_some());
650        assert!(project.find_module("Module2").is_none());
651    }
652
653    #[test]
654    fn module_name_taken_case_insensitive() {
655        let project = sample_project();
656        assert!(project.module_name_taken("module1"));
657        assert!(!project.module_name_taken("Module2"));
658    }
659
660    /// `sample_project()`'s shape with the sources the caller cares about,
661    /// one standard module per `(name, source)` pair.
662    fn project_of(sources: &[(&str, &str)]) -> VbaProject {
663        let mut project = sample_project();
664        project.modules = sources
665            .iter()
666            .map(|(name, source)| VbaModule {
667                name: (*name).to_string(),
668                kind: VbaModuleKind::Standard,
669                source: (*source).to_string(),
670                bound_sheet_id: None,
671                prefix_bytes: vec![0xBB; 16],
672                module_cookie: 0xFFFF,
673                cached_compressed_source: None,
674            })
675            .collect();
676        project
677    }
678
679    const CALLER: &str = "Public Sub Caller()\n    DoWork 1\nEnd Sub\n";
680    const CALLEE: &str = "Public Sub DoWork(n As Long)\nEnd Sub\n";
681
682    /// The two scopes differ on exactly one thing, and only on it: a name
683    /// no supplied module declares.
684    #[test]
685    fn partial_scope_accepts_a_call_into_source_not_supplied() {
686        assert!(check_syntax(CALLER).is_err());
687        assert!(check_syntax_partial(CALLER).is_ok());
688
689        let dup = "Sub Test()\n    Dim x As Long\n    Dim x As Long\nEnd Sub\n";
690        assert!(check_syntax(dup).is_err());
691        assert!(check_syntax_partial(dup).is_err());
692    }
693
694    #[test]
695    fn check_modules_resolves_across_siblings() {
696        let project = project_of(&[("Module1", CALLER), ("Module2", CALLEE)]);
697        for (name, result) in project.check_modules() {
698            assert!(result.is_ok(), "{name} should be clean: {result:?}");
699        }
700
701        let alone = project_of(&[("Module1", CALLER)]);
702        let results = alone.check_modules();
703        assert_eq!(results.len(), 1);
704        match &results[0].1 {
705            Err(Error::VbaSyntax {
706                message, module, ..
707            }) => {
708                assert!(message.contains("DoWork"), "{message}");
709                assert_eq!(module.as_deref(), Some("Module1"));
710            }
711            other => panic!("expected a syntax error, got {other:?}"),
712        }
713
714        assert!(alone.check_modules_partial()[0].1.is_ok());
715    }
716
717    #[test]
718    fn set_source_leaves_prefix_bytes_untouched() {
719        let mut project = sample_project();
720        let original_prefix = project.find_module("Module1").unwrap().prefix_bytes.clone();
721        project.find_module_mut("Module1").unwrap().source =
722            "Attribute VB_Name = \"Module1\"\r\nSub Bar()\r\nEnd Sub\r\n".to_string();
723        assert_eq!(
724            project.find_module("Module1").unwrap().prefix_bytes,
725            original_prefix
726        );
727    }
728}