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}