wineole 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ require 'English'
2
+ require_relative '../errors'
3
+
4
+ module WineOLE
5
+ module MSOffice
6
+ # Programmatic access to a workbook's VBA project is off by default, and
7
+ # turning it on means writing a macro security setting in the registry.
8
+ # This module can read and write it -- but nothing in the wrapper calls
9
+ # the writing half. That is for `wineole-vba`, which a human runs, and
10
+ # for tests, which need to exercise both sides of the switch.
11
+ module VBA
12
+ class Error < WineOLE::Error; end
13
+ class AccessDenied < Error; end
14
+
15
+ ACCESS_KEY = 'HKCU\Software\Microsoft\Office\11.0\Excel\Security'.freeze
16
+ ACCESS_VALUE = 'AccessVBOM'.freeze
17
+ CODEPAGE_KEY = 'HKLM\System\CurrentControlSet\Control\Nls\CodePage'.freeze
18
+
19
+ # :enabled, :disabled, or :unset when the value is not there at all.
20
+ def self.state
21
+ raw = read(ACCESS_KEY, ACCESS_VALUE)
22
+ return :unset if raw.nil?
23
+
24
+ raw.to_i(16).zero? ? :disabled : :enabled
25
+ end
26
+
27
+ def self.enabled?
28
+ state == :enabled
29
+ end
30
+
31
+ # Touching VBProject when access is refused gives 0x800A03EC and a
32
+ # localized message -- the same HRESULT a rejected NumberFormat
33
+ # gives, and neither identifies the condition. The registry is what
34
+ # turns the refusal into advice.
35
+ def self.denied!
36
+ message =
37
+ case state
38
+ when :enabled
39
+ 'access to the VBA project was refused even though the registry ' \
40
+ 'has it enabled -- Excel reads that setting when it starts, so ' \
41
+ 'restart Excel if it was switched on while this instance was running'
42
+ else
43
+ 'access to the VBA project is disabled. Run `wineole-vba enable`, ' \
44
+ 'then restart Excel -- it reads the setting at startup'
45
+ end
46
+ raise AccessDenied, message
47
+ end
48
+
49
+ def self.enable!
50
+ write(ACCESS_KEY, ACCESS_VALUE, '1')
51
+ end
52
+
53
+ def self.disable!
54
+ write(ACCESS_KEY, ACCESS_VALUE, '0')
55
+ end
56
+
57
+ # The Windows ANSI codepage of this prefix, as a name Ruby knows.
58
+ # Never hardcoded: VBA source files are written and read in whatever
59
+ # this is, and it is not CP932 everywhere.
60
+ #
61
+ # Memoized because it costs a `wine reg` subprocess -- measured at
62
+ # 328 ms on this host -- and a machine's ANSI codepage does not change
63
+ # while a process runs. Without this an import pays it twice and a
64
+ # non-ASCII refusal pays it three times.
65
+ def self.codepage
66
+ @codepage ||= read_codepage
67
+ end
68
+
69
+ # For tests, which swap the codepage to exercise both sides of it.
70
+ def self.forget_codepage
71
+ @codepage = nil
72
+ end
73
+
74
+ def self.read_codepage
75
+ raw = read(CODEPAGE_KEY, 'ACP')
76
+ raise Error, "could not read the ANSI codepage (ACP) from #{CODEPAGE_KEY}" if raw.nil?
77
+
78
+ name = "CP#{raw}"
79
+ begin
80
+ ::Encoding.find(name)
81
+ rescue ArgumentError
82
+ raise Error, "the registry reports ANSI codepage #{raw.inspect}, which Ruby does not know"
83
+ end
84
+ name
85
+ end
86
+
87
+ # One explanation, used by both paths that hand text to Excel. They
88
+ # are bound by the same codepage and fail the same way, so the caller
89
+ # should not be able to tell from the message which one they hit --
90
+ # only which character stopped it.
91
+ #
92
+ # `where` says what the text was, because the way out differs: code
93
+ # given as a string can be rewritten with ChrW(), a file has to be
94
+ # edited.
95
+ def self.unrepresentable!(char, where)
96
+ raise ArgumentError,
97
+ "#{where} contains #{char.inspect}, which the system codepage (#{codepage}) " \
98
+ "cannot represent. Excel stores a module's text in that codepage, so the " \
99
+ 'character would be silently replaced rather than stored. Rewrite it with ' \
100
+ 'Chr()/ChrW() escapes, which are built at run time and are not bound by the ' \
101
+ 'codepage the source text is'
102
+ end
103
+
104
+ # BOMs, longest first -- FF FE 00 00 is UTF-32LE and also starts with
105
+ # the UTF-16LE BOM, so a shorter match must never be tried first.
106
+ BOMS = [
107
+ ["\x00\x00\xFE\xFF".b, 'UTF-32BE'],
108
+ ["\xFF\xFE\x00\x00".b, 'UTF-32LE'],
109
+ ["\xEF\xBB\xBF".b, 'UTF-8'],
110
+ ["\xFE\xFF".b, 'UTF-16BE'],
111
+ ["\xFF\xFE".b, 'UTF-16LE']
112
+ ].freeze
113
+
114
+ # What encoding a VBA source file should be read as. Three rules, and
115
+ # every one of them decides on evidence rather than on a guess -- an
116
+ # encoding a heuristic merely finds likely is exactly the "succeeded,
117
+ # returned a value, the value is wrong" failure this wrapper exists to
118
+ # remove.
119
+ #
120
+ # 1. A BOM is conclusive. Follow it.
121
+ # 2. Bytes that are not valid UTF-8 PROVE the file is not UTF-8, so
122
+ # read it as the ANSI codepage -- which is what Excel's own
123
+ # Export writes, and what every .bas from a Windows toolchain is.
124
+ # 3. Otherwise UTF-8.
125
+ #
126
+ # Rule 2 is the one that earns its place, and the direction matters:
127
+ # measured on this host, a CP932 file read as UTF-8 is invalid 95.07%
128
+ # of the time at ONE non-ASCII character and 99.99% by five, so real
129
+ # codepage files land here almost without exception. The reverse does
130
+ # not hold -- UTF-8 bytes read as CP932 come out VALID from two
131
+ # characters on, silently wrong. That asymmetry is why UTF-8 is the
132
+ # fallback in rule 3 and the codepage is never the default: guessing
133
+ # UTF-8 and being wrong is loud, guessing the codepage and being wrong
134
+ # is silent.
135
+ #
136
+ # What is left is a file in the codepage whose bytes happen to be
137
+ # valid UTF-8 -- undecidable, by construction, for anyone. Pass
138
+ # `encoding:` to skip all of this when you already know.
139
+ def self.detect_encoding(path)
140
+ head = ::File.binread(path, 4).to_s
141
+ BOMS.each { |bytes, name| return name if head.start_with?(bytes) }
142
+
143
+ ::File.binread(path).force_encoding('UTF-8').valid_encoding? ? 'UTF-8' : codepage
144
+ end
145
+
146
+ def self.read(key, value)
147
+ out, ok = run_reg(['query', key, '/v', value])
148
+ return nil unless ok
149
+
150
+ # " NAME TYPE VALUE", indented, and wine leaves a CR on the
151
+ # end. Stripping each line before splitting is what makes both go
152
+ # away: without it the leading spaces produce an empty first field
153
+ # and the value comes back as "REG_SZ 932\r\n".
154
+ #
155
+ # The name is matched as a whole field, not as a substring of the
156
+ # line -- otherwise a value name that happens to appear inside
157
+ # another value's data would match, and it would happen silently.
158
+ matches = out.lines.filter_map do |l|
159
+ parts = l.strip.split(/\s+/, 3)
160
+ parts if parts.length == 3 && parts[0] == value
161
+ end
162
+
163
+ if matches.length > 1
164
+ raise Error,
165
+ "found #{matches.length} lines naming #{value} in `wine reg query #{key}` " \
166
+ "output, expected exactly one: #{out.inspect}"
167
+ end
168
+
169
+ if matches.empty?
170
+ raise Error,
171
+ "the command succeeded but no line naming #{value} could be parsed from " \
172
+ "`wine reg query #{key}` output: #{out.inspect}"
173
+ end
174
+
175
+ matches.first[2]
176
+ end
177
+ private_class_method :read
178
+
179
+ def self.write(key, value, data)
180
+ _out, ok = run_reg(['add', key, '/v', value, '/t', 'REG_DWORD', '/d', data, '/f'])
181
+ ok
182
+ end
183
+ private_class_method :write
184
+
185
+ # The only place that shells out, and deliberately not private: it is
186
+ # the seam the tests replace so that no test touches a real registry.
187
+ #
188
+ # Exit status is the only trustworthy signal. `reg` writes both its
189
+ # success message and its not-found message to stdout, in the system
190
+ # language, and wine writes unrelated `fixme:` lines to stderr.
191
+ def self.run_reg(args)
192
+ out = IO.popen(['wine', 'reg', *args], err: File::NULL, &:read)
193
+ [out.to_s, $CHILD_STATUS&.success? || false]
194
+ rescue SystemCallError, IOError
195
+ ['', false]
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,366 @@
1
+ require 'tmpdir'
2
+ require_relative 'paths'
3
+ require_relative 'vba'
4
+ require_relative 'vba_block'
5
+
6
+ module WineOLE
7
+ module MSOffice
8
+ # The VBA surface of a workbook, reached as `book.vba`.
9
+ #
10
+ # TWO GRANULARITIES LIVE HERE and the names are what keep them apart.
11
+ # `write` and `remove` act on a named BLOCK inside a module -- the span
12
+ # this wrapper owns, delimited by sentinel comments, which may sit in a
13
+ # module full of code somebody else wrote. `add_component`,
14
+ # `remove_component`, `import` and `export` act on whole COMPONENTS.
15
+ #
16
+ # The verbs are not interchangeable either, and the difference is
17
+ # deliberate. `write` is an upsert: writing the same name again replaces
18
+ # that block, so it is idempotent. `add_component` is create-only and
19
+ # refuses a name that is already taken -- "overwriting" a component
20
+ # would destroy whatever a person put in it, so there is no way to ask
21
+ # for that.
22
+ #
23
+ # WHERE CODE GOES DECIDES WHETHER IT CAN BE CALLED. Measured against a
24
+ # live Excel 11:
25
+ #
26
+ # standard module Run("Name") works and returns the value;
27
+ # a worksheet formula =Name() works too.
28
+ # Private does NOT hide it from either.
29
+ # ThisWorkbook, Run("Name") fails ("macro not found"). Run with
30
+ # a worksheet, the module qualified -- Run("Sheet1.Name") --
31
+ # a UserForm runs it but hands back nil, so a Function's
32
+ # return value cannot be collected. =Name() is
33
+ # #NAME?.
34
+ #
35
+ # So code meant to be called belongs in a standard module (which is
36
+ # where `into: nil` puts it), and code in a sheet or ThisWorkbook module
37
+ # is there to be reached by Excel itself -- an ActiveX control's
38
+ # `_Click`, a workbook event.
39
+ class BookVBA
40
+ # The module this wrapper makes for itself when `into:` is not given.
41
+ DEFAULT_MODULE = 'WineOLE'.freeze
42
+
43
+ # VBComponents.Add's type argument. 100 (a Document module -- a
44
+ # worksheet or ThisWorkbook) is deliberately absent: Excel owns those
45
+ # and neither creates nor destroys them on request.
46
+ KINDS = { standard: 1, class: 2, form: 3 }.freeze
47
+
48
+ # The Type of a component Excel owns. Cannot be added, cannot be
49
+ # removed -- only emptied.
50
+ DOCUMENT_TYPE = 100
51
+
52
+ def initialize(ole, convert_paths:)
53
+ @ole = ole
54
+ @convert_paths = convert_paths
55
+ end
56
+
57
+ # Put a named block of VBA into this workbook.
58
+ #
59
+ # `into:` names an EXISTING component -- a UserForm, ThisWorkbook, a
60
+ # worksheet's module, a module made with #add_component. It is not
61
+ # created on demand: a typo would otherwise become a new module in
62
+ # silence. Without it the block goes in this wrapper's own module,
63
+ # which is created on demand because its name is not the caller's to
64
+ # get wrong.
65
+ #
66
+ # The block is what this wrapper owns -- writing the same name again
67
+ # replaces it, and nothing else in the module is touched. That matters
68
+ # because the module may hold code somebody wrote by hand.
69
+ def write(code, name: 'main', into: nil)
70
+ VBABlock.write(target_module(into), name, code)
71
+ self
72
+ end
73
+
74
+ # Remove a named block. When `from:` is given, only the block is
75
+ # removed -- a component the caller named is the caller's, never swept
76
+ # up here (a UserForm can be deleted, unlike ThisWorkbook, so this has
77
+ # to be a rule rather than an accident of what COM allows). Use
78
+ # #remove_component to delete one deliberately.
79
+ #
80
+ # Without `from:`, this wrapper's own module is the target, and when
81
+ # it has nothing but whitespace left the module goes too -- an empty
82
+ # module is litter, and that one IS ours to clean up.
83
+ #
84
+ # VBABlock.remove already fetched the whole body to find the block; it
85
+ # hands back the remaining lines so this does not have to fetch the
86
+ # body a second time just to ask whether it is empty.
87
+ def remove(name, from: nil)
88
+ if from
89
+ VBABlock.remove(named_component!(from).CodeModule, name)
90
+ return self
91
+ end
92
+
93
+ component = existing_component(DEFAULT_MODULE)
94
+ return self if component.nil?
95
+
96
+ remaining = VBABlock.remove(component.CodeModule, name)
97
+ project.VBComponents.Remove(component) if remaining && VBABlock.blank_lines?(remaining)
98
+ self
99
+ end
100
+
101
+ # Create an empty component. Create-only on purpose: see the class
102
+ # comment on why there is no "overwrite a component".
103
+ #
104
+ # The existence check has to come BEFORE Add, not after, and that is
105
+ # measured rather than defensive: Add and the rename that follows it
106
+ # are not atomic. Adding under a taken name SUCCEEDS, and only the
107
+ # rename fails (0x80020009), leaving a stray `Module1` behind that
108
+ # nobody asked for. Checking first is what keeps the failure clean.
109
+ def add_component(name, kind: :standard)
110
+ type = KINDS[kind]
111
+ unless type
112
+ raise ArgumentError,
113
+ "unknown component kind #{kind.inspect} -- expected one of #{KINDS.keys.inspect}. " \
114
+ 'A worksheet module and ThisWorkbook are not on that list because Excel ' \
115
+ 'owns them; they exist already and cannot be made'
116
+ end
117
+
118
+ if existing_component(name)
119
+ raise ArgumentError,
120
+ "this workbook already has a VBA component named #{name.inspect}. " \
121
+ 'add_component never overwrites one -- that would destroy whatever is ' \
122
+ 'in it. Remove it first, or use write(into:) to put a block inside it'
123
+ end
124
+
125
+ component = project.VBComponents.Add(type)
126
+ begin
127
+ component.Name = name
128
+ rescue WineOLE::RemoteError
129
+ # Add succeeded and the rename did not, so the component exists
130
+ # under a name nobody chose. Take it back out rather than leaving
131
+ # the litter the pre-check exists to prevent.
132
+ project.VBComponents.Remove(component)
133
+ raise ArgumentError,
134
+ "Excel refused #{name.inspect} as a component name. VBA names start with a " \
135
+ 'letter and hold letters, digits and underscores, up to 31 characters'
136
+ end
137
+ component
138
+ end
139
+
140
+ # Delete a component outright, with whatever is inside it.
141
+ def remove_component(name)
142
+ component = named_component!(name)
143
+ if component.Type == DOCUMENT_TYPE
144
+ raise ArgumentError,
145
+ "#{name.inspect} is a module Excel owns (a worksheet's, or ThisWorkbook's) " \
146
+ 'and cannot be deleted -- it exists for as long as the sheet or the workbook ' \
147
+ 'does. To take this wrapper\'s code back out of it, use remove(name, from:)'
148
+ end
149
+
150
+ project.VBComponents.Remove(component)
151
+ self
152
+ end
153
+
154
+ # Read a VBA source file on this machine into the project as a new
155
+ # component. Excel's own Import does the work, so the component's name
156
+ # and kind come from the file.
157
+ #
158
+ # `encoding:` skips the detection when the caller already knows; the
159
+ # rules the default follows are on VBA.detect_encoding.
160
+ def import(path, encoding: nil)
161
+ local_bridge!('import')
162
+ reject_dotdot!(path)
163
+ source = encoding || VBA.detect_encoding(path)
164
+ text = decode(::File.binread(path), source, path, guessed: encoding.nil?)
165
+
166
+ Dir.mktmpdir('wineole-vba') do |dir|
167
+ staged = ::File.join(dir, ::File.basename(path))
168
+ ::File.binwrite(staged, to_codepage(text, path))
169
+ project.VBComponents.Import(Paths.to_wine(staged))
170
+ end
171
+ self
172
+ end
173
+
174
+ # Write a component out as a file on this machine, in UTF-8 with LF --
175
+ # what Excel produces is the ANSI codepage with CRLF, and the
176
+ # destination is a Linux path.
177
+ def export(name, path)
178
+ local_bridge!('export')
179
+ reject_dotdot!(path)
180
+ component = named_component!(name)
181
+ Dir.mktmpdir('wineole-vba') do |dir|
182
+ staged = ::File.join(dir, ::File.basename(path))
183
+ component.Export(Paths.to_wine(staged))
184
+ text = ::File.binread(staged).force_encoding(VBA.codepage).encode('UTF-8')
185
+ ::File.write(path, text.gsub("\r\n", "\n"))
186
+ end
187
+ self
188
+ end
189
+
190
+ # The workbook's VBA project, or an error that says what to do about
191
+ # it. The HRESULT and the message are both useless for telling this
192
+ # condition apart -- 0x800A03EC is what a rejected NumberFormat gives
193
+ # too, and the text is localized -- so the registry is what turns a
194
+ # refusal into advice.
195
+ def project
196
+ @ole.VBProject
197
+ rescue WineOLE::RemoteError
198
+ VBA.denied!
199
+ end
200
+
201
+ private
202
+
203
+ # The other direction, and it had been left bare: a file that reads
204
+ # cleanly can still hold a character the codepage cannot store, and
205
+ # #encode raised Encoding::UndefinedConversionError naming neither the
206
+ # file nor the way out. Same rule and same words as the string path --
207
+ # refuse rather than let Excel substitute in silence.
208
+ def to_codepage(text, path)
209
+ text.encode(VBA.codepage)
210
+ rescue ::Encoding::UndefinedConversionError => e
211
+ VBA.unrepresentable!(e.error_char, path.to_s)
212
+ end
213
+
214
+ # Both encodings can be wrong at once: the bytes are not valid UTF-8
215
+ # (which is what put us on the codepage branch) and not valid in the
216
+ # codepage either. Nothing can be inferred from that, so say so --
217
+ # a bare Encoding::InvalidByteSequenceError names neither the file
218
+ # nor why that encoding was the one tried.
219
+ def decode(raw, source, path, guessed:)
220
+ raw.dup.force_encoding(source).encode('UTF-8').sub(/\A\uFEFF/, '')
221
+ rescue ::Encoding::InvalidByteSequenceError, ::Encoding::UndefinedConversionError => e
222
+ why =
223
+ if guessed
224
+ "#{source} was tried because the file has no BOM and its bytes are not valid " \
225
+ "UTF-8, which rules UTF-8 out -- but they are not valid #{source} either, so " \
226
+ 'there is nothing left to infer from. Pass `encoding:` if you know what this is'
227
+ else
228
+ "you passed encoding: #{source.inspect}, and the file's bytes are not valid in it"
229
+ end
230
+ raise ArgumentError, "cannot read #{path} as #{source} (#{e.message}). #{why}"
231
+ end
232
+
233
+ # These hand Excel a path to a file on this machine. When the bridge
234
+ # is somewhere else that path means that machine's filesystem, and
235
+ # there is no sensible thing to do with it.
236
+ #
237
+ # Keying this off @convert_paths (rather than a separate "is this
238
+ # loopback" flag) is deliberate, and it means a caller who passed
239
+ # convert_paths: false for a legitimate reason on a *loopback* bridge
240
+ # gets refused here too, with a message that says "needs the bridge to
241
+ # be on this machine" when it already is. That is the wrong reason but
242
+ # never the wrong direction: this can only over-refuse a bridge that
243
+ # would have worked, never under-refuse one that would not.
244
+ def local_bridge!(what)
245
+ return if @convert_paths
246
+
247
+ raise ArgumentError,
248
+ "#{what} needs the bridge to be on this machine: it stages a file " \
249
+ 'and hands Excel the path, which means nothing on another host'
250
+ end
251
+
252
+ # File.basename of a path ending in ".." is literally "..". For import
253
+ # that path is read directly, so it already raises Errno::EISDIR of its
254
+ # own accord -- but a bare EISDIR does not say why. For export the same
255
+ # basename feeds File.join(dir, "..") when staging, which resolves to
256
+ # dir's *parent* rather than anywhere under our own tmpdir, and Export
257
+ # ends up trying to write a file over that directory -- also EISDIR,
258
+ # also confusing. Nothing escapes and nothing is corrupted either way;
259
+ # this just gives both a clear error instead of a bare Errno.
260
+ def reject_dotdot!(path)
261
+ return unless ::File.basename(path) == '..'
262
+
263
+ raise ArgumentError, "#{path.inspect} is not a usable file path (its basename is \"..\")"
264
+ end
265
+
266
+ def existing_component(name)
267
+ project.VBComponents.Item(name)
268
+ rescue WineOLE::RemoteError
269
+ nil
270
+ end
271
+
272
+ def target_module(into)
273
+ return own_module.CodeModule if into.nil?
274
+
275
+ named_component!(into).CodeModule
276
+ end
277
+
278
+ # This wrapper's own module, made on demand. Unlike a name the caller
279
+ # passed, this one cannot be a typo, so creating it silently is safe.
280
+ def own_module
281
+ found = existing_component(DEFAULT_MODULE)
282
+ return found unless found.nil?
283
+
284
+ component = project.VBComponents.Add(KINDS[:standard])
285
+ component.Name = DEFAULT_MODULE
286
+ component
287
+ end
288
+
289
+ def named_component!(name)
290
+ found = existing_component(name)
291
+ return found unless found.nil?
292
+
293
+ raise ArgumentError,
294
+ "this workbook has no VBA component named #{name.inspect}. " \
295
+ 'Components are UserForms, ThisWorkbook, worksheet modules and ' \
296
+ 'standard modules; add one with add_component, or omit `into:` ' \
297
+ 'to use this wrapper\'s own module'
298
+ end
299
+ end
300
+
301
+ # The VBA surface of one worksheet, reached as `sheet.vba`.
302
+ #
303
+ # Blocks only. A worksheet's module cannot be created or deleted -- Excel
304
+ # makes it with the sheet and destroys it with the sheet -- so the
305
+ # component methods are absent here rather than present and always
306
+ # failing. Removing the last block empties the module; it does not
307
+ # remove it.
308
+ class SheetVBA
309
+ def initialize(ole)
310
+ @ole = ole
311
+ end
312
+
313
+ def write(code, name: 'main')
314
+ VBABlock.write(code_module, name, code)
315
+ self
316
+ end
317
+
318
+ def remove(name)
319
+ VBABlock.remove(code_module, name)
320
+ self
321
+ end
322
+
323
+ private
324
+
325
+ # A worksheet's handlers live in the worksheet's own code module --
326
+ # that is where Excel looks for `<ActiveX control>_Click`. The module
327
+ # is named by the sheet's CodeName, inside the parent workbook's
328
+ # project.
329
+ #
330
+ # THE ORDER OF THESE TWO LINES IS LOAD-BEARING, and it is not obvious.
331
+ # Worksheet.CodeName comes back as "" until something has touched that
332
+ # workbook's VBProject -- measured: "" before, "Sheet3" after, and
333
+ # "Sheet3" on every read from then on. Reading the name first and then
334
+ # the project (which looks like the same code, and is what extracting
335
+ # a local naturally produces) hands VBComponents.Item("") and gets
336
+ # 0x800A0009, "index out of range". So the project is fetched first,
337
+ # on purpose, and the name after it.
338
+ #
339
+ # The empty check is what keeps that from becoming a silent trap
340
+ # again: if this ever stops holding, it fails saying why instead of
341
+ # failing as a bare COM index error.
342
+ def code_module
343
+ vb_project = project
344
+ code_name = @ole.CodeName.to_s
345
+ if code_name.empty?
346
+ raise VBA::Error,
347
+ 'this worksheet reports no CodeName even after its VBProject was opened, ' \
348
+ 'so there is no way to find its code module'
349
+ end
350
+
351
+ vb_project.VBComponents.Item(code_name).CodeModule
352
+ end
353
+
354
+ # Only the VBProject fetch is the denial. Wrapping the lookup that
355
+ # follows it in the same rescue would report any other COM failure --
356
+ # a component that is not there, a module that will not open -- as
357
+ # "turn on AccessVBOM", which is advice for a condition the caller is
358
+ # not in.
359
+ def project
360
+ @ole.Parent.VBProject
361
+ rescue WineOLE::RemoteError
362
+ VBA.denied!
363
+ end
364
+ end
365
+ end
366
+ end