litbuild 1.0.1

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,557 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'litbuild/multi_part_visitor'
5
+ require 'litbuild/service_dir'
6
+
7
+ module Litbuild
8
+ ##
9
+ # This class writes AsciiDoc fragments to directories, rooted at the
10
+ # DOCUMENT_DIR directory. It adds a sequential number prefix on each
11
+ # fragment written to a directory, and ensures that no duplicate
12
+ # fragments are written: if a fragment for a specific blueprint (or
13
+ # blueprint phase) has already been written to any directory,
14
+ # AsciiDocVisitor will ignore subsequent requests to write that
15
+ # blueprint (phase). AsciiDocVisitor can also write a top-level
16
+ # AsciiDoc document that includes all top-level fragments.
17
+ class AsciiDocVisitor < MultiPartVisitor
18
+ def initialize(parameters:)
19
+ @parameters = parameters
20
+ super(directory: @parameters['DOCUMENT_DIR'])
21
+ @written = Hash.new { |hash, key| hash[key] = [] }
22
+ @all_written_targets = []
23
+ end
24
+
25
+ def visit_commands(commands:)
26
+ file_collector = {}
27
+ extra_section = proc do |doc|
28
+ render_files(doc, commands, file_collector)
29
+ end
30
+ write(blueprint: commands,
31
+ location: cwd,
32
+ extra: extra_section) do |doc, directive, values|
33
+ case directive
34
+ when 'commands' then write_commands(doc, values)
35
+ when 'file' then write_file_chunk(doc, file_collector, values)
36
+ end
37
+ end
38
+ end
39
+
40
+ def visit_narrative(narrative:)
41
+ write(blueprint: narrative, location: cwd)
42
+ end
43
+
44
+ def visit_package(package:)
45
+ write(blueprint: package,
46
+ location: cwd,
47
+ summary: summary_block_for(package)) do |doc, directive, value|
48
+ case directive
49
+ when 'configure-commands'
50
+ write_stage_commands(doc, value, 'Configuration')
51
+ when 'compile-commands'
52
+ write_stage_commands(doc, value, 'Compilation')
53
+ when 'test-commands'
54
+ write_stage_commands(doc, value, 'Test')
55
+ when 'install-commands'
56
+ write_stage_commands(doc, value, 'Installation')
57
+ when 'before-build-as-root'
58
+ write_stage_commands(doc, value, 'Pre-build (as `root`)')
59
+ when 'after-install-as-root'
60
+ write_stage_commands(doc, value, 'Post-installation (as `root`)')
61
+ when 'patches'
62
+ write_patch_block(doc, package, value)
63
+ when 'in-tree-sources'
64
+ write_in_tree_block(doc, package, value)
65
+ when 'build-dir'
66
+ write_build_dir(doc, value)
67
+ end
68
+ end
69
+ end
70
+
71
+ def visit_section(section:)
72
+ # The files that need to be included by the section -- explicitly
73
+ # declared, added for dependencies, implicitly included by phase
74
+ # or whatever. Explicit blueprints (and their dependencies, if
75
+ # any) will be written into the narrative wherever they appear;
76
+ # the others will be written at the end by
77
+ # `write_remaining_blueprints`.
78
+ files = @written[File.join(cwd, section.name)].clone
79
+ write_remaining_blueprints = proc do |doc|
80
+ files.each do |file|
81
+ doc.puts
82
+ doc.puts("include::./#{section.name}/#{file}[leveloffset=+1]")
83
+ end
84
+ end
85
+ write(blueprint: section,
86
+ location: cwd,
87
+ extra: write_remaining_blueprints) do |doc, directive, value|
88
+ case directive
89
+ when 'blueprints'
90
+ write_blueprint_includes(doc, value, section.name, files)
91
+ when 'package-list-with-versions'
92
+ write_package_list(doc, section)
93
+ end
94
+ end
95
+ end
96
+
97
+ def write_toplevel_doc(blueprint:)
98
+ doc_dir = @parameters['DOCUMENT_DIR']
99
+ return if @written[doc_dir].empty?
100
+
101
+ doc_name = File.join(doc_dir, "#{blueprint.file_name}.adoc")
102
+ File.open(doc_name, 'w') do |f|
103
+ top_header = blueprint.header_text
104
+ top_header += " (#{blueprint.active_phase})" if blueprint.active_phase
105
+ f.puts("= #{top_header}")
106
+ f.puts(blueprint.value('author')) if blueprint['author']
107
+ f.puts(blueprint.value('revision')) if blueprint['revision']
108
+ f.puts(':sectnums:')
109
+ f.puts(':doctype: book') unless other_parts.empty?
110
+ write_toplevel_includes(location: doc_dir, document: f)
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ ##
117
+ # Write an AsciiDoc fragment.
118
+ #
119
+ # blueprint: the blueprint for which to write a fragment.
120
+ # location: the directory where the fragment should be written.
121
+ # extra: a proc that will render extra sections to be added at the
122
+ # end, if any.
123
+ # summary: a proc that will render a summary block, if one is
124
+ # needed. This will be called with the document I/O and with a
125
+ # `for_phase` parameter of `false` when writing an unphased
126
+ # blueprint or the base narrative for a phased blueprint, or
127
+ # `true` if writing the phase narrative of a phased blueprint.
128
+ #
129
+ # This method should be called with a block that accepts:
130
+ # - the document I/O, which may be written to as needed;
131
+ # - the name of a directive being rendered; and
132
+ # - the directive values.
133
+ # The block should render whatever AsciiDoc is suitable based on the
134
+ # directives passed to it. If a directive need not result in any
135
+ # output, just ignore it.
136
+ def write(blueprint:, location:, extra: proc {}, summary: proc {}, &block)
137
+ return if @all_written_targets.include?(blueprint.target_name)
138
+
139
+ FileUtils.mkdir_p(location)
140
+ doc_name = format('%<count>02d-%<doc>s',
141
+ count: @written[location].size,
142
+ doc: blueprint.file_name + '.adoc')
143
+ File.open(File.join(location, doc_name), 'w') do |f|
144
+ f.write(to_asciidoc(blueprint, extra, summary, block))
145
+ end
146
+ @written[location] << doc_name
147
+ @all_written_targets << blueprint.target_name
148
+ end
149
+
150
+ ##
151
+ # Should the base grafs of the blueprint be rendered? This is always
152
+ # true for unphased blueprints (since there is nothing else to
153
+ # render) and is true the _first_ time a phased blueprint is
154
+ # rendered.
155
+ def render_base_grafs?(blueprint)
156
+ return true unless blueprint.phases?
157
+
158
+ @all_written_targets.none? { |target| target =~ /^#{blueprint.name}::/ }
159
+ end
160
+
161
+ def to_asciidoc(blueprint, extra, summary, block)
162
+ doc = StringIO.new
163
+ if render_base_grafs?(blueprint)
164
+ doc.puts("[[#{blueprint.name},#{blueprint.header_text}]]")
165
+ doc.puts("= #{blueprint.header_text}")
166
+ doc.puts
167
+ summary.call(doc, false)
168
+ doc.puts("== Overview\n\n") if blueprint.active_phase
169
+ render_grafs(doc, blueprint.base_grafs, block)
170
+ doc.puts(phase_heading('==', blueprint)) if blueprint.active_phase
171
+ else
172
+ doc.puts(phase_heading('=', blueprint))
173
+ nm = blueprint.name
174
+ doc.puts("_For an overview of #{nm}, see <<#{nm}>>._\n\n")
175
+ end
176
+ if blueprint.active_phase
177
+ summary.call(doc, true)
178
+ render_grafs(doc, blueprint.phase_grafs, block)
179
+ end
180
+ extra.call(doc)
181
+ # If there are any extraneous blank lines in the document, get rid
182
+ # of them.
183
+ doc.string.gsub(/\n\n\n+/, "\n\n").strip + "\n"
184
+ end
185
+
186
+ def phase_heading(level, blueprint)
187
+ "[[#{blueprint.name_with_phase},#{blueprint.header_text_with_phase}]]\n" \
188
+ "#{level} #{blueprint.header_text_with_phase}\n\n"
189
+ end
190
+
191
+ def render_grafs(doc, grafs, block)
192
+ grafs.each do |graf|
193
+ case graf
194
+ when String then doc.puts(graf)
195
+ when Hash then handle_directives(doc, graf, block)
196
+ end
197
+ doc.puts
198
+ end
199
+ end
200
+
201
+ ##
202
+ # Render some AsciiDoc for a directive hash into the specified
203
+ # document. Any directive that may be specified as part of any
204
+ # blueprint should be handled directly here. Other types of
205
+ # directives will be passed into the block given originally to the
206
+ # `write` method; each visit method may define a block that does the
207
+ # correct thing for all directives that may be defined in that type
208
+ # of blueprint.
209
+ def handle_directives(doc, dir_hash, block)
210
+ dir_hash.each do |directive, values|
211
+ if directive == 'parameter'
212
+ write_parameter(doc, values)
213
+ elsif directive == 'environment'
214
+ write_environment(doc, values)
215
+ elsif directive == 'depends-on'
216
+ write_dependencies(doc, values)
217
+ elsif directive == 'configuration-files'
218
+ write_cfgfiles(doc, values)
219
+ elsif directive == 'servicedir'
220
+ write_servicedir(doc, values)
221
+ elsif directive == 'service-pipeline'
222
+ write_servicepipe(doc, values)
223
+ elsif block
224
+ block.call(doc, directive, values)
225
+ end
226
+ end
227
+ end
228
+
229
+ def write_servicedir(doc, svcdefs)
230
+ svcdefs.each do |svcdef|
231
+ sd = ServiceDir.new(svcdef)
232
+ head = ".Service Directory: #{sd.type.capitalize} `#{sd.name}`"
233
+ head += " (in bundle `#{sd.bundle}`)" if sd.bundle
234
+ doc.puts(head)
235
+ doc.puts("[%autowidth,cols=\"d,d\",caption=]\n|===\n\n")
236
+ write_servicedir_body(doc, sd)
237
+ doc.puts('|===')
238
+ end
239
+ end
240
+
241
+ def write_servicepipe(doc, pipedefs)
242
+ pipedefs.each do |pipedef|
243
+ head = ".Service Pipeline: `#{pipedef['name'].first}`"
244
+ head += " (in bundle `#{pipedef['bundle'].first}`)" if pipedef['bundle']
245
+ doc.puts(head)
246
+ doc.puts("[%autowidth,cols=\"d,d\",caption=]\n|===\n\n")
247
+ pipedef['servicedirs'].each_with_index do |svcdir, idx|
248
+ sd = ServiceDir.new(svcdir)
249
+ doc.puts("2+h|Service #{idx + 1}: #{sd.type.capitalize} `#{sd.name}`")
250
+ write_servicedir_body(doc, sd)
251
+ end
252
+ doc.puts('|===')
253
+ end
254
+ end
255
+
256
+ def write_servicedir_body(doc, svc)
257
+ svc.oneline_files.keys.sort.each do |fn|
258
+ next if fn == 'type'
259
+
260
+ doc.puts("|#{fn}|`#{svc.oneline_files[fn]}`")
261
+ end
262
+ unless svc.dependencies.empty?
263
+ doc.puts("|Dependencies\na|")
264
+ svc.dependencies.each do |dep|
265
+ doc.puts("- `#{dep}`")
266
+ end
267
+ doc.puts
268
+ end
269
+ unless svc.env.empty?
270
+ doc.puts("|Environment\na|")
271
+ svc.env.keys.sort.each do |var|
272
+ doc.puts("`#{var}`:: `#{svc.env[var]}`")
273
+ end
274
+ doc.puts
275
+ end
276
+ svc.multiline_files.keys.sort.each do |filename|
277
+ doc.puts("|#{filename.capitalize} script\nl|")
278
+ doc.puts(svc.multiline_files[filename])
279
+ doc.puts
280
+ end
281
+ end
282
+
283
+ def write_parameter(doc, params)
284
+ params.each do |param|
285
+ pname = param['name'].first
286
+ pdefault = param['default'].first.gsub(/\\\n /, '')
287
+ default_string = if pdefault == '(empty)'
288
+ 'not set'
289
+ else
290
+ "`#{pdefault}`"
291
+ end
292
+
293
+ value = @parameters[pname].gsub(/\\\n /, '')
294
+ val_string = if value == ''
295
+ 'not set'
296
+ else
297
+ "`#{value}`"
298
+ end
299
+
300
+ doc.print("Parameter: #{pname}:: ")
301
+ if val_string == default_string
302
+ doc.puts("Value: #{val_string} _(default)_")
303
+ else
304
+ doc.puts("Value: #{val_string} _(default: #{default_string})_")
305
+ end
306
+ end
307
+ end
308
+
309
+ def write_environment(doc, env_directives)
310
+ env_directives.each do |variables|
311
+ variables.each do |varname, value|
312
+ env_val = if value.first.empty?
313
+ '_(should not be set)_'
314
+ else
315
+ "`#{value.first}`"
316
+ end
317
+ doc.puts("Environment variable: #{varname}:: #{env_val}")
318
+ end
319
+ end
320
+ end
321
+
322
+ def dependency_anchor_list(dep_directives)
323
+ anchors = dep_directives.map { |d| "<<#{d}>>" }
324
+ anchors.join(', ')
325
+ end
326
+
327
+ def write_dependencies(doc, dep_directives)
328
+ doc.puts("Dependencies:: #{dependency_anchor_list(dep_directives)}.")
329
+ end
330
+
331
+ def write_cfgfiles(doc, files)
332
+ doc.puts('.Configuration Files')
333
+ files.each do |file|
334
+ doc.puts("- `#{file}`")
335
+ end
336
+ end
337
+
338
+ def write_build_dir(doc, build_dir)
339
+ doc.puts("Build Directory:: `#{build_dir.first}`")
340
+ end
341
+
342
+ def write_commands(doc, commands)
343
+ doc.puts('.Commands:')
344
+ doc.puts('[source,bash]')
345
+ doc.puts('----')
346
+ commands.each { |cmd| doc.puts(cmd) }
347
+ doc.puts('----')
348
+ end
349
+
350
+ def write_file_chunk(doc, file_collector, file_directive)
351
+ file = file_directive.first
352
+ filename = file['name'].first
353
+ if file_collector.key?(filename)
354
+ doc.puts(".File #{filename} (continued):")
355
+ file_collector[filename] = file_collector[filename] + file['content']
356
+ else
357
+ doc.puts(".File #{filename}:")
358
+ file_collector[filename] = file['content']
359
+ end
360
+ doc.puts('[source,indent=0]')
361
+ doc.puts('----')
362
+ doc.puts(file['content'])
363
+ doc.puts('----')
364
+ end
365
+
366
+ def render_files(doc, blueprint, files)
367
+ return if files.empty?
368
+
369
+ doc.puts("[[#{blueprint.name_with_phase}_files]]")
370
+ doc.puts("== Complete text of files\n\n")
371
+ files.keys.sort.each do |filename|
372
+ doc.puts("=== #{filename}\n\n")
373
+ doc.puts('[source,indent=0]')
374
+ doc.puts('----')
375
+ doc.puts(files[filename])
376
+ doc.puts('----')
377
+ end
378
+ end
379
+
380
+ def summary_block_for(package)
381
+ proc do |doc, for_phase|
382
+ write_full_block = !package.phases? || !for_phase
383
+ next unless write_full_block || package.build_dir ||
384
+ package.directives['environment']
385
+
386
+ doc.puts("[%autowidth,cols=\"h,d\"]\n|===\n\n")
387
+ if write_full_block
388
+ doc.puts("|Name|#{package.full_name}\n|Version|#{package.version}")
389
+ write_url_rows(doc, package)
390
+ write_list_row(doc, 'Patches', package.patch_files) do |pf|
391
+ "- `#{pf}`"
392
+ end
393
+ write_list_row(doc, 'Built In-Tree', package.in_tree) do |pkg|
394
+ "- #{pkg[0]} #{pkg[1]}"
395
+ end
396
+ write_dependency_row(doc, package)
397
+ end
398
+ if !package.phases? || for_phase
399
+ write_environment_row(doc, package)
400
+ if package.build_dir
401
+ doc.puts("|Build Directory| `#{package.build_dir}`")
402
+ end
403
+ end
404
+ doc.puts("|===\n\n")
405
+ end
406
+ end
407
+
408
+ def write_dependency_row(doc, blueprint)
409
+ dependencies = blueprint.deduped_dependency_names
410
+ return if dependencies.empty?
411
+
412
+ doc.puts("|Dependencies|#{dependency_anchor_list(dependencies)}")
413
+ end
414
+
415
+ def write_environment_row(doc, package)
416
+ return unless package['environment']
417
+
418
+ doc.puts('|Environment')
419
+ doc.puts('a|')
420
+ package['environment'].each do |env_hash|
421
+ env_hash.each do |var, value|
422
+ if value.first.empty?
423
+ doc.puts("- unset `#{var}`")
424
+ else
425
+ doc.puts("- `#{var}`: `#{value.first}`")
426
+ end
427
+ end
428
+ end
429
+ doc.puts
430
+ end
431
+
432
+ def write_list_row(doc, header, items)
433
+ return if items.nil? || items.empty?
434
+
435
+ doc.puts("|#{header}")
436
+ doc.puts('a|')
437
+ items.each { |pf| doc.puts(yield(pf)) }
438
+ doc.puts
439
+ end
440
+
441
+ def write_url_rows(doc, package)
442
+ write_url_row(doc, package.url('project'), 'Project URL')
443
+ write_url_row(doc, package.url('scm'), 'SCM URL')
444
+ write_url_row(doc, package.url('download'), 'Download URL')
445
+ end
446
+
447
+ def write_url_row(doc, url, header)
448
+ if url.match?(/\(.*\)/)
449
+ doc.puts("|#{header}|_#{url}_")
450
+ else
451
+ doc.puts("|#{header}|#{url}")
452
+ end
453
+ end
454
+
455
+ def write_stage_commands(doc, cmds, stage_name)
456
+ doc.puts(".#{stage_name} commands:")
457
+ doc.puts('[source,bash]')
458
+ doc.puts('----')
459
+ cmds.each { |cmd| doc.puts(cmd) }
460
+ doc.puts('----')
461
+ end
462
+
463
+ def write_patch_block(doc, package, patches_to_render)
464
+ doc.puts('.Patch:')
465
+ patches_to_render.each do |patch|
466
+ doc.puts("- #{package.name_and_version}-#{patch}.patch")
467
+ end
468
+ end
469
+
470
+ def write_in_tree_block(doc, package, intree_to_render)
471
+ doc.puts('.In-Tree Sources:')
472
+ intree_to_render.each do |intree|
473
+ package, version, path = intree.split
474
+ doc.puts("- #{package} #{version} (at `#{path}`)")
475
+ end
476
+ end
477
+
478
+ def write_blueprint_includes(doc, blueprints, section_name, files)
479
+ until blueprints.empty?
480
+ bp = blueprints.shift
481
+ bpfile = bp.sub('::', '-').tr(' ', '-')
482
+ rendered_bpfile = files.detect { |f| f =~ /[0-9]-#{bpfile}.adoc/ }
483
+ unless rendered_bpfile
484
+ msg = "specifies blueprint #{bp} but it has already been rendered"
485
+ raise(Litbuild::UnrenderedComponent, "Section #{section_name} #{msg}")
486
+ end
487
+ loop do
488
+ to_render = files.shift
489
+ doc.puts("include::./#{section_name}/#{to_render}[leveloffset=+1]")
490
+ doc.puts
491
+ break if to_render == rendered_bpfile
492
+ end
493
+ end
494
+ end
495
+
496
+ def write_package_list(doc, section)
497
+ packages = section.components.select { |c| c.is_a? Package }
498
+ sorted = packages.sort_by(&:name)
499
+ doc.puts('.Package Versions:')
500
+ sorted.each do |pkg|
501
+ doc.puts("- #{pkg.name} #{pkg.version}")
502
+ end
503
+ end
504
+
505
+ # The include directives are a little bit complicated because in
506
+ # different cases we want to write them with different styles.
507
+ # - Usually we want to swallow the level-0 header in the included
508
+ # fragment, so that the document header becomes the level-0 header
509
+ # and the first section becomes a preamble. We do this by
510
+ # including the fragment with lines="3..-1" so that the first two
511
+ # lines are ignored.
512
+ # - When we are writing a multi-part book, we do that for the first
513
+ # part but include the other fragments without any other
514
+ # arguments, so their level-0 header stays a level-0 header and
515
+ # becomes the part header.
516
+ # - When we are writing a single-part document (article, not book),
517
+ # and we have multiple fragments, that means that the requested
518
+ # target has dependencies and those dependencies are being written
519
+ # before the requested target. In that case, the document header
520
+ # is still the level-0 header, but we don't want to ignore the
521
+ # level-0 header in the included fragments -- we increase
522
+ # leveloffset so they become level-1 headers, and so on.
523
+ # - For appendices, we always use a multi-part document style and
524
+ # include appendices as siblings to the parts. Since we want a
525
+ # level-0 header for the appendix itself but want other headers to
526
+ # start at level-3, we write a level-0 header into the top level
527
+ # document, swallow the level-0 header in the included fragment,
528
+ # and _also_ increase the header level in the reset of the
529
+ # fragment so we can skip the level-2 headers.
530
+ def write_toplevel_includes(location:, document:)
531
+ fragments = @written[location]
532
+ if fragments.size > 1 && other_parts.empty?
533
+ fragments.each do |fragment|
534
+ document.puts
535
+ document.puts("include::./#{fragment}[leveloffset=+1]")
536
+ end
537
+ else
538
+ main_section = fragments.first
539
+ document.puts
540
+ document.puts("include::./#{main_section}[lines=\"3..-1\"]")
541
+ other_parts.each do |part|
542
+ partfile = fragments.detect { |x| x =~ /^[0-9]+-#{part}.adoc$/ }
543
+ document.puts
544
+ document.puts("include::./#{partfile}[]")
545
+ end
546
+ appendices.each do |appendix|
547
+ appname = appendix.file_name
548
+ appfile = fragments.detect { |x| x =~ /^[0-9]+-#{appname}.adoc$/ }
549
+ document.puts("\n[appendix]")
550
+ document.puts("[[#{appendix.name},#{appendix.header_text}]]")
551
+ document.puts("= #{appendix.header_text}\n\n")
552
+ document.puts("include::./#{appfile}[leveloffset=+1,lines=\"3..-1\"]")
553
+ end
554
+ end
555
+ end
556
+ end
557
+ end