margin 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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
  - - ">="