diff --git a/exe/referator b/exe/referator new file mode 100755 index 0000000..23f5df6 --- /dev/null +++ b/exe/referator @@ -0,0 +1,63 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +lib = File.expand_path('../lib', __dir__).freeze +$LOAD_PATH.unshift lib unless $LOAD_PATH.include? lib + +require 'json' +require 'referator' + +$stdin.sync = true +$stdout.sync = true + +def parse_command(line) + line = String(line).chomp + command = line[0...line.index(' ')].freeze + rest = line[command.length..].strip.freeze + [command, rest].freeze +end + +workdir = ARGV.first +workdir = Dir.pwd if String(workdir).strip.empty? +workdir = File.expand_path(workdir).freeze + +config = Referator::Config.new workdir +footnotes = Referator::Footnotes.new config + +while (line = $stdin.gets) + command, rest = parse_command line + case command + when 'REGISTER_FORMAT' + data = JSON.parse rest + config.formats.register( + String(data['name']).to_sym, + **data.except('name').transform_keys(&:to_sym), + ) + when 'REGISTER_KIND' + data = JSON.parse rest + config.kinds.register String(data).to_sym + when 'ADD_REF' + data = JSON.parse rest + config.repo.add_ref(**data.transform_keys(&:to_sym)) + when 'ADD_NOTE' + config.freeze + data = JSON.parse rest + puts footnotes.add_note( + String(data['kind']).to_sym, + String(data['id']), + ).to_h.to_json + when 'REF' + config.freeze + data = JSON.parse rest + puts config.repo[ + String(data['kind']).to_sym, + String(data['id']), + ].to_h.to_json + when 'RENDER_FOOTNOTES' + config.freeze + data = JSON.parse rest + puts footnotes.render(String(data).to_sym).to_json + else + raise 'Invalid command' + end +end diff --git a/lib/referator.rb b/lib/referator.rb new file mode 100644 index 0000000..7016dc6 --- /dev/null +++ b/lib/referator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'forwardable' +require 'json' +require 'open3' +require 'pathname' + +require_relative 'referator/config' +require_relative 'referator/config/formats' +require_relative 'referator/config/kinds' +require_relative 'referator/config/repo' +require_relative 'referator/footnotes' +require_relative 'referator/note' +require_relative 'referator/reference' +require_relative 'referator/script' + +module Referator + NAME_RE = /\A\w+(_\w+)*\z/ + SLUG_RE = /\A\w+(-\w+)*\z/ +end diff --git a/lib/referator/config.rb b/lib/referator/config.rb new file mode 100644 index 0000000..8766494 --- /dev/null +++ b/lib/referator/config.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Referator + class Config + attr_reader :workdir + + def initialize(workdir) + self.workdir = workdir + end + + def freeze + formats.freeze + kinds.freeze + repo.freeze + super + end + + def formats(&) + @formats ||= Formats.new self + end + + def kinds(&) + @kinds ||= Kinds.new self + end + + def repo(&) + @repo ||= Repo.new self + end + + private + + def workdir=(workdir) + workdir = Pathname.new(workdir).expand_path.freeze + raise 'Expected absolute path' unless workdir.absolute? + raise 'Expected existing directory' unless workdir.directory? + + @workdir = workdir + end + end +end diff --git a/lib/referator/config/formats.rb b/lib/referator/config/formats.rb new file mode 100644 index 0000000..09efde1 --- /dev/null +++ b/lib/referator/config/formats.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Referator + class Config + class Formats + attr_reader :config, :names, :scripts + + def initialize(config) + self.config = config + @names = [] + @scripts = {} + end + + def freeze + @names.freeze + @scripts.freeze + super + end + + def register(name, **kwargs) + validate_name! name + raise 'Already exists' if names.index(name) || scripts.key?(name) + + script = Script.new(**kwargs.merge(workdir: config.workdir, + vars: script_vars.keys)) + + names << name + scripts[name] = script + nil + end + + def exists!(name) + scripts[name] or raise 'Unknown format' + nil + end + + def render(name, notes) + exists! name + scripts[name].call(**script_vars(format: name, notes: notes.to_json)) + end + + private + + def config=(config) + unless config.instance_of? Config + raise TypeError, "Expected #{Config}, got #{config.class}" + end + + @config = config + end + + def validate_name!(name) + unless name.instance_of? Symbol + raise TypeError, "Expected #{Symbol}, got #{name.class}" + end + raise 'Invalid name' unless NAME_RE.match? name + end + + def script_vars(format: nil, notes: nil) = { format:, notes: }.freeze + end + end +end diff --git a/lib/referator/config/kinds.rb b/lib/referator/config/kinds.rb new file mode 100644 index 0000000..f403153 --- /dev/null +++ b/lib/referator/config/kinds.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Referator + class Config + class Kinds + attr_reader :config, :names + + def initialize(config) + self.config = config + @names = [] + end + + def freeze + @names.freeze + super + end + + def register(name) + validate_name! name + raise 'Already exists' if names.index(name) + + names << name + nil + end + + def exists!(name) + names.index name or raise 'Unknown kind' + nil + end + + private + + def config=(config) + unless config.instance_of? Config + raise TypeError, "Expected #{Config}, got #{config.class}" + end + + @config = config + end + + def validate_name!(name) + unless name.instance_of? Symbol + raise TypeError, "Expected #{Symbol}, got #{name.class}" + end + raise 'Invalid name' unless NAME_RE.match? name + end + end + end +end diff --git a/lib/referator/config/repo.rb b/lib/referator/config/repo.rb new file mode 100644 index 0000000..3c0ce88 --- /dev/null +++ b/lib/referator/config/repo.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Referator + class Config + class Repo + attr_reader :config + + def initialize(config) + self.config = config + @references = {} + end + + def freeze + @references.freeze + super + end + + def add_ref(**kwargs) + reference = Reference.new(**kwargs) + key = "#{reference.kind}-#{reference.id}".freeze + raise 'Reference already exists' if @references.key? key + + @references[key] = reference + nil + end + + def [](kind, id) + config.kinds.exists! kind + key = "#{kind}-#{id}".freeze + @references[key].tap do |reference| + raise 'Invalid reference' if reference.nil? + end + end + + private + + def config=(config) + unless config.instance_of? Config + raise TypeError, "Expected #{Config}, got #{config.class}" + end + + @config = config + end + end + end +end diff --git a/lib/referator/footnotes.rb b/lib/referator/footnotes.rb new file mode 100644 index 0000000..03b0e0c --- /dev/null +++ b/lib/referator/footnotes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Referator + class Footnotes + attr_reader :config + + def initialize(config) + self.config = config + + @notes = [] + end + + def add_note(kind, id) + config.kinds.exists! kind + reference = config.repo[kind, id] + new_note = Note.new self, @notes.count + 1, reference + @notes.each { |note| return note if note.eql? new_note } + @notes << new_note + new_note + end + + def render(format) + config.formats.render format, @notes.map(&:to_h).freeze + end + + private + + def config=(config) + unless config.instance_of? Config + raise TypeError, "Expected #{Config}, got #{config.class}" + end + + @config = config + end + end +end diff --git a/lib/referator/note.rb b/lib/referator/note.rb new file mode 100644 index 0000000..a95afec --- /dev/null +++ b/lib/referator/note.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Referator + class Note + extend Forwardable + + attr_reader :footnotes, :index, :reference + + def_delegators :reference, :anchor, :fragment + + def initialize(footnotes, index, reference) + self.footnotes = footnotes + self.index = index + self.reference = reference + end + + def to_h + @to_h ||= reference.to_h.merge(index:).freeze + end + + def eql?(other) + other.instance_of?(self.class) && + footnotes == other.footnotes && + anchor == other.anchor + end + + def ==(other) = eql?(other) && index == other.index + + private + + def footnotes=(footnotes) + unless footnotes.instance_of? Footnotes + raise TypeError, "Expected #{Footnotes}, got #{footnotes.class}" + end + + @footnotes = footnotes + end + + def index=(index) + index = Integer index + raise ArgumentError, 'Invalid index' unless index.positive? + + @index = index + end + + def reference=(reference) + unless reference.is_a? Reference + raise TypeError, "Expected #{Reference}, got #{reference.class}" + end + + @reference = reference + end + end +end diff --git a/lib/referator/reference.rb b/lib/referator/reference.rb new file mode 100644 index 0000000..78f3ffd --- /dev/null +++ b/lib/referator/reference.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Referator + class Reference + attr_reader :kind, :id, :slug, :data + + def initialize(kind:, id:, slug:, **kwargs) + self.kind = kind + self.id = id + self.slug = slug + self.data = kwargs + end + + def to_h + @to_h ||= data.merge(kind:, id:, slug:, anchor:, fragment:).freeze + end + + def anchor + @anchor ||= "#{kind}-#{slug}".freeze + end + + def fragment + @fragment ||= "##{anchor}".freeze + end + + private + + def kind=(kind) + @kind = String(kind).to_sym.tap do |new_kind| + raise 'Invalid kind' unless NAME_RE.match? new_kind + end + end + + def id=(id) + @id = String(id).freeze + end + + def slug=(slug) + @slug = String(slug).freeze.tap do |new_slug| + raise 'Invalid slug' unless SLUG_RE.match? new_slug + end + end + + def data=(data) + @data = Hash(data).transform_keys { |key| String(key).to_sym }.freeze + end + end +end diff --git a/lib/referator/script.rb b/lib/referator/script.rb new file mode 100644 index 0000000..9e05096 --- /dev/null +++ b/lib/referator/script.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Referator + class Script + attr_reader :workdir, :vars, :args, :env, :stdin + + def initialize(workdir:, vars:, args: [], env: {}, stdin: []) + self.workdir = workdir + self.vars = vars + + self.args = args + self.env = env + self.stdin = stdin + + raise 'Invalid script' if self.args.empty? + end + + def call(**kwargs) + raise 'Invalid vars' unless kwargs.keys.sort == vars + + stdout, status = Open3.capture2( + sub_env(**kwargs), + *sub_args(**kwargs), + chdir: workdir.to_s, + stdin_data: sub_stdin(**kwargs), + ) + raise 'Exit code' unless status.success? + + String(stdout).freeze + end + + private + + def workdir=(workdir) + workdir = Pathname.new(workdir).expand_path.freeze + raise 'Expected absolute path' unless workdir.absolute? + raise 'Expected existing directory' unless workdir.directory? + + @workdir = workdir + end + + def vars=(vars) + @vars = [*vars].uniq.sort.freeze.each do |var| + unless var.instance_of? Symbol + raise TypeError, "Expected #{Symbol}, got #{var.class}" + end + end + end + + def args=(args) + @args = [*args].map { |arg| Sub.new vars, arg }.freeze + end + + def env=(env) + @env = Hash(env).to_h do |key, value| + [String(key).freeze, Sub.new(vars, value)] + end.freeze + end + + def stdin=(stdin) + @stdin = Sub.new vars, stdin + end + + def sub_args(**kwargs) + args.map { |sub| sub.call(**kwargs) }.freeze + end + + def sub_env(**kwargs) + env.transform_values { |sub| sub.call(**kwargs) }.freeze + end + + def sub_stdin(**kwargs) + stdin.call(**kwargs) + end + + class Sub + attr_reader :vars, :template + + def initialize(vars, template) + self.vars = vars + + self.template = + if template.instance_of?(String) || + template.instance_of?(Symbol) || + template.instance_of?(Hash) + [template] + else + template + end + end + + def call(**kwargs) + raise 'Invalid vars' unless kwargs.keys.sort == vars + + template.map do |part| + if part.instance_of? String + part + else + kwargs.fetch part + end + end.join.freeze + end + + private + + def vars=(vars) + @vars = [*vars].uniq.sort.freeze.each do |var| + unless var.instance_of? Symbol + raise TypeError, "Expected #{Symbol}, got #{var.class}" + end + end + end + + def template=(template) + raise 'Invalid format' unless template.instance_of? Array + + @template = template.map do |part| + part = String(part.keys.first).to_sym if part.instance_of? Hash + + unless part.instance_of?(String) || + part.instance_of?(Symbol) && vars.include?(part) + raise 'Invalid format' + end + + part.freeze + end.freeze + end + end + end +end diff --git a/spec/lib/referator/script/sub_spec.rb b/spec/lib/referator/script/sub_spec.rb new file mode 100644 index 0000000..637f813 --- /dev/null +++ b/spec/lib/referator/script/sub_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Referator::Script::Sub do + subject(:sub) { described_class.new vars, template } + + let(:vars) { %i[foo bar car].freeze } + let(:template) { ['qwe', :foo, 'rty', { bar: nil }.freeze].freeze } + + describe '#call' do + subject(:result) { sub.call(**kwargs) } + + let(:kwargs) { { foo: '123', bar: '456', car: '789' }.freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq 'qwe123rty456' } + + context 'when the "vars" array is empty' do + let(:vars) { [].freeze } + let(:template) { %w[qwe rty].freeze } + let(:kwargs) { {}.freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq 'qwerty' } + end + + context 'when template is empty' do + let(:template) { [].freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to be_empty } + end + + context 'when the "vars" array and template are empty' do + let(:vars) { [].freeze } + let(:template) { [].freeze } + let(:kwargs) { {}.freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to be_empty } + end + + context 'when template is a String' do + let(:template) { 'qwe' } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq 'qwe' } + end + + context 'when template is a Symbol' do + let(:template) { :foo } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq '123' } + end + + context 'when template is a Hash' do + let(:template) { { foo: nil }.freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq '123' } + end + end +end diff --git a/spec/lib/referator/script_spec.rb b/spec/lib/referator/script_spec.rb new file mode 100644 index 0000000..71d00aa --- /dev/null +++ b/spec/lib/referator/script_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Referator::Script do + subject(:script) { described_class.new workdir:, vars:, args:, env:, stdin: } + + let(:workdir) { Pathname.new(__dir__).join('../../..').expand_path.freeze } + let(:vars) { %i[foo bar car].freeze } + + let(:args) { ['echo', :foo, { bar: nil }, :car].freeze } + let(:env) { {}.freeze } + let(:stdin) { [].freeze } + + describe '#call' do + subject(:result) { script.call(**kwargs) } + + let(:kwargs) { { foo: '123', bar: '456', car: '789' }.freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq "123 456 789\n" } + + context 'when it depends on env vars' do + let(:args) { ['sh', '-c', ['echo ', :foo, ' $BAR $CAR'].freeze].freeze } + let(:env) { { BAR: :bar, CAR: [:car].freeze }.freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq "123 456 789\n" } + end + + context 'when it depends on stdin' do + let(:args) { 'cat' } + let(:stdin) { [:foo, "\n", :bar, "\n", :car, "\n"].freeze } + + it { is_expected.to be_instance_of String } + it { is_expected.to be_frozen } + it { is_expected.to eq "123\n456\n789\n" } + end + end +end