Skip to main content

visi_core/core/
vba.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
22use serde::{Deserialize, Serialize};
23
24/// What kind of VBA module a [`VbaModule`] is, which decides how it binds to
25/// the workbook.
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27pub enum VbaModuleKind {
28    /// A `.bas`-equivalent module with no host object binding.
29    Standard,
30    /// A `.cls`-equivalent module (not validated end-to-end against real
31    /// Excel yet -- see the feature plan's open-risk notes).
32    Class,
33    /// `ThisWorkbook` or a worksheet's code-behind module. Must correspond
34    /// 1:1 with an existing sheet (or the workbook itself) via
35    /// `bound_sheet_id`, mirroring Excel's own codeName wiring.
36    Document,
37}
38
39/// A single VBA module's editable content plus the opaque bytes needed to
40/// keep Excel happy on export.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct VbaModule {
43    /// VB_Name -- must satisfy `validate_vba_module_name`.
44    pub name: String,
45    /// What kind of module this is, and so how it binds to the workbook.
46    pub kind: VbaModuleKind,
47    /// Plain VBA source text (no compression, no Attribute-line management
48    /// beyond what the caller writes -- callers are expected to include the
49    /// `Attribute VB_Name = "..."` line themselves, matching how real
50    /// Excel-authored module streams are shaped).
51    pub source: String,
52    /// Required iff `kind == Document`: the sheet this module's code
53    /// belongs to (or `None`/ignored for `ThisWorkbook`, which isn't tied to
54    /// a specific sheet). Kept as a stable id (not a name) so sheet renames
55    /// don't silently orphan the binding -- deliberately NOT cascaded the
56    /// other direction (renaming this module does not rename the sheet, and
57    /// vice versa; Excel allows the two names to diverge).
58    pub bound_sheet_id: Option<u64>,
59    /// Opaque bytes forming the pre-TextOffset "p-code prefix" of this
60    /// module's stream. Never reparsed or validated by this codebase --
61    /// proven (via the POC) that its *content* doesn't need to correspond
62    /// to this module's actual source, only its presence matters, as long
63    /// as it's shaped the way real Excel's module loader expects (a
64    /// naively zero-filled placeholder of the same length is NOT enough).
65    /// For an imported module these are the real bytes read back from the
66    /// original file; for a module created in this codebase they're
67    /// `vba_synth::synthetic_module_prefix()`'s from-scratch, self-consistent
68    /// zero-procedure cache -- see that module's doc comment.
69    #[serde(default)]
70    pub prefix_bytes: Vec<u8>,
71    /// The module stream's MODULECOOKIE record (`0x002C`) value. MS-OVBA
72    /// documents this as implementation-specific and ignorable on read, but
73    /// this codebase used to blindly overwrite every module's (including
74    /// untouched, imported ones') cookie with a hardcoded `0xFFFF` on every
75    /// export -- discovered while investigating why every workbook this
76    /// codebase produces failed `has vb project` in real Excel, by diffing
77    /// a re-exported real donor project's `dir` stream against the
78    /// original's record-by-record and finding this was the one place real
79    /// data was being discarded and replaced rather than round-tripped
80    /// verbatim. Preserved here instead so an imported module's original
81    /// value survives re-export.
82    #[serde(default = "default_module_cookie")]
83    pub module_cookie: u16,
84    /// This module stream's already-compressed source, as read back
85    /// verbatim from an imported file -- `None` for a module created fresh
86    /// in this session (nothing to cache yet). `set_vba_module_source`
87    /// clears this whenever `source` is replaced. Export reuses the cached
88    /// bytes instead of recompressing `source` from scratch for every
89    /// module untouched by the CRUD operation that triggered the save.
90    #[serde(default)]
91    pub cached_compressed_source: Option<Vec<u8>>,
92}
93
94fn default_module_cookie() -> u16 {
95    0xFFFF
96}
97
98impl VbaModule {
99    /// Whether this is a document module -- `ThisWorkbook` or a worksheet's
100    /// code-behind -- as opposed to a standard or class module.
101    pub fn is_document(&self) -> bool {
102        self.kind == VbaModuleKind::Document
103    }
104}
105
106/// A workbook's VBA project: its modules plus the raw material needed to
107/// patch (not rebuild from scratch) a `vbaProject.bin` on export.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct VbaProject {
110    /// Project ID GUID, e.g. `"{7B4E3A2C-1F5D-4A6B-9C8E-2D3F4A5B6C7D}"`.
111    /// Must stay internally consistent with `protection_lines` -- never
112    /// mutated after import/creation, so it always is. If `CMG`/`DPB`/`GC`
113    /// protection-state lines are ever made independently settable, they
114    /// must correspond to this exact ID or Excel reports the whole project
115    /// "unviewable" (a real finding from the POC, not a hypothetical).
116    pub project_id: String,
117    /// The project's modules, in no particular order. Names are unique
118    /// case-insensitively.
119    pub modules: Vec<VbaModule>,
120    /// The full original `vbaProject.bin` bytes this project was imported
121    /// from, or (for a project created fresh in this session)
122    /// `vba_synth::synthetic_raw_donor()`'s from-scratch bytes -- export's
123    /// patch base. See `vba_xlsx.rs`.
124    #[serde(default)]
125    pub raw_donor: Vec<u8>,
126    /// P-code prefix bytes to donate to the first module ever added to a
127    /// project that started with none -- kept separate from `modules`
128    /// rather than as a phantom placeholder module, so it never shows up in
129    /// `list_vba_modules`/export. Once a project has at least one real
130    /// module, new modules instead borrow prefix bytes from an existing
131    /// one, and this field goes unused.
132    #[serde(default)]
133    pub seed_prefix_bytes: Vec<u8>,
134    /// `VbaModule::module_cookie` to donate to the first module ever added
135    /// to a project that started with none -- same donation scheme as
136    /// `seed_prefix_bytes`, see there for why.
137    #[serde(default = "default_module_cookie")]
138    pub seed_module_cookie: u16,
139    /// The donor's original `PROJECT` stream `CMG=`/`DPB=`/`GC=` lines
140    /// (joined with `\r\n`), reproduced verbatim on export -- `None` for a
141    /// project created fresh in this session, which never had any. See
142    /// `vba_xlsx::build_project_stream` for why these must be preserved
143    /// rather than dropped.
144    #[serde(default)]
145    pub protection_lines: Option<String>,
146}
147
148impl VbaProject {
149    /// A brand-new, empty VBA project with no real Excel-authored file
150    /// behind it anywhere -- `raw_donor` and `seed_prefix_bytes` are built
151    /// by `vba_synth` entirely from scratch. See `vba_synth`'s doc comment
152    /// for why that's now possible.
153    pub fn new_empty() -> Self {
154        VbaProject {
155            project_id: new_project_guid(),
156            modules: Vec::new(),
157            raw_donor: crate::core::vba_synth::synthetic_raw_donor(),
158            seed_prefix_bytes: crate::core::vba_synth::synthetic_module_prefix(),
159            seed_module_cookie: default_module_cookie(),
160            protection_lines: None,
161        }
162    }
163
164    /// Finds a module by name, matched case-insensitively as VBA does.
165    pub fn find_module(&self, name: &str) -> Option<&VbaModule> {
166        self.modules
167            .iter()
168            .find(|m| m.name.eq_ignore_ascii_case(name))
169    }
170
171    /// [`VbaProject::find_module`], mutably.
172    pub fn find_module_mut(&mut self, name: &str) -> Option<&mut VbaModule> {
173        self.modules
174            .iter_mut()
175            .find(|m| m.name.eq_ignore_ascii_case(name))
176    }
177
178    /// Whether a module of this name already exists, matched
179    /// case-insensitively.
180    pub fn module_name_taken(&self, name: &str) -> bool {
181        self.find_module(name).is_some()
182    }
183}
184
185/// A GUID-shaped project id (`{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}`) for
186/// a brand-new project, built from two `generate_unique_id()` draws rather
187/// than duplicating its getrandom/fallback logic.
188fn new_project_guid() -> String {
189    let hi = crate::core::engine::generate_unique_id();
190    let lo = crate::core::engine::generate_unique_id();
191    format!(
192        "{{{:08X}-{:04X}-{:04X}-{:04X}-{:012X}}}",
193        (hi >> 32) as u32,
194        (hi >> 16) as u16,
195        hi as u16,
196        (lo >> 48) as u16,
197        lo & 0xFFFF_FFFF_FFFF,
198    )
199}
200
201/// VBA identifiers: must start with a letter, contain only letters/digits/
202/// underscore, and be at most 31 characters (the real VBE module-name
203/// limit).
204pub fn validate_vba_module_name(name: &str) -> Result<(), String> {
205    let trimmed = name.trim();
206    if trimmed.is_empty() {
207        return Err("Module name cannot be empty".to_string());
208    }
209    if trimmed.chars().count() > 31 {
210        return Err(format!(
211            "Module name '{}' exceeds VBA's 31-character limit",
212            name
213        ));
214    }
215    let first = trimmed.chars().next().unwrap();
216    if !first.is_alphabetic() {
217        return Err(format!("Module name '{}' must start with a letter", name));
218    }
219    if !trimmed.chars().all(|c| c.is_alphanumeric() || c == '_') {
220        return Err(format!(
221            "Module name '{}' may only contain letters, digits, and underscores",
222            name
223        ));
224    }
225    Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn sample_project() -> VbaProject {
233        VbaProject {
234            project_id: "{00000000-0000-0000-0000-000000000000}".to_string(),
235            modules: vec![
236                VbaModule {
237                    name: "ThisWorkbook".to_string(),
238                    kind: VbaModuleKind::Document,
239                    source: "Attribute VB_Name = \"ThisWorkbook\"\r\n".to_string(),
240                    bound_sheet_id: None,
241                    prefix_bytes: vec![0xAA; 16],
242                    module_cookie: 0xFFFF,
243                    cached_compressed_source: None,
244                },
245                VbaModule {
246                    name: "Module1".to_string(),
247                    kind: VbaModuleKind::Standard,
248                    source: "Attribute VB_Name = \"Module1\"\r\nSub Foo()\r\nEnd Sub\r\n"
249                        .to_string(),
250                    bound_sheet_id: None,
251                    prefix_bytes: vec![0xBB; 16],
252                    module_cookie: 0xFFFF,
253                    cached_compressed_source: None,
254                },
255            ],
256            raw_donor: Vec::new(),
257            seed_prefix_bytes: Vec::new(),
258            seed_module_cookie: 0xFFFF,
259            protection_lines: None,
260        }
261    }
262
263    #[test]
264    fn validate_name_rules() {
265        assert!(validate_vba_module_name("Module1").is_ok());
266        assert!(validate_vba_module_name("_Bad").is_err());
267        assert!(validate_vba_module_name("1Bad").is_err());
268        assert!(validate_vba_module_name("").is_err());
269        assert!(validate_vba_module_name("Has Space").is_err());
270        assert!(validate_vba_module_name("Has-Dash").is_err());
271        assert!(validate_vba_module_name(&"A".repeat(32)).is_err());
272        assert!(validate_vba_module_name(&"A".repeat(31)).is_ok());
273    }
274
275    #[test]
276    fn find_module_case_insensitive() {
277        let project = sample_project();
278        assert!(project.find_module("module1").is_some());
279        assert!(project.find_module("MODULE1").is_some());
280        assert!(project.find_module("Module2").is_none());
281    }
282
283    #[test]
284    fn module_name_taken_case_insensitive() {
285        let project = sample_project();
286        assert!(project.module_name_taken("module1"));
287        assert!(!project.module_name_taken("Module2"));
288    }
289
290    #[test]
291    fn set_source_leaves_prefix_bytes_untouched() {
292        let mut project = sample_project();
293        let original_prefix = project.find_module("Module1").unwrap().prefix_bytes.clone();
294        project.find_module_mut("Module1").unwrap().source =
295            "Attribute VB_Name = \"Module1\"\r\nSub Bar()\r\nEnd Sub\r\n".to_string();
296        assert_eq!(
297            project.find_module("Module1").unwrap().prefix_bytes,
298            original_prefix
299        );
300    }
301}