cabriolet 0.1.2 → 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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +700 -38
  3. data/lib/cabriolet/algorithm_factory.rb +250 -0
  4. data/lib/cabriolet/base_compressor.rb +206 -0
  5. data/lib/cabriolet/binary/bitstream.rb +154 -14
  6. data/lib/cabriolet/binary/bitstream_writer.rb +129 -17
  7. data/lib/cabriolet/binary/chm_structures.rb +2 -2
  8. data/lib/cabriolet/binary/hlp_structures.rb +258 -37
  9. data/lib/cabriolet/binary/lit_structures.rb +231 -65
  10. data/lib/cabriolet/binary/oab_structures.rb +17 -1
  11. data/lib/cabriolet/cab/command_handler.rb +226 -0
  12. data/lib/cabriolet/cab/compressor.rb +35 -43
  13. data/lib/cabriolet/cab/decompressor.rb +14 -19
  14. data/lib/cabriolet/cab/extractor.rb +140 -31
  15. data/lib/cabriolet/chm/command_handler.rb +227 -0
  16. data/lib/cabriolet/chm/compressor.rb +7 -3
  17. data/lib/cabriolet/chm/decompressor.rb +39 -21
  18. data/lib/cabriolet/chm/parser.rb +5 -2
  19. data/lib/cabriolet/cli/base_command_handler.rb +127 -0
  20. data/lib/cabriolet/cli/command_dispatcher.rb +140 -0
  21. data/lib/cabriolet/cli/command_registry.rb +83 -0
  22. data/lib/cabriolet/cli.rb +356 -607
  23. data/lib/cabriolet/compressors/base.rb +1 -1
  24. data/lib/cabriolet/compressors/lzx.rb +241 -54
  25. data/lib/cabriolet/compressors/mszip.rb +35 -3
  26. data/lib/cabriolet/compressors/quantum.rb +34 -45
  27. data/lib/cabriolet/decompressors/base.rb +1 -1
  28. data/lib/cabriolet/decompressors/lzss.rb +13 -3
  29. data/lib/cabriolet/decompressors/lzx.rb +70 -33
  30. data/lib/cabriolet/decompressors/mszip.rb +126 -39
  31. data/lib/cabriolet/decompressors/quantum.rb +3 -2
  32. data/lib/cabriolet/errors.rb +3 -0
  33. data/lib/cabriolet/file_entry.rb +156 -0
  34. data/lib/cabriolet/file_manager.rb +144 -0
  35. data/lib/cabriolet/hlp/command_handler.rb +282 -0
  36. data/lib/cabriolet/hlp/compressor.rb +28 -238
  37. data/lib/cabriolet/hlp/decompressor.rb +107 -147
  38. data/lib/cabriolet/hlp/parser.rb +52 -101
  39. data/lib/cabriolet/hlp/quickhelp/compression_stream.rb +138 -0
  40. data/lib/cabriolet/hlp/quickhelp/compressor.rb +626 -0
  41. data/lib/cabriolet/hlp/quickhelp/decompressor.rb +558 -0
  42. data/lib/cabriolet/hlp/quickhelp/huffman_stream.rb +74 -0
  43. data/lib/cabriolet/hlp/quickhelp/huffman_tree.rb +167 -0
  44. data/lib/cabriolet/hlp/quickhelp/parser.rb +274 -0
  45. data/lib/cabriolet/hlp/winhelp/btree_builder.rb +289 -0
  46. data/lib/cabriolet/hlp/winhelp/compressor.rb +400 -0
  47. data/lib/cabriolet/hlp/winhelp/decompressor.rb +192 -0
  48. data/lib/cabriolet/hlp/winhelp/parser.rb +484 -0
  49. data/lib/cabriolet/hlp/winhelp/zeck_lz77.rb +271 -0
  50. data/lib/cabriolet/huffman/tree.rb +85 -1
  51. data/lib/cabriolet/kwaj/command_handler.rb +213 -0
  52. data/lib/cabriolet/kwaj/compressor.rb +7 -3
  53. data/lib/cabriolet/kwaj/decompressor.rb +18 -12
  54. data/lib/cabriolet/lit/command_handler.rb +221 -0
  55. data/lib/cabriolet/lit/compressor.rb +633 -38
  56. data/lib/cabriolet/lit/decompressor.rb +518 -152
  57. data/lib/cabriolet/lit/parser.rb +670 -0
  58. data/lib/cabriolet/models/hlp_file.rb +130 -29
  59. data/lib/cabriolet/models/hlp_header.rb +105 -17
  60. data/lib/cabriolet/models/lit_header.rb +212 -25
  61. data/lib/cabriolet/models/szdd_header.rb +10 -2
  62. data/lib/cabriolet/models/winhelp_header.rb +127 -0
  63. data/lib/cabriolet/oab/command_handler.rb +257 -0
  64. data/lib/cabriolet/oab/compressor.rb +17 -8
  65. data/lib/cabriolet/oab/decompressor.rb +41 -10
  66. data/lib/cabriolet/offset_calculator.rb +81 -0
  67. data/lib/cabriolet/plugin.rb +233 -0
  68. data/lib/cabriolet/plugin_manager.rb +453 -0
  69. data/lib/cabriolet/plugin_validator.rb +422 -0
  70. data/lib/cabriolet/system/io_system.rb +3 -0
  71. data/lib/cabriolet/system/memory_handle.rb +17 -4
  72. data/lib/cabriolet/szdd/command_handler.rb +217 -0
  73. data/lib/cabriolet/szdd/compressor.rb +15 -11
  74. data/lib/cabriolet/szdd/decompressor.rb +18 -9
  75. data/lib/cabriolet/version.rb +1 -1
  76. data/lib/cabriolet.rb +67 -17
  77. metadata +33 -2
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cabriolet
4
+ # Represents a file to be added to an archive
5
+ #
6
+ # Single responsibility: Encapsulate file metadata and data access.
7
+ # Supports both disk files and memory data, providing unified interface
8
+ # for file operations across all format compressors.
9
+ #
10
+ # @example Adding a disk file
11
+ # entry = FileEntry.new(
12
+ # source: "/path/to/file.txt",
13
+ # archive_path: "docs/file.txt"
14
+ # )
15
+ #
16
+ # @example Adding memory data
17
+ # entry = FileEntry.new(
18
+ # data: "Hello, World!",
19
+ # archive_path: "greeting.txt"
20
+ # )
21
+ class FileEntry
22
+ attr_reader :source_path, :archive_path, :data, :options
23
+
24
+ # Initialize a file entry
25
+ #
26
+ # @param source [String, nil] Path to source file on disk
27
+ # @param data [String, nil] File data in memory
28
+ # @param archive_path [String] Path within the archive
29
+ # @param options [Hash] Format-specific options
30
+ # @raise [ArgumentError] if validation fails
31
+ def initialize(archive_path:, source: nil, data: nil, **options)
32
+ @source_path = source
33
+ @data = data
34
+ @archive_path = archive_path
35
+ @options = options
36
+
37
+ validate!
38
+ end
39
+
40
+ # Check if file data is from disk
41
+ #
42
+ # @return [Boolean] true if file is on disk
43
+ def from_disk?
44
+ !@source_path.nil?
45
+ end
46
+
47
+ # Check if file data is in memory
48
+ #
49
+ # @return [Boolean] true if data is in memory
50
+ def from_memory?
51
+ !@data.nil?
52
+ end
53
+
54
+ # Read file data (from disk or memory)
55
+ #
56
+ # @return [String] File contents
57
+ def read_data
58
+ return @data if from_memory?
59
+
60
+ File.binread(@source_path)
61
+ end
62
+
63
+ # Get file size
64
+ #
65
+ # @return [Integer] File size in bytes
66
+ def size
67
+ return @data.bytesize if from_memory?
68
+
69
+ File.size(@source_path)
70
+ end
71
+
72
+ # Get file stat (disk files only)
73
+ #
74
+ # @return [File::Stat, nil] File stat or nil for memory files
75
+ def stat
76
+ return nil if from_memory?
77
+
78
+ File.stat(@source_path)
79
+ end
80
+
81
+ # Get modification time
82
+ #
83
+ # @return [Time] Modification time (current time for memory files)
84
+ def mtime
85
+ return Time.now if from_memory?
86
+
87
+ stat&.mtime || Time.now
88
+ end
89
+
90
+ # Get file attributes
91
+ #
92
+ # @return [Integer] File attributes flags
93
+ def attributes
94
+ return @options[:attributes] if @options[:attributes]
95
+ return Constants::ATTRIB_ARCH if from_memory?
96
+
97
+ calculate_disk_attributes
98
+ end
99
+
100
+ # Get compression flag from options
101
+ #
102
+ # @return [Boolean] Whether to compress this file
103
+ def compress?
104
+ @options.fetch(:compress, true)
105
+ end
106
+
107
+ private
108
+
109
+ # Validate entry parameters
110
+ #
111
+ # @raise [ArgumentError] if invalid
112
+ def validate!
113
+ if @source_path.nil? && @data.nil?
114
+ raise ArgumentError,
115
+ "Must provide either source or data"
116
+ end
117
+
118
+ if @source_path && @data
119
+ raise ArgumentError,
120
+ "Cannot provide both source and data"
121
+ end
122
+
123
+ if @source_path
124
+ unless File.exist?(@source_path)
125
+ raise ArgumentError,
126
+ "File not found: #{@source_path}"
127
+ end
128
+
129
+ unless File.file?(@source_path)
130
+ raise ArgumentError,
131
+ "Not a file: #{@source_path}"
132
+ end
133
+ end
134
+
135
+ raise ArgumentError, "Archive path required" if @archive_path.nil?
136
+ end
137
+
138
+ # Calculate attributes from disk file stat
139
+ #
140
+ # @return [Integer] Attribute flags
141
+ def calculate_disk_attributes
142
+ file_stat = stat
143
+ return Constants::ATTRIB_ARCH unless file_stat
144
+
145
+ attribs = Constants::ATTRIB_ARCH
146
+
147
+ # Read-only flag
148
+ attribs |= Constants::ATTRIB_READONLY unless file_stat.writable?
149
+
150
+ # Executable flag (Unix systems)
151
+ attribs |= Constants::ATTRIB_EXEC if file_stat.executable?
152
+
153
+ attribs
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_entry"
4
+
5
+ module Cabriolet
6
+ # Manages collection of files for archive creation
7
+ #
8
+ # Single responsibility: File list management and enumeration.
9
+ # Provides unified interface for adding files from disk or memory,
10
+ # and supports standard Ruby enumeration patterns.
11
+ #
12
+ # @example Basic usage
13
+ # manager = FileManager.new
14
+ # manager.add_file("/path/to/file.txt", "docs/file.txt")
15
+ # manager.add_data("Hello", "greeting.txt")
16
+ # manager.each { |entry| puts entry.archive_path }
17
+ class FileManager
18
+ include Enumerable
19
+
20
+ # Initialize empty file manager
21
+ def initialize
22
+ @entries = []
23
+ end
24
+
25
+ # Add file from disk
26
+ #
27
+ # @param source_path [String] Path to source file
28
+ # @param archive_path [String, nil] Path in archive (nil = use basename)
29
+ # @param options [Hash] Format-specific options
30
+ # @return [FileEntry] Added entry
31
+ # @raise [ArgumentError] if file doesn't exist
32
+ def add_file(source_path, archive_path = nil, **options)
33
+ archive_path ||= File.basename(source_path)
34
+
35
+ entry = FileEntry.new(
36
+ source: source_path,
37
+ archive_path: archive_path,
38
+ **options,
39
+ )
40
+
41
+ @entries << entry
42
+ entry
43
+ end
44
+
45
+ # Add file from memory
46
+ #
47
+ # @param data [String] File data
48
+ # @param archive_path [String] Path in archive
49
+ # @param options [Hash] Format-specific options
50
+ # @return [FileEntry] Added entry
51
+ def add_data(data, archive_path, **options)
52
+ entry = FileEntry.new(
53
+ data: data,
54
+ archive_path: archive_path,
55
+ **options,
56
+ )
57
+
58
+ @entries << entry
59
+ entry
60
+ end
61
+
62
+ # Enumerate entries (Enumerable interface)
63
+ #
64
+ # @yield [FileEntry] Each file entry
65
+ def each(&)
66
+ @entries.each(&)
67
+ end
68
+
69
+ # Check if empty
70
+ #
71
+ # @return [Boolean] true if no files added
72
+ def empty?
73
+ @entries.empty?
74
+ end
75
+
76
+ # Get count of entries
77
+ #
78
+ # @return [Integer] Number of entries
79
+ def size
80
+ @entries.size
81
+ end
82
+ alias count size
83
+
84
+ # Get entry by index
85
+ #
86
+ # @param index [Integer] Entry index
87
+ # @return [FileEntry, nil] Entry or nil if out of bounds
88
+ def [](index)
89
+ @entries[index]
90
+ end
91
+
92
+ # Get all entries
93
+ #
94
+ # @return [Array<FileEntry>] Copy of entries array
95
+ def all
96
+ @entries.dup
97
+ end
98
+
99
+ # Clear all entries
100
+ #
101
+ # @return [self]
102
+ def clear
103
+ @entries.clear
104
+ self
105
+ end
106
+
107
+ # Calculate total size of all files
108
+ #
109
+ # @return [Integer] Total size in bytes
110
+ def total_size
111
+ @entries.sum(&:size)
112
+ end
113
+
114
+ # Get files from disk
115
+ #
116
+ # @return [Array<FileEntry>] Disk-based entries
117
+ def disk_files
118
+ @entries.select(&:from_disk?)
119
+ end
120
+
121
+ # Get files from memory
122
+ #
123
+ # @return [Array<FileEntry>] Memory-based entries
124
+ def memory_files
125
+ @entries.select(&:from_memory?)
126
+ end
127
+
128
+ # Find entry by archive path
129
+ #
130
+ # @param path [String] Archive path to find
131
+ # @return [FileEntry, nil] Entry or nil if not found
132
+ def find_by_path(path)
133
+ @entries.find { |entry| entry.archive_path == path }
134
+ end
135
+
136
+ # Check if archive path exists
137
+ #
138
+ # @param path [String] Archive path to check
139
+ # @return [Boolean] true if path exists
140
+ def path_exists?(path)
141
+ !find_by_path(path).nil?
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli/base_command_handler"
4
+ require_relative "decompressor"
5
+ require_relative "compressor"
6
+
7
+ module Cabriolet
8
+ module HLP
9
+ # Command handler for HLP (Help) format
10
+ #
11
+ # This handler implements the unified command interface for HLP files,
12
+ # wrapping the existing HLP::Decompressor and HLP::Compressor classes.
13
+ # Supports both QuickHelp and Windows Help formats.
14
+ #
15
+ class CommandHandler < Commands::BaseCommandHandler
16
+ # List HLP file contents
17
+ #
18
+ # Displays information about the HLP file including format type,
19
+ # and lists all contained files with their sizes.
20
+ #
21
+ # @param file [String] Path to the HLP file
22
+ # @param options [Hash] Additional options (unused)
23
+ # @return [void]
24
+ def list(file, _options = {})
25
+ validate_file_exists(file)
26
+
27
+ decompressor = Decompressor.new
28
+ header = decompressor.open(file)
29
+
30
+ display_header(header, file)
31
+ display_files(decompressor, header)
32
+
33
+ decompressor.close(header)
34
+ end
35
+
36
+ # Extract files from HLP archive
37
+ #
38
+ # Extracts all files from the HLP file to the specified output directory.
39
+ # Supports both QuickHelp and Windows Help formats.
40
+ #
41
+ # @param file [String] Path to the HLP file
42
+ # @param output_dir [String] Output directory path (default: current directory)
43
+ # @param options [Hash] Additional options (unused)
44
+ # @return [void]
45
+ def extract(file, output_dir = nil, _options = {})
46
+ validate_file_exists(file)
47
+
48
+ output_dir ||= "."
49
+ output_dir = ensure_output_dir(output_dir)
50
+
51
+ decompressor = Decompressor.new
52
+ header = decompressor.open(file)
53
+
54
+ count = decompressor.extract_all(header, output_dir)
55
+ decompressor.close(header)
56
+
57
+ puts "Extracted #{count} file(s) to #{output_dir}"
58
+ end
59
+
60
+ # Create a new HLP archive
61
+ #
62
+ # Creates an HLP file from source files using QuickHelp format.
63
+ #
64
+ # @param output [String] Output HLP file path
65
+ # @param files [Array<String>] List of input files to add
66
+ # @param options [Hash] Additional options
67
+ # @option options [String] :format HLP format (:quickhelp, :winhelp)
68
+ # @return [void]
69
+ # @raise [ArgumentError] if no files specified
70
+ def create(output, files = [], options = {})
71
+ raise ArgumentError, "No files specified" if files.empty?
72
+
73
+ files.each do |f|
74
+ raise ArgumentError, "File does not exist: #{f}" unless File.exist?(f)
75
+ end
76
+
77
+ format = parse_format_option(options[:format])
78
+
79
+ if format == :winhelp
80
+ create_winhelp(output, files, options)
81
+ else
82
+ create_quickhelp(output, files, options)
83
+ end
84
+ end
85
+
86
+ # Display detailed HLP file information
87
+ #
88
+ # Shows comprehensive information about the HLP structure,
89
+ # including format type, file count, and metadata.
90
+ #
91
+ # @param file [String] Path to the HLP file
92
+ # @param options [Hash] Additional options (unused)
93
+ # @return [void]
94
+ def info(file, _options = {})
95
+ validate_file_exists(file)
96
+
97
+ decompressor = Decompressor.new
98
+ header = decompressor.open(file)
99
+
100
+ display_hlp_info(header, file)
101
+
102
+ decompressor.close(header)
103
+ end
104
+
105
+ # Test HLP file integrity
106
+ #
107
+ # Verifies the HLP file structure.
108
+ #
109
+ # @param file [String] Path to the HLP file
110
+ # @param options [Hash] Additional options (unused)
111
+ # @return [void]
112
+ def test(file, _options = {})
113
+ validate_file_exists(file)
114
+
115
+ decompressor = Decompressor.new
116
+ header = decompressor.open(file)
117
+
118
+ puts "Testing #{file}..."
119
+ # TODO: Implement full integrity testing
120
+ format_name = if header.respond_to?(:version)
121
+ version_value = header.version
122
+ # Convert BinData objects to integer for comparison
123
+ version_int = version_value.to_i if version_value.respond_to?(:to_i)
124
+
125
+ if version_value.is_a?(Integer) || version_int&.positive?
126
+ "QUICKHELP v#{version_value}"
127
+ elsif version_value.is_a?(Symbol)
128
+ version_value.to_s.upcase.sub("WINHELP", "WinHelp ")
129
+ else
130
+ "unknown"
131
+ end
132
+ end
133
+ puts "OK: HLP file structure is valid (#{format_name} format)"
134
+
135
+ decompressor.close(header)
136
+ end
137
+
138
+ private
139
+
140
+ # Display HLP header information
141
+ #
142
+ # @param header [Object] The HLP header object
143
+ # @param file [String] Original file path
144
+ # @return [void]
145
+ def display_header(header, file)
146
+ format_name = if header.respond_to?(:version)
147
+ version_value = header.version
148
+ # Convert BinData objects to integer for comparison
149
+ version_int = version_value.to_i if version_value.respond_to?(:to_i)
150
+
151
+ if version_value.is_a?(Integer) || version_int&.positive?
152
+ "QUICKHELP v#{version_value}"
153
+ elsif header.version.is_a?(Symbol)
154
+ header.version.to_s.upcase.sub("WINHELP", "WinHelp ")
155
+ else
156
+ header.version.to_s
157
+ end
158
+ end
159
+ puts "HLP File: #{file}"
160
+ puts "Format: #{format_name || 'unknown'}"
161
+ puts "\nFiles:"
162
+ end
163
+
164
+ # Display list of files in HLP
165
+ #
166
+ # @param decompressor [Decompressor] The decompressor instance
167
+ # @param header [Object] The HLP header object
168
+ # @return [void]
169
+ def display_files(_decompressor, header)
170
+ if header.respond_to?(:files)
171
+ header.files.each do |f|
172
+ puts " #{f.filename} (#{f.length} bytes)"
173
+ end
174
+ else
175
+ puts " (File listing not available for this format)"
176
+ end
177
+ end
178
+
179
+ # Display comprehensive HLP information
180
+ #
181
+ # @param header [Object] The HLP header object
182
+ # @param file [String] Original file path
183
+ # @return [void]
184
+ def display_hlp_info(header, file)
185
+ puts "HLP File Information"
186
+ puts "=" * 50
187
+ puts "Filename: #{file}"
188
+
189
+ if header.respond_to?(:version)
190
+ version_value = header.version
191
+ # Convert BinData objects to integer for comparison
192
+ version_int = version_value.to_i if version_value.respond_to?(:to_i)
193
+
194
+ format_name = if version_value.is_a?(Integer) || version_int&.positive?
195
+ "QUICKHELP v#{version_value}"
196
+ elsif version_value.is_a?(Symbol)
197
+ version_value.to_s.upcase.sub("WINHELP", "WinHelp ")
198
+ else
199
+ version_value.to_s
200
+ end
201
+ puts "Format: #{format_name}"
202
+ end
203
+
204
+ if header.respond_to?(:length)
205
+ puts "Size: #{header.length} bytes"
206
+ end
207
+
208
+ if header.respond_to?(:files)
209
+ puts "Files: #{header.files.size}"
210
+ puts ""
211
+ puts "Files:"
212
+ header.files.each do |f|
213
+ puts " #{f.filename}"
214
+ puts " Size: #{f.length} bytes"
215
+ end
216
+ end
217
+ end
218
+
219
+ # Create QuickHelp format HLP file
220
+ #
221
+ # @param output [String] Output file path
222
+ # @param files [Array<String>] Input files
223
+ # @param options [Hash] Additional options
224
+ # @return [void]
225
+ def create_quickhelp(output, files, _options)
226
+ compressor = Compressor.new
227
+
228
+ files.each do |f|
229
+ # Default: add files with compression
230
+ archive_name = File.basename(f)
231
+ compressor.add_file(f, "/#{archive_name}", compress: true)
232
+ end
233
+
234
+ puts "Creating #{output} with #{files.size} file(s) (QuickHelp format)" if verbose?
235
+ bytes = compressor.generate(output)
236
+ puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
237
+ end
238
+
239
+ # Create Windows Help format HLP file
240
+ #
241
+ # @param output [String] Output file path
242
+ # @param files [Array<String>] Input files
243
+ # @param options [Hash] Additional options
244
+ # @return [void]
245
+ def create_winhelp(output, files, _options)
246
+ compressor = Compressor.create_winhelp
247
+
248
+ files.each do |f|
249
+ archive_name = File.basename(f)
250
+ # WinHelp compression uses different API
251
+ compressor.add_file(f, "/#{archive_name}")
252
+ end
253
+
254
+ puts "Creating #{output} with #{files.size} file(s) (WinHelp format)" if verbose?
255
+ bytes = compressor.generate(output)
256
+ puts "Created #{output} (#{bytes} bytes, #{files.size} files)"
257
+ end
258
+
259
+ # Parse format option to symbol
260
+ #
261
+ # @param format_value [String, Symbol] The format type
262
+ # @return [Symbol] The format symbol
263
+ def parse_format_option(format_value)
264
+ return :quickhelp if format_value.nil?
265
+
266
+ format = format_value.to_sym
267
+ valid_formats = %i[quickhelp winhelp]
268
+
269
+ # Map :hlp to default :quickhelp format
270
+ format = :quickhelp if format == :hlp
271
+
272
+ unless valid_formats.include?(format)
273
+ raise ArgumentError,
274
+ "Invalid HLP format: #{format_value}. " \
275
+ "Valid options: #{valid_formats.join(', ')}"
276
+ end
277
+
278
+ format
279
+ end
280
+ end
281
+ end
282
+ end