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 +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
|
- - ">="
|