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