├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── factor ├── factor.gemspec ├── factor.png ├── lib ├── commands.rb └── factor │ ├── commands │ ├── base.rb │ ├── run_command.rb │ └── workflow_command.rb │ ├── connector.rb │ ├── logger.rb │ ├── version.rb │ └── workflow │ ├── connector_future.rb │ ├── dsl.rb │ ├── future.rb │ └── runtime.rb └── spec ├── commands ├── base_spec.rb ├── run_spec.rb └── workflow_spec.rb ├── connector_spec.rb ├── logger_spec.rb ├── spec_helper.rb └── workflow ├── connector_future_spec.rb ├── dsl_spec.rb ├── future_spec.rb └── runtime_spec.rb /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | *.log 4 | .bundle 5 | .config 6 | coverage 7 | InstalledFiles 8 | lib/bundler/man 9 | pkg 10 | rdoc 11 | spec/reports 12 | test/tmp 13 | test/version_tmp 14 | tmp 15 | 16 | # YARD artifacts 17 | .yardoc 18 | _yardoc 19 | doc/ 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.4 4 | - 2.3.0 5 | deploy: 6 | provider: rubygems 7 | gem: factor 8 | api_key: 9 | secure: mMY5ZBtkbojnbXBLCnMpvdokLoJU+RbeafrQ3CZpOGXK4+mg8hoY79kdNIYNImF4bhOh3G0Ol0ByCsBGcciX1hknWLyrv8ZNNxlhoDLgTozcTIi8Zyd1SkRD360LUxGn5cDOgX+7o226zh6p2B742dVcgIf8Y469cGxT855DpBg= 10 | on: 11 | tags: true 12 | all_branches: true 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | factor (3.0.0) 5 | commander (~> 4.4.0) 6 | concurrent-ruby (~> 1.0.2) 7 | configatron (~> 4.5.0) 8 | rainbow (~> 2.1.0) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | coderay (1.1.1) 14 | commander (4.4.0) 15 | highline (~> 1.7.2) 16 | concurrent-ruby (1.0.2) 17 | configatron (4.5.0) 18 | coveralls (0.8.13) 19 | json (~> 1.8) 20 | simplecov (~> 0.11.0) 21 | term-ansicolor (~> 1.3) 22 | thor (~> 0.19.1) 23 | tins (~> 1.6.0) 24 | diff-lcs (1.2.5) 25 | docile (1.1.5) 26 | ffi (1.9.10) 27 | formatador (0.2.5) 28 | guard (2.13.0) 29 | formatador (>= 0.2.4) 30 | listen (>= 2.7, <= 4.0) 31 | lumberjack (~> 1.0) 32 | nenv (~> 0.1) 33 | notiffany (~> 0.0) 34 | pry (>= 0.9.12) 35 | shellany (~> 0.0) 36 | thor (>= 0.18.1) 37 | guard-compat (1.2.1) 38 | guard-rspec (4.6.5) 39 | guard (~> 2.1) 40 | guard-compat (~> 1.1) 41 | rspec (>= 2.99.0, < 4.0) 42 | highline (1.7.8) 43 | json (1.8.3) 44 | listen (3.1.2) 45 | rb-fsevent (>= 0.9.3) 46 | rb-inotify (>= 0.9.7) 47 | ruby_dep (~> 1.1) 48 | lumberjack (1.0.10) 49 | method_source (0.8.2) 50 | nenv (0.3.0) 51 | notiffany (0.0.8) 52 | nenv (~> 0.1) 53 | shellany (~> 0.0) 54 | pry (0.10.3) 55 | coderay (~> 1.1.0) 56 | method_source (~> 0.8.1) 57 | slop (~> 3.4) 58 | rainbow (2.1.0) 59 | rake (11.1.2) 60 | rb-fsevent (0.9.7) 61 | rb-inotify (0.9.7) 62 | ffi (>= 0.5.0) 63 | rspec (3.4.0) 64 | rspec-core (~> 3.4.0) 65 | rspec-expectations (~> 3.4.0) 66 | rspec-mocks (~> 3.4.0) 67 | rspec-core (3.4.4) 68 | rspec-support (~> 3.4.0) 69 | rspec-expectations (3.4.0) 70 | diff-lcs (>= 1.2.0, < 2.0) 71 | rspec-support (~> 3.4.0) 72 | rspec-mocks (3.4.1) 73 | diff-lcs (>= 1.2.0, < 2.0) 74 | rspec-support (~> 3.4.0) 75 | rspec-support (3.4.1) 76 | ruby_dep (1.2.0) 77 | shellany (0.0.1) 78 | simplecov (0.11.2) 79 | docile (~> 1.1.0) 80 | json (~> 1.8) 81 | simplecov-html (~> 0.10.0) 82 | simplecov-html (0.10.0) 83 | slop (3.6.0) 84 | term-ansicolor (1.3.2) 85 | tins (~> 1.0) 86 | thor (0.19.1) 87 | tins (1.6.0) 88 | 89 | PLATFORMS 90 | ruby 91 | 92 | DEPENDENCIES 93 | coveralls (~> 0.8.13) 94 | factor! 95 | guard (~> 2.13.0) 96 | guard-rspec (~> 4.6.5) 97 | rake (~> 11.1.2) 98 | 99 | BUNDLED WITH 100 | 1.12.1 101 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: "bundle exec rspec --color" do 2 | require "guard/rspec/dsl" 3 | dsl = Guard::RSpec::Dsl.new(self) 4 | 5 | # RSpec files 6 | rspec = dsl.rspec 7 | watch(rspec.spec_helper) { rspec.spec_dir } 8 | watch(rspec.spec_support) { rspec.spec_dir } 9 | watch(rspec.spec_files) 10 | 11 | # Ruby files 12 | ruby = dsl.ruby 13 | dsl.watch_spec_files_for(ruby.lib_files) 14 | end 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) {{{year}}} {{{fullname}}} 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Factor.io Logo](/factor.png) 2 | 3 | [![Code Climate](https://codeclimate.com/github/factor-io/factor.png)](https://codeclimate.com/github/factor-io/factor) 4 | [![Coverage Status](https://coveralls.io/repos/github/factor-io/factor/badge.svg?branch=master)](https://coveralls.io/github/factor-io/factor?branch=master) 5 | [![Dependency Status](https://gemnasium.com/factor-io/factor.svg)](https://gemnasium.com/factor-io/factor) 6 | [![Build Status](https://travis-ci.org/factor-io/factor.svg)](https://travis-ci.org/factor-io/factor) 7 | [![Gem Version](https://badge.fury.io/rb/factor.svg)](http://badge.fury.io/rb/factor) 8 | [![Inline docs](http://inch-ci.org/github/factor-io/factor.svg?branch=master)](http://inch-ci.org/github/factor-io/factor) 9 | 10 | ## What is Factor.io? 11 | Factor.io a Ruby-based DSL for defining and running workflows connecting popular developer tools and services. It is designed to run from the command line, run locally without other service dependencies, very easily extensible, and workflow definitions are stored in files so they can be checked into your project repos. Workflows can run tasks on various tools and services (e.g. create a Github issue, post to Slack, make a HTTP POST call), and they can listen for events too (e.g. listen for a pattern in Slack, open a web hook, or listen for a git push on a branch in Github). Lastly, it supports great concurrency control so you can run many tasks in parallel and aggregate the results. 12 | 13 | ## Install and Setup 14 | This is a gem with a command line interface `factor`. To install: 15 | 16 | gem install factor 17 | 18 | ## Basic Usage 19 | First, we need to install the dependencies (via Bundler). 20 | 21 | **Gemfile**: 22 | ``` 23 | source "https://rubygems.org" 24 | 25 | # Using code from Github for latest (as opposed to RubyGems). 26 | gem 'factor', git: 'https://github.com/factor-io/factor.git' 27 | gem 'factor-connector-web', git: 'https://github.com/factor-io/connector-web.git' 28 | ``` 29 | 30 | In a new project directory create a new file `workflow.rb` like this: 31 | 32 | **workflow.rb**: 33 | ```ruby 34 | require 'factor-connector-web' 35 | 36 | web_hook = run 'web::hook' 37 | 38 | web_hook.on(:trigger) do |post_info| 39 | if post_info[:configured] 40 | success 'Configured and listening on...' 41 | success post_info[:configured][:url] 42 | else 43 | info post_info 44 | end 45 | end 46 | 47 | web_hook.on(:log) do |log_info| 48 | debug log_info[:message] 49 | end 50 | 51 | web_hook.execute 52 | web_hook.wait 53 | ``` 54 | 55 | Now run this from the command line: 56 | ``` 57 | bundle install 58 | bundle exec factor w workflow.rb 59 | ``` 60 | 61 | ## Next 62 | 63 | - [Workflow Syntax](https://github.com/factor-io/factor/wiki/Workflow-Syntax): Factor.io workflows can do all sorts of magic, like running in parallel, aggregating command results, defining sequences, error handling, and more. 64 | - [Connectors](https://github.com/factor-io/factor/wiki/Connectors): These are the officially support Connectors (integrations). 65 | - [Custom Connectors](https://github.com/factor-io/factor/wiki/Creating-a-custom-connector): A guide for creating a custom Connector. 66 | - [More examples](https://github.com/factor-io/example-workflows): This repo contains a library of examples that demonstrate all the Factor.io workflow syntax capabilities as well as the officially supported Conenctors. 67 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | desc 'Run specs' 7 | RSpec::Core::RakeTask.new do |t| 8 | t.verbose = false 9 | t.rspec_opts = '--color --order random' 10 | end 11 | 12 | task default: :spec 13 | -------------------------------------------------------------------------------- /bin/factor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- mode: ruby -*- 3 | 4 | require File.expand_path('../../lib/commands', __FILE__) 5 | -------------------------------------------------------------------------------- /factor.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | $LOAD_PATH.push File.expand_path('../lib', __FILE__) 3 | require 'factor/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'factor' 7 | s.version = Factor::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ['Maciej Skierkowski'] 10 | s.email = ['maciej@factor.io'] 11 | s.homepage = 'https://factor.io' 12 | s.summary = 'CLI and Library for Factor.io runtime' 13 | s.description = 'This is the core of the Factor.io Runtime. The library contains the DSL for defining connectors (plugsin) and workflows. The command line tool can be used to run those workflows' 14 | s.files = Dir.glob('lib/**/*.rb') 15 | s.license = "MIT" 16 | 17 | s.test_files = Dir.glob("./{test,spec,features}/*.rb") 18 | s.executables = ['factor'] 19 | s.require_paths = ['lib'] 20 | 21 | s.add_runtime_dependency 'commander', '~> 4.4.0' 22 | s.add_runtime_dependency 'rainbow', '~> 2.1.0' 23 | s.add_runtime_dependency 'configatron', '~> 4.5.0' 24 | s.add_runtime_dependency 'concurrent-ruby', '~> 1.0.2' 25 | s.add_development_dependency 'coveralls', '~> 0.8.13' 26 | s.add_development_dependency 'rake', '~> 11.1.2' 27 | s.add_development_dependency 'guard', '~> 2.13.0' 28 | s.add_development_dependency 'guard-rspec', '~> 4.6.5' 29 | end 30 | -------------------------------------------------------------------------------- /factor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factor-io/factor/6118ab4b76e0a8fe8a2ceab1369a2403e7380695/factor.png -------------------------------------------------------------------------------- /lib/commands.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'commander/import' 4 | 5 | require 'factor/version' 6 | require 'factor/commands/workflow_command' 7 | require 'factor/commands/run_command' 8 | 9 | program :name, 'Factor.io Server' 10 | program :version, Factor::VERSION 11 | program :description, 'Factor.io Server to run workflows' 12 | 13 | command 'workflow' do |c| 14 | c.syntax = 'factor workflow workflow_file' 15 | c.description = 'Start the Factor.io Server in the current local directory' 16 | c.option '--settings FILE', String, 'factor.yml file path.' 17 | c.option '--verbose', 'Verbose logging' 18 | c.when_called Factor::Commands::WorkflowCommand, :run 19 | end 20 | 21 | command 'run' do |c| 22 | c.syntax = 'factor run service_address params' 23 | c.description = 'Run a specific command.' 24 | c.option '--connector FILE', String, 'file to require for loading method' 25 | c.option '--verbose', 'Verbose logging' 26 | c.option '--settings FILE', String, 'factor.yml file path.' 27 | c.when_called Factor::Commands::RunCommand, :run 28 | end 29 | 30 | alias_command 'w', 'workflow' 31 | alias_command 'r', 'run' -------------------------------------------------------------------------------- /lib/factor/commands/base.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'configatron' 4 | require 'yaml' 5 | require 'fileutils' 6 | require 'factor/logger' 7 | 8 | module Factor 9 | module Commands 10 | # @abstract Subclass to implement new command line commands powered by 11 | # commander. Subclasses use this to get access to protected methods for 12 | # logging and get access to settings. Used by {RunCommand} and 13 | # {WorkflowCommand} 14 | class Command 15 | # @attribute [rw] logger 16 | # @return [Factor::Logger] logger for accepting logs 17 | attr_accessor :logger 18 | 19 | # The default relative path to the settings file. 20 | DEFAULT_SETTINGS_FILENAME = File.expand_path('./.factor.yml') 21 | 22 | # @param [Hash] options the options containing settings for a new command 23 | # @option options [Factor::Logger] logger to be used for logging, by default 24 | # createas a new instance 25 | def initialize(options={}) 26 | @logger = options[:logger] || Factor::Logger.new 27 | end 28 | 29 | # Loads settings from a YAML settings file 30 | # @param [Hash] options the options to select the file/default value 31 | # @option options [Boolean] :verbose (false) whether the method should emit verbose logs 32 | # @option options [String] :settings ('./.factor.yaml') the path to the YAML settings file 33 | # @return [Hash] the settings loaded from the file, also avilable by calling {#settings} 34 | def load_settings(options) 35 | settings_file = DEFAULT_SETTINGS_FILENAME 36 | settings = {} 37 | if options.settings 38 | info "Using '#{options.settings}' settings file" if options.verbose 39 | settings_file = options.settings 40 | else 41 | info "Using default '#{DEFAULT_SETTINGS_FILENAME}' settings file" if options.verbose 42 | end 43 | 44 | begin 45 | absolute_path = File.expand_path(settings_file) 46 | content = File.read(absolute_path) 47 | rescue 48 | warn "Couldn't open the settings file '#{settings_file}', continuing without settings" 49 | end 50 | 51 | begin 52 | settings = YAML.load(content) if content 53 | rescue 54 | warn "Couldn't process the configuration file, continuing without settings" 55 | end 56 | 57 | configatron[:settings].configure_from_hash(settings) 58 | end 59 | 60 | # Gets the current settings that were loaded from the settings file. 61 | # @return [Hash] the settings loaded from the settings file via {#load_settings} 62 | def settings 63 | configatron.settings.to_hash.inject({}){|memo,(k,v)| memo[k.to_s] = v; memo} 64 | end 65 | 66 | protected 67 | 68 | def debug(message) 69 | log(:debug, message) 70 | end 71 | 72 | def info(message) 73 | log(:info, message) 74 | end 75 | 76 | def warn(message) 77 | log(:warn, message) 78 | end 79 | 80 | def error(message) 81 | log(:error, message) 82 | end 83 | 84 | def success(message) 85 | log(:success, message) 86 | end 87 | 88 | def log(type, message) 89 | @logger.log(type, message) if @logger 90 | end 91 | 92 | private 93 | 94 | def try_json(value) 95 | new_value = value 96 | begin 97 | new_value = JSON.parse(value, symbolize_names: true) 98 | rescue JSON::ParserError 99 | end 100 | new_value 101 | end 102 | 103 | 104 | def params(args = []) 105 | request_options = {} 106 | args.each do |arg| 107 | key,value = arg.split(/:/,2) 108 | raise ArgumentError, "Option '#{arg}' is not a valid option" unless key && value 109 | request_options[key.to_sym] = try_json(value) 110 | end 111 | request_options 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/factor/commands/run_command.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'json' 3 | 4 | require 'factor/commands/base' 5 | require 'factor/connector' 6 | 7 | module Factor 8 | module Commands 9 | class RunCommand < Factor::Commands::Command 10 | def run(args, options) 11 | address = args[0] 12 | parameters = params(args[1..-1]) 13 | 14 | load_settings(options) 15 | 16 | connector = load_connector(options, address, parameters) 17 | 18 | if options.verbose 19 | info "Running '#{address}(#{parameters})'" 20 | connector.add_observer(self, :events) 21 | end 22 | response = connector.run 23 | 24 | success "Response:" if options.verbose 25 | @logger.indent options.verbose ? 1 : 0 do 26 | info response 27 | end 28 | end 29 | 30 | def events(type, content) 31 | if type==:log 32 | @logger.indent { 33 | @logger.log(content[:type], content[:message]) 34 | } 35 | end 36 | end 37 | 38 | def load_connector(options, address, parameters) 39 | service_name = address.split('::')[0] 40 | connector_settings = settings[service_name] || {} 41 | 42 | if options.connector 43 | info "Loading #{options.connector}" if options.verbose 44 | require options.connector 45 | end 46 | connector_class = Factor::Connector.get(address) 47 | raise ArgumentError, "Connector '#{address}' not found" unless connector_class 48 | 49 | info "Settings: #{connector_settings || {}}" if options.verbose 50 | info "Parameters: #{parameters || {}}" if options.verbose 51 | 52 | connector = connector_class.new(parameters.merge(connector_settings)) 53 | connector 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/factor/commands/workflow_command.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'factor/commands/base' 4 | require 'factor/workflow/runtime' 5 | require 'factor/logger' 6 | 7 | module Factor 8 | module Commands 9 | # Workflow is a Command to start the factor runtime from the CLI 10 | class WorkflowCommand < Factor::Commands::Command 11 | def run(args, options) 12 | load_settings(options) 13 | 14 | workflow_filename = File.expand_path(args[0]) 15 | info "Loading workflow from '#{workflow_filename}'" if options.verbose 16 | workflow_definition = File.read(workflow_filename) 17 | 18 | info "Starting workflow runtime..." if options.verbose 19 | @logger.indent options.verbose ? 1 : 0 do 20 | runtime = Factor::Workflow::Runtime.new(settings: settings, logger:@logger, verbose: options.verbose) 21 | runtime.load workflow_definition, workflow_filename 22 | end 23 | 24 | success "Workflow completed" if options.verbose 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/factor/connector.rb: -------------------------------------------------------------------------------- 1 | require 'observer' 2 | 3 | module Factor 4 | # @abstract Subclass and override {#run} to implement a connector, optionally also implement #stop 5 | class Connector 6 | include Observable 7 | 8 | # Registers the Connector so that it can be accessed via get 9 | # @param connector [Factor::Connector] Connector to register 10 | def self.register(connector) 11 | raise ArgumentError, "Connector must be a Factor::Connector" unless connector.ancestors.include?(self) 12 | 13 | @@paths ||= {} 14 | @@paths[underscore(connector.name)] = connector 15 | end 16 | 17 | # Retreives a previously register Connector by a string name 18 | # @param path [String] the reference to the class name 19 | def self.get(path) 20 | @@paths ||= {} 21 | @@paths[path] 22 | end 23 | 24 | # Method to override to add the core functionality of the connector. 25 | # @return [Hash] value after executing the connector 26 | def run 27 | end 28 | 29 | protected 30 | 31 | def trigger(data) 32 | changed 33 | notify_observers(:trigger, data) 34 | end 35 | 36 | def debug(message) 37 | log(:debug, message) 38 | end 39 | 40 | def info(message) 41 | log(:info, message) 42 | end 43 | 44 | def warn(message) 45 | log(:warn, message) 46 | end 47 | 48 | def success(message) 49 | log(:success, message) 50 | end 51 | 52 | def error(message) 53 | log(:error, message) 54 | end 55 | 56 | def log(type, message) 57 | changed 58 | notify_observers(:log, {type: type, message:message}) 59 | changed 60 | notify_observers(type, message) 61 | end 62 | 63 | private 64 | 65 | def self.underscore(string) 66 | word = string.dup 67 | word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2') 68 | word.gsub!(/([a-z\d])([A-Z])/,'\1_\2') 69 | word.tr!("-", "_") 70 | word.downcase! 71 | word 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/factor/logger.rb: -------------------------------------------------------------------------------- 1 | require 'rainbow' 2 | 3 | module Factor 4 | # Default text logger used by Factor. Displays logs with timestamps, indentation, and coloring 5 | class Logger 6 | attr_accessor :indentation 7 | 8 | def initialize 9 | @indentation = 0 10 | end 11 | 12 | # Given an indentation level all messages logged inside of the provided block 13 | # will be indented to the defined depth. 14 | # @param indentation [Integer] the depth to indent 15 | def indent(indentation=1, &block) 16 | @indentation += indentation 17 | block.call 18 | @indentation -= indentation 19 | end 20 | 21 | # Logs a string message to standard output using the format `[