module Prick module Build class Parser def self.parse(conn, path, single: true) Parser.new(conn).parse(path, single: single).unit end attr_reader :conn attr_reader :unit # The singular RootBuildNode object def initialize(conn) @conn = conn @unit = nil end # Return a RootBuildNode object. #path can be a file or a directory def parse(path, single: false) File.exist?(path) or raise Error, "Can't find #{path}" if single parse_path(path) else parse_directory(nil, path) end self end private # First built unit is a RootBuildNode, the rest are regular BuildNode objects def make_build_unit(parent, path) if @unit BuildNode.new(parent, path) else @unit = RootBuildNode.new(conn, path) end end def parse_path(path, schema: nil) File.exist?(path) or raise Error, "Can't find #{file}" dir = File.dirname(path) file = File.basename(path) unit = make_build_unit(nil, nil) unit.schema = schema if schema entry = parse_build_entry(unit, dir, file) unit end def parse_directory(parent, dir) build_file = "#{dir}/build.yml".sub(/\/\//, "/") if File.exist? build_file parse_build_file(parent, dir, build_file) else raise Error, "Can't find build.yml in #{dir} while parsing #{parent}" end end def parse_build_file(parent, dir, path) unit = make_build_unit(parent, path) entries = YAML.load(File.read(path)) || [] entries.each { |entry| if entry.is_a?(Hash) && (entry.size != 1 || entry.first.first != "sql") entry.each { |key, value| if key == "schema" unit.schema = value unit.has_schema = true elsif key == "standard" unit.pg_graph_ignore_schema = !value elsif key == "refresh" unit.refresh_schema = value else case key when "init"; unit.init_nodes when "term"; unit.term_nodes when "seed"; unit.seed_nodes else raise Error, "Illegal key in #{unit.path}: #{key}" end.concat(Array(value).map { |value| parse_entry(unit, key.to_sym, dir, value) }.compact) end } else parse_build_entry(unit, dir, entry) end } unit end def parse_build_entry(unit, dir, file) node = parse_entry(unit, :decl, dir, file) or return nil if node.kind == :fox unit.seed_nodes << node else unit.decl_nodes << node end node end # Returns path, filename, and an array of arguments. It is an error if # the file can't be found unless #optional is true. In that case a nil # value is returned def parse_file_entry(unit, dir, entry) entry = entry.sub(/\/$/, "") # puts "#parse_file_entry(#{unit.path.inspect}, #{dir.inspect}, #{entry.inspect})" if entry =~ /^(\S+?)(\?)?(?:\s+(.+))?\s*$/ command = $1 optional = !$2.nil? rest = $3 args = expand_string(rest || '').split path = expand_filename(dir, command) path || optional or raise Error, "Can't find file #{command} #{path} in #{dir}/ from #{unit}" !path.nil? or return nil else raise Error, "Not a file name: '#{entry}'" end [path, Array(args).flatten] end def parse_entry(unit, phase, dir, entry) # puts "#parse_entry(#{unit.inspect}, #{phase.inspect}, #{dir.inspect}, #{entry.inspect})" if entry.is_a?(Hash) entry.size == 1 or raise Error, "sql and module are single-line values" key, value = entry.first case key when "sql" InlineNode.new(unit, phase, unit.path, expand_string(value)) when "call" (path, args = parse_file_entry(unit, dir, value)) or return nil args.size >= 2 or raise Error, "Illegal number of arguments" klass = args.shift command = args.shift klass && command or raise "Illegal number of arguments: #{value}" ModuleNode.new(unit, phase, path, klass, command, args) when "eval" (path, args = parse_file_entry(unit, dir, value)) or return nil EvalNode.new(unit, phase, path, args) when "exec" (path, args = parse_file_entry(unit, dir, value)) or return nil ExecNode.new(unit, phase, path, args) else raise Error, "Illegal key: #{key}" end else (path, args = parse_file_entry(unit, dir, entry)) or return nil if File.directory? path parse_directory(unit, path) elsif File.executable?(path) ExecNode.new(unit, phase, path, args) elsif File.file? path case path when /\.sql$/ SqlNode.new(unit, phase, path) when /\.fox$/ FoxNode.new(unit, :seed, path) when /(?:^|\/)build-.*\.yml$/ parse_build_file(unit, dir, path) when /(?:^|\/)build.yml$/ # Only used when building a single file parse_build_file(unit, dir, path) else raise Error, "Expected executable, fox, or sql file: #{File.basename(path)} in #{dir}" end else raise Error, "Can't find '#{entry}' in #{dir}/ from #{unit}" end end end # Search for an executable in path. Return nil if not found # # Note that "." is ignored in the search path def find_executable(filename) # ChatGPT # puts "#find_executable(#{filename.inspect})" if !Prick.state.environments.key?(filename) # Environment names are reserved Prick.state.executable_search_path.split(File::PATH_SEPARATOR).each do |directory| next if directory == "." path = File.join(directory, filename) return path if File.file?(path) && File.executable?(path) end end nil end # Expand environment variables in the given file name # # #expend_filename substitute '$' expressions in the filename # with the corresponding value in the current environment. If the file # was not found, inherited environments are processed hierarchly with the # special environment variable ENVIRONMENT set to each PRICK_ENVIRONMENT # value in the inherited environments # # Return the resulting path to the file and nil if not found # def expand_filename(dir, filename) envs = Prick.state.environments env = envs[Prick.state.environment] bash_vars = Prick.state.bash_environment.dup last = nil for env in [env] + env.ancestors.reverse bash_vars["ENVIRONMENT"] = env.name file = expand_variables(filename, bash_vars) # last ||= (file != last and file) or return nil # return if no ENVIRONMENT substitution path = (file.start_with?("/") ? file : File.join(dir, file)) # Check for file (may be executable) return path if File.exist?(path) # Check for executable in search path if file doesn't contain a '/' path = find_executable(file) and return path if file !~ /\// end # Return nil if not found return nil end # Expand $ENVIRONMENT variable def expand_string(string) expand_variables(string, Prick.state.bash_environment) end end end end