From e6da42c210d93459c7b5ba4fd3286f60de650c3f Mon Sep 17 00:00:00 2001 From: wheatevo Date: Sun, 14 Apr 2019 11:45:56 -0500 Subject: [PATCH] Complete initial implementation --- .rubocop.yml | 3 + lib/md_transformer.rb | 1 + lib/md_transformer/markdown.rb | 106 ++++++++----- lib/md_transformer/markdown/section.rb | 38 +++++ spec/markdown_spec.rb | 203 ++++++++++++++++++++++++- 5 files changed, 312 insertions(+), 39 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 128ad81..5db4df4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,9 @@ Metrics/BlockLength: ExcludedMethods: ['describe', 'context'] +Metrics/ClassLength: + Max: 200 + Metrics/LineLength: Max: 120 diff --git a/lib/md_transformer.rb b/lib/md_transformer.rb index 27ff1fd..58889b3 100644 --- a/lib/md_transformer.rb +++ b/lib/md_transformer.rb @@ -4,4 +4,5 @@ module MdTransformer class Error < ::StandardError; end class InvalidMarkdownPath < MdTransformer::Error; end + class HeaderTooDeep < MdTransformer::Error; end end diff --git a/lib/md_transformer/markdown.rb b/lib/md_transformer/markdown.rb index eed0e81..63f627c 100644 --- a/lib/md_transformer/markdown.rb +++ b/lib/md_transformer/markdown.rb @@ -4,10 +4,15 @@ module MdTransformer # Class representing a parsed markdown file class Markdown + include Comparable + include Enumerable + attr_accessor :title attr_reader :content + attr_reader :children + attr_reader :parent - LOWEST_CHILD_PRECEDENCE = 7 + LOWEST_PRECEDENCE = 6 def initialize(source = '', options = {}) @parent = options[:parent] @@ -22,13 +27,42 @@ def initialize(source = '', options = {}) end def content=(value) - parse(value) + # Reflow the content headers based on the child's levels (raise exception if level exceeds 6) + m = Markdown.new(value.to_s) + + # Reassign the parent of the children to the current object and ensure the new depth is valid + @children = m.children + @children.each do |c| + c.instance_variable_set(:@parent, self) + validate_levels(c) + end + + @content = m.content end def keys @children.map(&:title) end + def key?(key) + !self[key].nil? + end + + def values + @children + end + + def value?(value) + !@children.find { |c| c == value }.nil? + end + + def dig(key, *rest) + value = self[key] + return value if value.nil? || rest.empty? + + value.dig(*rest) + end + # Retrieves the markdown value at a given key if it exists (or nil) def [](key) @children.find { |c| c.title == key } @@ -36,18 +70,17 @@ def [](key) # Sets the markdown value at a given key def []=(key, value) - child = self[key] - unless child - # Key does not exist, create a new child - @children.push(Markdown.new(value, title: key, parent: self)) - return - end - child.content = value + child = self[key] || Markdown.new('', title: key, parent: self) + child.content = value.to_s + @children.push(child) unless key?(key) end - def to_s + # Creates a string representing the markdown document's content + def to_s(options = { title: true }) title_str = @title.to_s.empty? ? '' : "#{'#' * level} #{@title}\n" - "#{title_str}#{@content}#{@children.map(&:to_s).join}" + md_string = "#{options[:title] ? title_str : ''}#{@content}#{@children.map(&:to_s).join}" + md_string << "\n" unless md_string.end_with?("\n") + md_string end def write(filename, options: { create_dir: true }) @@ -65,15 +98,34 @@ def level @parent.level + 1 end + def each + return enum_for(__method__) unless block_given? + @children.each do |child| + yield child.title, child + end + end + + def <=>(other) + to_s <=> other.to_s + end + private + def validate_levels(child) + if child.level >= LOWEST_PRECEDENCE + raise HeaderTooDeep, "#{child.title} header level (h#{child.level}) is beyond h#{LOWEST_PRECEDENCE}" + end + + child.children.each { |c| validate_levels(c) } + end + # Parses the provided markdown string content into a nested data structure by header section def parse(content) @children = [] @content = '' # Parse all direct children and create new markdown objects - sections = parse_sections(content) + sections = Section.parse_sections(content) # No children! if sections.empty? @@ -84,8 +136,12 @@ def parse(content) # Populate content prior to found headers @content = content[0..sections.first.location.begin - 1] if sections.first.location.begin > 0 + parse_children(sections) + end + + def parse_children(sections) # Go through the headers sequentially to find all direct children (base on header level vs. current level) - last_child_level = LOWEST_CHILD_PRECEDENCE + last_child_level = LOWEST_PRECEDENCE + 1 sections.each do |s| # Finish parsing if we encounter a sibling (same level) or aunt/uncle (higher level) @@ -97,29 +153,5 @@ def parse(content) end end end - - def code_blocks(content) - block_regex = /^([`~]{3}.*?^[`~]{3})$/m - content.enum_for(:scan, block_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) } - end - - def parse_sections(content) - code_ranges = code_blocks(content) - header_regex = /^(\#{1,6}\s+.*)$/ - header_ranges = content.enum_for(:scan, header_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) } - header_ranges.reject! { |header_range| code_ranges.any? { |code_range| code_range.include?(header_range.begin) } } - header_ranges.each_with_index do |h, i| - content_end = content.length - header_ranges[i] = { header: h, content: ((h.end + 1)..(content_end - 1)) } - end - header_ranges.map! do |h| - Section.new( - content[h[:header]].match(/^#+\s+(.*)$/)[1], - level: content[h[:header]].split.first.count('#'), - location: h[:header].begin..h[:content].end, - content: content[h[:content]] - ) - end - end end end diff --git a/lib/md_transformer/markdown/section.rb b/lib/md_transformer/markdown/section.rb index 5bdd396..ec17c61 100644 --- a/lib/md_transformer/markdown/section.rb +++ b/lib/md_transformer/markdown/section.rb @@ -16,6 +16,44 @@ def initialize(title, options = {}) @location = options[:location].nil? ? 0..0 : options[:location] @content = options[:content] || '' end + + def self.parse_sections(content) + headers = header_ranges(content) + headers.each_with_index do |h, i| + content_end = content.length + headers[i] = { header: h, content: ((h.end + 1)..(content_end - 1)) } + end + headers.map! { |h| create_section_from_header(h, content) } + end + + class << self + private + + def header_ranges(content) + code_ranges = code_blocks(content) + hdr_regex = /^(\#{1,6}\s+.*)$/ + hdr_ranges = content.enum_for(:scan, hdr_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) } + hdr_ranges.reject { |hdr_range| code_ranges.any? { |code_range| code_range.include?(hdr_range.begin) } } + end + + def code_blocks(content) + block_regex = /^([`~]{3}.*?^[`~]{3})$/m + content.enum_for(:scan, block_regex).map { Regexp.last_match.begin(0)..Regexp.last_match.end(0) } + end + + def create_section_from_header(header, content) + Section.new( + content[header[:header]].match(/^#+\s+(.*)$/)[1], + level: header_level(content[header[:header]]), + location: header[:header].begin..header[:content].end, + content: content[header[:content]] + ) + end + + def header_level(header) + header.split.first.count('#') + end + end end end end diff --git a/spec/markdown_spec.rb b/spec/markdown_spec.rb index 969deda..5bcad7a 100644 --- a/spec/markdown_spec.rb +++ b/spec/markdown_spec.rb @@ -25,6 +25,126 @@ end end + describe '#content=' do + let(:md) { described_class.new(@basic_md_content) } + + context 'when string content is passed' do + it 'replaces existing content' do + expect(md.content = "My new content\n").to eq("My new content\n") + expect(md.to_s).to eq("My new content\n") + end + + it 'replaces existing nested content' do + expect(md['H1-1']['H2-1'].content = "More new content\n").to eq("More new content\n") + expect(md['H1-1']['H2-1'].to_s).to eq("## H2-1\nMore new content\n") + end + end + + context 'when Markdown content is passed' do + it 'replaces existing content with the passed markdown content' do + md.content = described_class.new("# H1\nMy new content\n") + expect(md.to_s).to eq("# H1\nMy new content\n") + end + + it 'replaces existing nested content with the passed markdown content and reflows header nesting' do + md['H1-1'].content = described_class.new("# H1\nMy new content\n") + expect(md['H1-1'].to_s).to eq("# H1-1\n## H1\nMy new content\n") + end + end + + context 'when string content is passed with too many header levels (results in h7+)' do + it 'raises a HeaderTooDeep exception' do + expect do + md['H1-1'].content = "# t\n## t\n### t\n#### t\n##### t\n###### t\n" + end.to raise_error(MdTransformer::HeaderTooDeep) + end + end + end + + describe '#keys' do + let(:md) { described_class.new(@basic_md_content) } + + context 'when children exist' do + it 'returns an array of all direct descendent keys' do + expect(md.keys).to eq(['H1-1']) + expect(md['H1-1'].keys).to eq(['H2-1', 'H2-2', 'H2-3']) + end + end + + context 'when children do not exist' do + it 'returns an empty array' do + expect(md['H1-1']['H2-1'].keys).to eq([]) + end + end + end + + describe '#key?' do + let(:md) { described_class.new(@basic_md_content) } + + context 'when the given key exists' do + it 'returns true' do + expect(md.key?('H1-1')).to be true + end + end + + context 'when the given key does not exist' do + it 'returns false' do + expect(md.key?('H1-5')).to be false + end + end + end + + describe '#values' do + let(:md) { described_class.new(@basic_md_content) } + + context 'when children exist' do + it 'returns an array of all direct descendent values' do + md['H1-1'].values.each do |v| + expect(v.parent).to eq(md['H1-1']) + expect(v).to be_a(described_class) + end + end + end + + context 'when children do not exist' do + it 'returns an empty array' do + expect(md['H1-1']['H2-1'].values).to eq([]) + end + end + end + + describe '#value?' do + let(:md) { described_class.new(@basic_md_content) } + + context 'when the value exists in direct descendent values' do + it 'returns true' do + expect(md['H1-1']['H2-2'].value?(md['H1-1']['H2-2']['H3-1'])).to be true + end + end + + context 'when the value does not exist in direct descendent values' do + it 'returns false' do + expect(md['H1-1']['H2-2'].value?('Not a member')).to be false + end + end + end + + describe '#dig' do + let(:md) { described_class.new(@basic_md_content) } + + context 'when the key to be dug exists' do + it 'returns the Markdown object' do + expect(md.dig('H1-1', 'H2-2', 'H3-1')).to be_a(described_class) + end + end + + context 'when the key to be dug does not exist' do + it 'returns nil' do + expect(md.dig('H1-1', 'H2-5', 'H4-1')).to be nil + end + end + end + describe '#[]' do let(:md) { described_class.new(@basic_md_content) } @@ -67,6 +187,20 @@ expect(md['H1-1'].to_s).to eq("# H1-1\nMuch shorter, ahh...\n") end end + + context 'when the key to be retrieved does not exist and headered content is passed' do + it 'reflows the content, updates the key, and returns the new value' do + expect(md['H1-new'] = "# My new content\n## child 1\n").to eq("# My new content\n## child 1\n") + expect(md['H1-new'].to_s).to eq("# H1-new\n## My new content\n### child 1\n") + end + end + + context 'when the key to be retrieved does exist and headered content is passed' do + it 'reflows the content, updates the key, and returns the new value' do + expect(md['H1-1'] = "# My new content\n## child 1\n").to eq("# My new content\n## child 1\n") + expect(md['H1-1'].to_s).to eq("# H1-1\n## My new content\n### child 1\n") + end + end end describe '#to_s' do @@ -88,11 +222,76 @@ describe '#write' do context 'when initialized with no content' do let(:md) { described_class.new } - it 'writes an empty file' do + it 'writes an empty file containing a single newline' do empty_md = File.join(@temp_dir, 'empty.md') md.write(empty_md) expect(File.exist?(empty_md)).to be true - expect(File.read(empty_md)).to be_empty + expect(File.read(empty_md)).to eq("\n") + end + end + end + + describe '#root?' do + let(:md) { described_class.new(@basic_md_path, file: true) } + context 'when the current object is the root of the Markdown file' do + it 'returns true' do + expect(md.root?).to be true + end + end + + context 'when the current object is not the root of the Markdown file' do + it 'returns false' do + expect(md['H1-1'].root?).to be false + end + end + end + + describe '#level' do + let(:md) { described_class.new(@basic_md_path, file: true) } + context 'when the current object is the root of the Markdown file' do + it 'returns 0' do + expect(md.level).to eq(0) + end + end + + context 'when the current object 2 levels beneath the root' do + it 'returns 2' do + expect(md['H1-1']['H2-1'].level).to eq(2) + end + end + end + + describe '#each' do + let(:md) { described_class.new(@basic_md_path, file: true) } + context 'when no block is passed' do + it 'returns an enumerator for the object' do + expect(md.each).to be_a(Enumerator) + end + end + + context 'when a block with 2 args is passed' do + it 'returns the key and value of each child element' do + md.each do |k, v| + expect(k).to be_a(String) + expect(v).to be_a(described_class) + end + end + end + end + + describe '#==' do + let(:md) { described_class.new(@basic_md_path, file: true) } + let(:md2) { md.dup } + context 'when 2 objects are passed with equivalent content' do + it 'returns true' do + expect(md == md2).to be true + end + end + + context 'when 2 objects are passed with differing content' do + it 'returns false' do + md2['H1-1'] = 'Nope' + expect(md == 'Not the same content').to be false end end end