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 +4 -4
- data/.gitignore +1 -0
- data/Gemfile.lock +1 -1
- data/README.md +59 -13
- data/lib/margin/document.rb +46 -0
- data/lib/margin/item.rb +64 -0
- data/lib/margin/parser.rb +175 -0
- data/lib/margin/version.rb +1 -1
- data/margin.gemspec +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 233a6e36f3ef6ab23c049f53a2dfa1d2835102324ccb0c95d4db8d9075b9a064
|
4
|
+
data.tar.gz: 7ed177ebb4ae5298a16f02e3ad89c7c6a2659636dc93d268536b5314b5d00dfb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 642f0084057a8cbc72553cb7b4c68480bb3d947d82b6be3a6b69c20a10552171b023be0014b4c7e5cf27948343b359749b70f28aade5b8af9182b47ed52439d1
|
7
|
+
data.tar.gz: 86121dc2787e55b910f4924c4d808bfa4cdffc050972969b2ee1d376fdf0ed6d38c46245e2e0d2db5eae86ca240cd233a977343ef878611663bb9ccb8259eab6
|
data/.gitignore
CHANGED
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,12 +1,46 @@
|
|
1
|
-
#
|
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
|
-
|
33
|
+
## Installation
|
4
34
|
|
5
|
-
|
35
|
+
Margin requires Ruby >= 2.6.x.
|
6
36
|
|
7
|
-
|
37
|
+
To use it directly:
|
38
|
+
|
39
|
+
```sh
|
40
|
+
$ gem install margin
|
41
|
+
```
|
8
42
|
|
9
|
-
|
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
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
$ gem install margin
|
51
|
+
```sh
|
52
|
+
$ bundle install
|
53
|
+
```
|
22
54
|
|
23
55
|
## Usage
|
24
56
|
|
25
|
-
|
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
|
-
##
|
74
|
+
## Development Plans
|
34
75
|
|
35
|
-
|
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
|
data/lib/margin/item.rb
ADDED
@@ -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
|
data/lib/margin/version.rb
CHANGED
data/margin.gemspec
CHANGED
@@ -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.
|
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.
|
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
|
+
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.
|
52
|
+
version: 2.6.0
|
50
53
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
51
54
|
requirements:
|
52
55
|
- - ">="
|