Skip to main content

visi_core/core/vba/
mod.rs

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