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