libshit

Just some random shit
git clone https://git.neptards.moe/neptards/libshit.git
Log | Files | Refs | Submodules | README | LICENSE

ci_lib.rb (8319B)


      1 # frozen_string_literal: true
      2 
      3 require 'open3'
      4 require 'pathname'
      5 require 'set'
      6 
      7 module CI
      8   extend self
      9 
     10   def einfo  str; $stderr.puts " \e[32;1m*\e[0m #{str}"; end
     11   def ewarn  str; $stderr.puts " \e[33;1m*\e[0m #{str}"; end
     12   def eerror str; $stderr.puts " \e[31;1m*\e[0m #{str}"; end
     13   def ecmd   str; $stderr.puts " \e[35;1m*\e[0m #{str}"; end
     14 
     15   # Replacement for Shellwords.shellescape. Unfortunately it outputs pretty
     16   # unreadable shit as it escapes space with \ instead of putting it in quotes,
     17   # and if there are like, newlines in the string, it puts just the newline into
     18   # quotes. Our primary goal here is to have a *readable* output as the output
     19   # normally won't be run through the shell, try to create something that's
     20   # readable and also valid bash input.
     21   refine String do
     22     def shellescape
     23       if self =~ %r{^[0-9a-zA-Z%+,./:=@^_'-]+$}
     24         gsub "'", "\\\\'"
     25       else
     26         "'" + gsub("'", "'\\\\''") + "'"
     27       end
     28     end
     29 
     30     # because adding this to ruby is fucking impossible, as there's an alternate
     31     # solution, which is:
     32     #   base = expr()
     33     #   Dir.glob("*/glob...", base: base).map {|f| File.join base, f }
     34     # instead of
     35     #   Dir.glob "#{Dir.glob_escape expr()}/*/glob..."
     36     # or our version:
     37     #   Dir.glob "#{expr().globescape}/*/glob..."
     38     # yeah, I'm totally gonna write that boilerplate every fucking time. And it
     39     # also only works at the beginning of the path, not anywhere else. Why must
     40     # every language became java?!
     41     def globescape; gsub /\[|\]|\*|\?|\{|\}/, '\\\\' + '\0'; end
     42   end
     43   # Fuckin shellwords (required by nokogiri) puts shelljoin onto Array instead
     44   # of Enumerable, so override it here too.
     45   refine Array do
     46     def shelljoin; map(&:shellescape).join ' '; end
     47   end
     48   refine Enumerable do
     49     def shelljoin; map(&:shellescape).join ' '; end
     50   end
     51   using self
     52 
     53   def get_path
     54     opt.extra_path + ENV['PATH'].split(':')
     55   end
     56 
     57   def which cmd
     58     get_path.lazy.map {|d| File.join(d, cmd) }.find {|f| File.executable? f }
     59   end
     60 
     61   class Store
     62     include Enumerable
     63     def initialize
     64       @map = {}
     65     end
     66 
     67     def set name, val
     68       name = name.to_sym
     69       fail "Overwriting variable #{name}" if @map[name].is_a? Array
     70       @map[name] = check_val val
     71     end
     72 
     73     def set! name, val; @map[name.to_sym] = check_val val; end
     74     def unset name; @map.delete name.to_sym; end
     75     def get name; @map[name.to_sym]; end
     76 
     77     def method_missing name, *args
     78       if name.end_with? '='
     79         set name.to_s.chomp('='), *args
     80       else
     81         @map.[] name.to_sym, *args
     82       end
     83     end
     84 
     85     def each &blk; @map.each &blk; end
     86 
     87     private
     88     def check_val val
     89       unless val.is_a?(Array) || val.is_a?(Integer) || val.is_a?(String) ||
     90              val == true || val == false
     91         fail "Unsupported value type #{val.class}"
     92       end
     93       val
     94     end
     95   end
     96   @env = Store.new; attr_reader :env
     97   @opt = Store.new; attr_reader :opt
     98 
     99   @last_env = {}
    100   def calc_env args
    101     args = args[0] if args.size == 1 && args[0].is_a?(Array)
    102     now_env = env.map {|k,v| [k.to_s, v.is_a?(Array) ? v.shelljoin : v.to_s] }.to_h
    103     now_env['PATH'] = get_path.join ':'
    104     @last_env.each {|k,v| ecmd "unset #{k.shellescape}" unless now_env.key? k }
    105     now_env.each do |k,v|
    106       ecmd "export #{k.shellescape}=#{v.shellescape}" unless @last_env[k] == v
    107     end
    108     @last_env = now_env
    109     args
    110   end
    111   def print_cd cd
    112     ecmd "cd #{cd.shellescape}" unless @last_cd == cd
    113     @last_cd = cd
    114   end
    115   private :print_cd
    116   def maybe_run dir, *args
    117     args = calc_env args
    118     print_cd dir
    119     ecmd args.shelljoin
    120     system @last_env, *args, chdir: dir
    121   end
    122 
    123   def run dir, *args; maybe_run(dir, *args) or fail "Command failure: #{$?}"; end
    124 
    125   def run_capture dir, *args
    126     args = calc_env args
    127     print_cd dir
    128     ecmd args.shelljoin
    129     out, st = Open3.capture2 @last_env, *args, chdir: dir
    130     fail "Command failure: #{st}" unless st.success?
    131     out
    132   end
    133 
    134   # random filesystem helpers that look like commands
    135   def rm dir, *files
    136     files = files[0] if files.size == 1 && files[0].is_a?(Array)
    137     return if files.empty?
    138     print_cd dir
    139     ecmd "rm #{files.shelljoin}"
    140     Dir.chdir(dir) { File.unlink *files }
    141   end
    142 
    143   def rm_if dir, *files
    144     files = files[0] if files.size == 1 && files[0].is_a?(Array)
    145     files.select! {|f| File.exist? File.join dir, f }
    146     rm dir, *files
    147   end
    148 
    149   def rel_from path, from
    150     res = Pathname.new(path).relative_path_from(from).to_s
    151     # make sure it has at least a slash, in case it is used in shell, except .
    152     # stays ., because ./. looks weird
    153     res == ?. || res.include?('/') ? res : "./#{res}"
    154   end
    155 
    156   @anon_step_id = 0
    157   @steps = {}
    158   Step = Struct.new :blk, :dep, :always_dep, :cond
    159   def get_step name
    160     @steps[name] ||= Step.new nil, [], [], nil
    161   end
    162   private :get_step
    163 
    164   def step name = nil, before: [], after: [], always_after: [], cond: nil, &blk
    165     name = :"__anon_step_#{@anon_step_id += 1}" unless name
    166     before = [before] unless before.is_a? Array
    167     after = [after] unless after.is_a? Array
    168     always_after = [always_after] unless always_after.is_a? Array
    169     blk ||= -> {}
    170     step = get_step name
    171     if step.blk || step.cond
    172       fail "Duplicate step #{name}. Previous: #{step.blk.source_location.join ':'}"
    173     end
    174 
    175     before.each {|s| get_step(s).dep << name }
    176     after.each {|s| get_step s }
    177     always_after.each {|s| get_step s }
    178     step.blk = blk
    179     step.dep.concat after
    180     step.always_dep.concat always_after
    181     step.cond = cond
    182     true
    183   end
    184 
    185   def run_steps *steps
    186     steps = steps[0] if steps.size == 1 && steps[0].is_a?(Array)
    187     fail "No steps specified" if steps.empty?
    188 
    189     state = {}
    190     queue = steps.reverse
    191     while !queue.empty?
    192       step_id = queue.pop
    193       step = @steps[step_id]
    194       fail "Invalid step #{step_id}" unless step
    195 
    196       case state[step_id]
    197       when nil
    198         queue << step_id
    199         queue.concat step.always_dep.reverse
    200         state[step_id] = :always
    201 
    202       when :always
    203         if step.cond && !step.cond[]
    204           state[step_id] = :done
    205         else
    206           queue << step_id
    207           queue.concat step.dep.reverse
    208           state[step_id] = :deps
    209         end
    210 
    211       when :deps
    212         if step.cond && !step.cond[]
    213           ewarn "Step #{step_id} was scheduled, but condition changed, skipping"
    214         else
    215           step.blk[]
    216           state[step_id] = :done
    217         end
    218 
    219       when :done
    220       end
    221     end
    222   end
    223 
    224   def check_steps
    225     fail 'Step error' unless @steps.select {|k,s| s.blk.nil? }.each do |k,s|
    226       eerror "Missing step dependency: #{k.inspect}"
    227     end.empty?
    228   end
    229 
    230   HOUR = 60*60
    231   MIN = 60
    232   def format_time f
    233     if f >= HOUR
    234       sprintf "%d:%02d:%06.3f", f/HOUR, f/MIN%MIN, f%MIN
    235     else
    236       sprintf "%02d:%06.3f", f/MIN, f%MIN
    237     end
    238   end
    239 
    240   # add test decorators around step
    241   def test_step name, **args, &blk
    242     step name, always_after: :final_opts, before: :test, **args do
    243       einfo "Running test #{name}..."
    244       start = Time.now
    245       begin
    246         blk[]
    247         einfo "Test #{name} success (#{format_time Time.now - start}s)"
    248       rescue
    249         ewarn "Test #{name} failed (#{format_time Time.now - start}s): #{$!}"
    250         puts $!.backtrace
    251         mark_failed
    252       end
    253     end
    254   end
    255 
    256   @status = true; attr_reader :status
    257   def mark_failed; @status = false; end
    258 
    259   # Different function, to make `return` inside a `step` throw a
    260   # `LocalJumpError` instead of silently terminating the script... See
    261   # https://ruby-doc.org/core-3.0.2/Proc.html#class-Proc-label-Orphaned+Proc
    262   # https://archive.vn/Q7LIl
    263   def load_file name
    264     instance_eval File.read(name), name
    265   end
    266 
    267   def main
    268     [opt.tools_dir, *opt.extra_tools_dirs].each do |d|
    269       Dir.glob 'ci_conf.*.rb', base: d do |fn|
    270         load_file File.join(d, fn)
    271       end
    272     end
    273     check_steps
    274 
    275     steps = []
    276     ARGV.each do |a|
    277       if a =~ /^([^=]+)=(.*)/
    278         opt.set $1, $2
    279       else
    280         steps << a.to_sym
    281       end
    282     end
    283     run_steps *steps
    284 
    285     status ? einfo('Success') : ewarn('Test failure')
    286     exit status ? 0 : 42
    287   rescue
    288     eerror $!.to_s
    289     puts $!.backtrace
    290     exit 1
    291   end
    292 end
    293 
    294 CI.opt.tools_dir = __dir__
    295 CI.opt.extra_tools_dirs = []
    296 CI.opt.libshit_dir = File.dirname __dir__
    297 CI.opt.extra_path = []
    298 CI.opt.run_dir = Dir.pwd