margin 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb9dc60bfae6e3c83db3a65db9f6acc5190f134f3ad7cf950814d822b0cd089e
4
- data.tar.gz: 754b9388aaf8f10472ad9866f5a5e133cbb33437d7ab562906b38705cb3f9e1c
3
+ metadata.gz: 233a6e36f3ef6ab23c049f53a2dfa1d2835102324ccb0c95d4db8d9075b9a064
4
+ data.tar.gz: 7ed177ebb4ae5298a16f02e3ad89c7c6a2659636dc93d268536b5314b5d00dfb
5
5
  SHA512:
6
- metadata.gz: b551f8c060c5f2bc07cfee1dc4578d7ecac235a6332042575ae5a64e995ecc50ce070852f6b62f86a17b4f98ee76e09d2a784a6307975146c6f6333dcb76c639
7
- data.tar.gz: 202ab73b884e08667e06e552447113170ce98f3cda2de6dfff3f5b4df453ee673ab09137714ca8455169a375239a7691914a84b2d06729387ceda0af1f32b9ef
6
+ metadata.gz: 642f0084057a8cbc72553cb7b4c68480bb3d947d82b6be3a6b69c20a10552171b023be0014b4c7e5cf27948343b359749b70f28aade5b8af9182b47ed52439d1
7
+ data.tar.gz: 86121dc2787e55b910f4924c4d808bfa4cdffc050972969b2ee1d376fdf0ed6d38c46245e2e0d2db5eae86ca240cd233a977343ef878611663bb9ccb8259eab6
data/.gitignore CHANGED
@@ -6,3 +6,4 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
+ *.gem
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- margin (0.1.0)
4
+ margin (0.2.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -1,12 +1,46 @@
1
- # Margin
1
+ # MarginRB
2
+
3
+ This is a Ruby parser for Alex Gamburg's "Margin" markup language.
4
+
5
+ See https://margin.love/ for the documentation,
6
+ and https://github.com/gamburg/margin for Alex's implementation.
7
+
8
+ This should be considered an early Alpha, as many details in the
9
+ spec are still being worked out.
10
+
11
+ ## Divergence from Alex's Implementation
12
+
13
+ To eliminate some ambiguities in the spec and keep the parser
14
+ simple, this implementation refines the specification for Margin
15
+ as follows:
16
+
17
+ 1. Every valid item must end in a newline, which means the document itself
18
+ must end in a newline if there is an item on the last line.
19
+ 2. Items can be one of two types, "item" or "task." To represent this I added
20
+ a 'type' field on the Item's JSON representation. This is mostly informational
21
+ and avoids users of the parsed data needing to do additional checks
22
+ to figure out if an item is a task or not.
23
+ 3. Items which are tasks have a boolean `done` field indicating whether they are done.
24
+ 4. The `value` field on each item is cleaned of any leading or trailing decoration,
25
+ including whitespace. Thus, the `value` field of a Task does not include the leading
26
+ "checkbox" annotation.
27
+ 5. Annotations are represented as objects with a required `value` and an optional `key`.
28
+ In Alex's implementation the key and value of what he calls an "index"
29
+ (an annotation with a colon in it) are not machine parsed.
30
+ 6. If the `value` of an annotation is strictly numeric, it is parsed into a number,
31
+ not a string.
2
32
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/margin`. To experiment with that code, run `bin/console` for an interactive prompt.
33
+ ## Installation
4
34
 
5
- TODO: Delete this and the text above, and describe your gem
35
+ Margin requires Ruby >= 2.6.x.
6
36
 
7
- ## Installation
37
+ To use it directly:
38
+
39
+ ```sh
40
+ $ gem install margin
41
+ ```
8
42
 
9
- Add this line to your application's Gemfile:
43
+ In an application, add this line to your application's Gemfile:
10
44
 
11
45
  ```ruby
12
46
  gem 'margin'
@@ -14,15 +48,22 @@ gem 'margin'
14
48
 
15
49
  And then execute:
16
50
 
17
- $ bundle install
18
-
19
- Or install it yourself as:
20
-
21
- $ gem install margin
51
+ ```sh
52
+ $ bundle install
53
+ ```
22
54
 
23
55
  ## Usage
24
56
 
25
- TODO: Write usage instructions here
57
+ Right this minute there's not much you can do with it except load it into
58
+ a repl and poke it. As an example, supposing you have a file `example.margin`
59
+ in your working directory, you can then run `bin/console` and play with it in
60
+ IRB like so:
61
+
62
+ ```ruby
63
+ d = Margin::Document.from_margin(File.read('example.margin'))
64
+ d.root #=> returns a Margin::Item for the root
65
+ d.to_json #=> returns a JSON representation of the parsed tree
66
+ ```
26
67
 
27
68
  ## Development
28
69
 
@@ -30,10 +71,15 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
30
71
 
31
72
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
73
 
33
- ## Contributing
74
+ ## Development Plans
34
75
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/margin.
76
+ I'm not sure! I saw Alex's ShowHN introducing Margin to the world and thought it looked like a fun parser to crank out. I thought it would take a couple hours... a couple hours turned out to be more like 8, haha :D.
77
+
78
+ I'm having fun poking at this for now and discussing the spec on the main Margin repo. I'll probably add a CLI that will make this more useful. After that, I'm not sure. At that point it's sort of done?
79
+
80
+ ## Contributing
36
81
 
82
+ Feedback, bug reports, and pull requests are welcome on GitHub at https://github.com/burlesona/margin.
37
83
 
38
84
  ## License
39
85
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'margin/parser'
5
+
6
+ module Margin
7
+ class Document
8
+ attr_reader :root
9
+
10
+ def initialize(input="", format: :margin)
11
+ @root = Item.root
12
+ case format
13
+ when :margin then parse_margin!(input)
14
+ when :json then parse_json!(input)
15
+ else raise ArgumentError, "Allowed formats: :margin, :json"
16
+ end
17
+ end
18
+
19
+ def to_margin
20
+ ""
21
+ end
22
+
23
+ def to_json(pretty: false)
24
+ root.to_json(pretty: pretty)
25
+ end
26
+
27
+ private
28
+
29
+ def parse_margin!(input)
30
+ @root = Parser.parse(input)
31
+ end
32
+
33
+ def parse_json!(input)
34
+ end
35
+
36
+ class << self
37
+ def from_margin(input)
38
+ new(input, format: :margin)
39
+ end
40
+
41
+ def from_json(input)
42
+ new(input, format: :json)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Margin
6
+ class Item
7
+ attr_accessor :raw_data,
8
+ :type,
9
+ :value,
10
+ :done,
11
+ :annotations,
12
+ :children
13
+
14
+ def initialize(raw_data: "", type: :item, done: false, value: "", annotations: [], children: [])
15
+ @raw_data = raw_data
16
+ @type = type
17
+ @done = done
18
+ @value = value
19
+ @annotations = annotations
20
+ @children = children
21
+ end
22
+
23
+ def task?
24
+ type == :task
25
+ end
26
+
27
+ def done?
28
+ done
29
+ end
30
+
31
+ def root?
32
+ value == "root"
33
+ end
34
+
35
+ def as_json
36
+ h = {}
37
+ h[:raw_data] = raw_data
38
+ h[:type] = type
39
+ h[:value] = value
40
+ h[:done] = done if type == :task
41
+ h[:annotations] = annotations
42
+ h[:children] = children.map(&:as_json)
43
+ h
44
+ end
45
+
46
+ def to_json(pretty: false)
47
+ pretty ? JSON.pretty_generate(as_json) : JSON.generate(as_json)
48
+ end
49
+
50
+ class << self
51
+ def root
52
+ new raw_data: "root", value: "root"
53
+ end
54
+
55
+ def from_line(line)
56
+ new raw_data: line.raw_data,
57
+ type: line.type,
58
+ value: line.value,
59
+ done: line.done,
60
+ annotations: line.annotations
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+ require 'margin/item'
5
+
6
+ module Margin
7
+
8
+ # Notes:
9
+ # - The parser requires a trailing newline at the end of the last item.
10
+ # - Per the spec: leading and trailing dashes, colons, asterisks,
11
+ # chevrons, underscores and whitespaces, as well as blank lines, are ignored.
12
+ module Parser
13
+
14
+ IGNORED_CHARACTERS = /[\ \-\*:><_]/
15
+ LINE_DECORATION = /#{IGNORED_CHARACTERS}*/
16
+ EMPTY_LINE = /^#{IGNORED_CHARACTERS}*\n/
17
+ TASK_START = /^\[.\]\ /
18
+ ANNOTATION_ONLY = /^\[.*\]$/
19
+
20
+
21
+ module LineMethods
22
+ module_function
23
+ # Get the inner value by scanning past all leading and trailing line decorations.
24
+ def strip_line(string)
25
+ s = StringScanner.new(string)
26
+ raw_inner = ""
27
+ s.skip LINE_DECORATION
28
+ raw_inner += s.getch until s.match? (/#{LINE_DECORATION}$/)
29
+ raw_inner
30
+ end
31
+
32
+ # Detect the offset of a raw line
33
+ def detect_offset(string)
34
+ s = StringScanner.new(string)
35
+ s.match? (/\s*/)
36
+ end
37
+
38
+ # Detect if this matches the criteria for a task,
39
+ # Return the status and rest of string if yes,
40
+ # otherwise return false.
41
+ def detect_task(string)
42
+ return false unless string.match? TASK_START
43
+ done = string[1] != " "
44
+ rest = string.sub(TASK_START,"")
45
+ [done, rest]
46
+ end
47
+
48
+ def detect_annotation_only(string)
49
+ string.match? ANNOTATION_ONLY
50
+ end
51
+
52
+ def extract_annotations(string)
53
+ value = ""
54
+ current_annotation = ""
55
+ annotations = []
56
+ s = StringScanner.new(string)
57
+ in_annotation = false
58
+ until s.eos?
59
+ c = s.getch
60
+ case
61
+ when c == "["
62
+ in_annotation = true
63
+ when c == "]"
64
+ in_annotation = false
65
+ annotations << structure_annotation(current_annotation)
66
+ current_annotation = ""
67
+ when in_annotation
68
+ current_annotation += c
69
+ else
70
+ value += c
71
+ end
72
+ end
73
+ value = value.strip
74
+ value = nil if value.length == 0
75
+ [value, annotations]
76
+ end
77
+
78
+ def structure_annotation(string)
79
+ first, last = string.split(":",2)
80
+ if last
81
+ { key: first.strip, value: extract_annotation_value(last) }
82
+ else
83
+ { value: extract_annotation_value(first) }
84
+ end
85
+ end
86
+
87
+ # Check if a value is really numeric, return the clean value
88
+ def extract_annotation_value(string)
89
+ string = string.strip
90
+ case
91
+ when i = Integer(string, exception: false) then i
92
+ when f = Float(string, exception: false) then f
93
+ else string
94
+ end
95
+ end
96
+ end
97
+
98
+
99
+ class Line
100
+ include LineMethods
101
+
102
+ attr_reader :raw_data,
103
+ :offset,
104
+ :type,
105
+ :done,
106
+ :value,
107
+ :annotations
108
+
109
+ def initialize(raw_data)
110
+ @raw_data = raw_data
111
+ @offset = detect_offset(raw_data)
112
+ @annotations = { notes: [], indexes: {} }
113
+ parse!
114
+ end
115
+
116
+ private
117
+
118
+ def parse!
119
+ raw_inner = strip_line(raw_data)
120
+
121
+ if detect_annotation_only(raw_inner)
122
+ @type = :annotation
123
+ elsif result = detect_task(raw_inner)
124
+ @type = :task
125
+ @done, raw_inner = result
126
+ else
127
+ @type = :item
128
+ end
129
+
130
+ @value, @annotations = extract_annotations(raw_inner)
131
+ end
132
+ end
133
+
134
+
135
+ module_function
136
+
137
+ def parse(text)
138
+ s = StringScanner.new(text)
139
+ lines = []
140
+ until s.eos?
141
+ raw = s.scan_until(/\n/)&.chomp
142
+ break if raw.nil? # probably should handle this with a custom error
143
+ lines << Line.new(raw) unless raw.match? (/^#{IGNORED_CHARACTERS}*$/)
144
+ end
145
+ parse_items Item.root, lines
146
+ end
147
+
148
+ def parse_items(parent, lines)
149
+ return parent if !lines.any?
150
+ while this_line = lines.shift do
151
+ this_item = nil
152
+
153
+ # handle this line
154
+ case this_line.type
155
+ when :item, :task
156
+ this_item = Item.from_line(this_line)
157
+ parent.children << this_item
158
+ when :annotation
159
+ parent.annotations += this_line.annotations
160
+ else
161
+ raise TypeError, "Unknown line type: `#{this_line.type}`"
162
+ end
163
+
164
+ break if this_item.nil?
165
+ break if lines.empty?
166
+
167
+ # now look ahead to decide what to do next
168
+ next_line = lines.first
169
+ parse_items(this_item, lines) if next_line.offset > this_line.offset
170
+ break if next_line.offset < this_line.offset
171
+ end
172
+ parent
173
+ end
174
+ end
175
+ end
@@ -1,3 +1,3 @@
1
1
  module Margin
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.description = "Converts margin-formatted text into Ruby data (or the inverse), and can also import and export compatible JSON."
11
11
  spec.homepage = "https://aburleson.com"
12
12
  spec.license = "MIT"
13
- spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
13
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.6.0")
14
14
 
15
15
  spec.metadata["homepage_uri"] = spec.homepage
16
16
  spec.metadata["source_code_uri"] = "https://github.com/burlesona/margin-rb"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: margin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Burleson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-05-11 00:00:00.000000000 Z
11
+ date: 2020-05-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Converts margin-formatted text into Ruby data (or the inverse), and can
14
14
  also import and export compatible JSON.
@@ -29,6 +29,9 @@ files:
29
29
  - bin/console
30
30
  - bin/setup
31
31
  - lib/margin.rb
32
+ - lib/margin/document.rb
33
+ - lib/margin/item.rb
34
+ - lib/margin/parser.rb
32
35
  - lib/margin/version.rb
33
36
  - margin.gemspec
34
37
  homepage: https://aburleson.com
@@ -46,7 +49,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
46
49
  requirements:
47
50
  - - ">="
48
51
  - !ruby/object:Gem::Version
49
- version: 2.3.0
52
+ version: 2.6.0
50
53
  required_rubygems_version: !ruby/object:Gem::Requirement
51
54
  requirements:
52
55
  - - ">="