Skip to main content

visi_core/core/vba/
mod.rs

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