rsyntaxtree 1.10.0 → 1.11.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: 38f3abe8841c2097201c6120f900a0d9eb99fb8bea9c6dc8388ac38a8e0df2c7
4
- data.tar.gz: d22ebd93a2a2ed135e4afcc21797f4f057a3b5d340fdfd265a7f5490ccc642fb
3
+ metadata.gz: 6afa75eb042fe177454d387fa097c74fb6b44fd317ae709f91495f2f9b8df269
4
+ data.tar.gz: 60e201c577b1cf02d12f6506b10e62b1b21e419d6d37955e4a69a338b28bfd90
5
5
  SHA512:
6
- metadata.gz: b3c9dc57831246bdd189e92998ba8325606635669e4e63839509d84db3e21911d0eb928f615b2d2d0f5d9ed6e711c2fed3785d3f1453988deef333a5e513ec48
7
- data.tar.gz: eea07050d7df010b52615c475d4681749420c7b16687169c9e5ae3de5fbc6683daf939b9c17795442ed2589ad08dfddd745ad41b6d169f5238f521eaab638d3b
6
+ metadata.gz: 27d3c6eaf4e9d892238f41c7cce05bea11a639351069e80e130af6ad809916e89794dd41bbaf32b407c8560e49b8dc45c243fe7724d4aa36a3a1ce727dc61321
7
+ data.tar.gz: c3744c0b738859a27699dc444f9d27d3169b5d8ee91bd04be2d9296deca560050f9f7cce8db69b7cb980342ef94a1dd2f29e627030b04f7de990cb4adf0850f9
data/.gitattributes ADDED
@@ -0,0 +1,10 @@
1
+ # Text files are stored with LF and checked out with LF, whatever the
2
+ # editor on the machine does. Two of the documentation pages had drifted
3
+ # to CRLF, which turned a three-line edit into a whole-file diff.
4
+ * text=auto eol=lf
5
+
6
+ *.png binary
7
+ *.jpg binary
8
+ *.gif binary
9
+ *.pdf binary
10
+ *.gem binary
data/.gitignore CHANGED
@@ -20,3 +20,6 @@ tags
20
20
  .solargraph.yml
21
21
 
22
22
  CLAUDE.md
23
+
24
+ # git filter-branch leaves this behind; it is machinery, not source.
25
+ .git-rewrite/
data/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.0] - 2026-08
4
+
5
+ Every input the tool accepts now draws, and every input it refuses says why.
6
+ Line thickness follows the type size, so figures drawn at any font size keep
7
+ the same balance between text and rules — existing figures come out about a
8
+ fifth lighter than before.
9
+
10
+ ### Added
11
+ - `--validate` checks input without drawing or writing a file, reporting a
12
+ machine-readable diagnosis on stdout and the verdict in the exit code.
13
+ - `--notation` prints a short reference for the notation, which now ships
14
+ with the gem.
15
+ - Errors carry a code, the offending label and an offset inside it, a
16
+ one-line fix, and whether rewriting the input could help. The message text
17
+ is unchanged. Each cause is confirmed by applying its fix and parsing
18
+ again, so a named cause is one whose fix is known to work.
19
+ - A label may be nothing but a matrix: `[#(HEAD\tnoun#)]` had no spelling
20
+ before, since the enclosure rule took the `#` first.
21
+ - `-f tikz` on the command line, which the documentation had offered while
22
+ the CLI rejected it. TikZ output also carries the line width, which it
23
+ had been accepting and dropping.
24
+ - A gallery figure and a heading of its own for levelling the terminals with
25
+ `<>` joints.
26
+
27
+ ### Changed
28
+ - Line width is a fraction of the font size rather than an absolute number.
29
+ `1` is five per cent of the type size at any size, and the scale runs from
30
+ `0.5` to `3.0` in halves where it ran `1` to `5` and meant `2` to `6`.
31
+ Lines were two units wide whatever the type size, which read as heavily as
32
+ a serif stem at 16 point and heavier still below that, and nothing thinner
33
+ was available.
34
+ - On/off options accept every spelling of off. `mirror: "no"` reversed the
35
+ tree and `transparent: "0"` cut the background away, because anything but
36
+ `"off"` and `"false"` was read as on.
37
+ - Option values are checked against the values the option takes. A value
38
+ nobody defines was taken and read as something else — `direction:
39
+ "left-to-right"` laid the tree out top to bottom. The CLI has always
40
+ refused these; the library did not, which left the web interface and other
41
+ programmatic callers unguarded.
42
+ - Colour names are checked against the CSS colour names. A name nobody
43
+ defines passed validation and drew black.
44
+ - Penn Treebank input converts in the library, not only in the CLI. Other
45
+ callers got no conversion and no error: `(S (NP the dog))` drew as one
46
+ leaf containing that text.
47
+ - Font sizes go down to 6, which the web interface has always offered while
48
+ the documentation said 8.
49
+
50
+ ### Fixed
51
+ - Validation accepted input that drawing then refused: a movement path with
52
+ one end, a tree too wide for a raster surface, a colour spec that fails
53
+ only when the label is parsed. It now generates the drawing and discards
54
+ it, going as far as the requested format can still refuse.
55
+ - A malformed colour was reported as an unclosed enclosure, sending the
56
+ writer to fix a `#` that was never the problem.
57
+ - A left-to-right matrix drew its brackets on top of the attribute names.
58
+ - Text in front of a tree crashed symmetrization, and an empty argument was
59
+ read as a path to the current directory.
60
+ - Overline was documented as missing from PNG output. It has been there.
61
+
3
62
  ## [1.10.0] - 2026-08
4
63
 
5
64
  A transitional release ahead of 2.0, which drops JPG/GIF output and the
data/CITATION.cff CHANGED
@@ -11,8 +11,8 @@ repository-code: "https://github.com/yohasebe/rsyntaxtree"
11
11
  # Concept DOI: always resolves to the latest archived version on Zenodo
12
12
  doi: "10.5281/zenodo.21916150"
13
13
  license: MIT
14
- version: 1.10.0
15
- date-released: 2026-08-19
14
+ version: 1.11.0
15
+ date-released: 2026-08-20
16
16
  keywords:
17
17
  - linguistics
18
18
  - syntax tree
data/README.md CHANGED
@@ -170,7 +170,8 @@ Usage:
170
170
  | `-f, --format` | Output format: png, svg, pdf, lsif, tikz (jpg and gif are deprecated) | `png` |
171
171
  | `-l, --leafstyle` | Leaf style: auto, triangle, bar, nothing | `auto` |
172
172
  | `-n, --fontstyle` | Font style: sans, serif, cjk, mono | `sans` |
173
- | `-s, --fontsize` | Font size: 8-26 | `16` |
173
+ | `-s, --fontsize` | Font size: 6-26 | `16` |
174
+ | `-i, --linewidth` | Line width relative to the font size: 0.5-3.0 (`1` = 5% of the font size) | `1.0` |
174
175
  | `-c, --color` | Color mode: modern, traditional, gray (black text, grey lines), off | `modern` |
175
176
  | `-p, --polyline` | Polyline connectors: on, off | `off` |
176
177
  | `-d, --direction` | Tree layout direction: ttb, ltr | `ttb` |
@@ -178,6 +179,8 @@ Usage:
178
179
  | `--hspacing` | Horizontal spacing factor, all layout modes (0.5-3.0) | `1.0` |
179
180
  | `--hyphen` | How a hyphen reads: markup (`-underline-`) or literal | `markup` |
180
181
  | `-m, --mirror` | Flip the tree horizontally (RTL convention): on, off | `off` |
182
+ | `--validate` | Check the input without drawing: a JSON diagnosis on stdout, the verdict in the exit code | |
183
+ | `--notation` | Print a short reference for the notation | |
181
184
 
182
185
  Run `rsyntaxtree -h` for the full list of options.
183
186
 
data/Rakefile CHANGED
@@ -32,7 +32,12 @@ end
32
32
 
33
33
  Rake::Task["build"].enhance([:normalize_permissions])
34
34
 
35
- desc "Generate SVG and PNG example images locally"
35
+ # For trying something out. It does NOT reproduce the committed gallery:
36
+ # fontconfig resolves a family to whatever this machine has, so the same input
37
+ # measures differently here than in the image the figures were drawn with. Run
38
+ # over docs/assets it rewrites every PNG and about half the SVGs, none of it an
39
+ # intended change. Use docker_generate to update the gallery.
40
+ desc "Generate SVG and PNG example images locally (not for updating the gallery)"
36
41
  task :generate do
37
42
  require_relative "dev/generate_examples"
38
43
  end
@@ -47,7 +52,9 @@ task :docker_build do
47
52
  `docker build ./ -t rsyntaxtree_devel`
48
53
  end
49
54
 
50
- desc "Generate SVG and PNG example images using Docker image"
55
+ # The gallery is drawn here and nowhere else, so that a figure depends on the
56
+ # input and the options rather than on the fonts of whoever regenerated it.
57
+ desc "Generate SVG and PNG example images using Docker image (the gallery)"
51
58
  task :docker_generate do
52
59
  docpath = File.expand_path(File.join(__dir__, "docs"))
53
60
  `docker build ./ -t rsyntaxtree_devel`
data/bin/rsyntaxtree CHANGED
@@ -3,32 +3,37 @@
3
3
 
4
4
  require 'optimist'
5
5
  require 'yaml'
6
+ require 'json'
6
7
 
7
8
  require_relative '../lib/rsyntaxtree'
8
9
  require_relative '../lib/rsyntaxtree/utils'
9
10
  require_relative '../lib/rsyntaxtree/format_converter'
10
11
 
12
+ # "png, jpg, gif, pdf, svg, lsif, or tikz" — built once from the library's
13
+ # own list so the three places that name the formats cannot drift apart.
14
+ FORMAT_LIST_MESSAGE = FORMATS.join(", ").sub(/, ([^,]+)\z/, ', or \1')
15
+
11
16
  # Valid configuration keys and their validators
12
17
  CONFIG_VALIDATORS = {
13
18
  outdir: ->(v) { FileTest.directory?(v) ? nil : "must be an existing directory path" },
14
19
  outfilename: ->(v) { v.is_a?(String) ? nil : "must be a string" },
15
- format: ->(v) { /\A(png|jpg|gif|pdf|svg|lsif)\z/ =~ v.to_s ? nil : "must be png, jpg, gif, pdf, svg, or lsif" },
16
- leafstyle: ->(v) { /\A(auto|triangle|bar|nothing)\z/ =~ v.to_s ? nil : "must be auto, triangle, bar, or nothing" },
17
- fontstyle: ->(v) { /\A(sans|serif|cjk|mono)\z/ =~ v.to_s ? nil : "must be sans, serif, cjk, or mono" },
18
- fontsize: ->(v) { v.is_a?(Integer) && v >= 8 && v <= 26 ? nil : "must be in the range of 8-26" },
19
- linewidth: ->(v) { v.is_a?(Integer) && v >= 1 && v <= 5 ? nil : "must be in the range of 1-5" },
20
+ format: ->(v) { FORMATS.include?(v.to_s) ? nil : "must be #{FORMAT_LIST_MESSAGE}" },
21
+ leafstyle: ->(v) { OPTION_VALUES[:leafstyle].include?(v.to_s) ? nil : "must be auto, triangle, bar, or nothing" },
22
+ fontstyle: ->(v) { OPTION_VALUES[:fontstyle].include?(v.to_s) ? nil : "must be sans, serif, cjk, or mono" },
23
+ fontsize: ->(v) { v.is_a?(Integer) && v >= 6 && v <= 26 ? nil : "must be in the range of 6-26" },
24
+ linewidth: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
20
25
  vheight: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 5.0 ? nil : "must be in the range of 0.5-5.0" },
21
- color: ->(v) { /\A(modern|traditional|gray|grey|on|off|true|false)\z/ =~ v.to_s ? nil : "must be modern, traditional, gray, or off" },
26
+ color: ->(v) { OPTION_VALUES[:color].include?(v.to_s) ? nil : "must be modern, traditional, gray, or off" },
22
27
  symmetrize: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
23
28
  transparent: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
24
29
  polyline: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
25
30
  hide_default_connectors: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
26
31
  mirror: ->(v) { /\A(on|off|true|false)\z/ =~ v.to_s ? nil : "must be on or off" },
27
- tidy: ->(v) { /\A(off|symmetric|low|medium|high|on|compact|true|false)\z/ =~ v.to_s ? nil : "must be off, symmetric, low, medium, or high" },
32
+ tidy: ->(v) { OPTION_VALUES[:tidy].include?(v.to_s) ? nil : "must be off, symmetric, low, medium, or high" },
28
33
  hspacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
29
34
  tidy_spacing: ->(v) { v.is_a?(Numeric) && v >= 0.5 && v <= 3.0 ? nil : "must be in the range of 0.5-3.0" },
30
- direction: ->(v) { /\A(ttb|ltr)\z/ =~ v.to_s ? nil : "must be ttb or ltr" },
31
- hyphen: ->(v) { /\A(markup|literal)\z/ =~ v.to_s ? nil : "must be markup or literal" }
35
+ direction: ->(v) { OPTION_VALUES[:direction].include?(v.to_s) ? nil : "must be ttb or ltr" },
36
+ hyphen: ->(v) { OPTION_VALUES[:hyphen].include?(v.to_s) ? nil : "must be markup or literal" }
32
37
  }.freeze
33
38
 
34
39
  # Validate configuration and return errors/warnings
@@ -102,11 +107,11 @@ opts = Optimist.options do
102
107
 
103
108
  opt :outdir, "Output directory", default: "./", short: :o
104
109
  opt :outfilename, "Output file base name", default: "syntree", short: :u
105
- opt :format, "Output format: png, jpg, gif, pdf, svg, or lsif", default: "png", short: :f
110
+ opt :format, "Output format: #{FORMAT_LIST_MESSAGE}", default: "png", short: :f
106
111
  opt :leafstyle, "visual style of tree leaves: auto, triangle, bar, or nothing", default: "auto", short: :l
107
112
  opt :fontstyle, "Font style: sans, serif, cjk, mono", default: "sans", short: :n
108
- opt :fontsize, "Size: 8-26", default: 16, short: :s
109
- opt :linewidth, "Size: 1-5", default: 1, short: :i
113
+ opt :fontsize, "Size: 6-26", default: 16, short: :s
114
+ opt :linewidth, "Size: 0.5-3.0", default: 1.0, short: :i
110
115
  opt :vheight, "Connector Height: 0.5-5.0", default: 2.0, short: :v
111
116
  opt :color, "Color text and bars: modern, traditional, gray, or off", default: "modern", short: :c
112
117
  opt :symmetrize, "DEPRECATED alias of --tidy symmetric: on or off", default: "off", short: :y
@@ -119,25 +124,27 @@ opts = Optimist.options do
119
124
  opt :tidy_spacing, "DEPRECATED alias of --hspacing: 0.5-3.0", default: 1.0, short: :none
120
125
  opt :direction, "Tree layout direction: ttb (top-to-bottom) or ltr (left-to-right)", default: "ttb", short: :d
121
126
  opt :hyphen, "How a hyphen in a label reads: markup (-underline-) or literal (a hyphen; \-underline\- then underlines)", default: "markup", short: :none
127
+ opt :validate, "Validate the input without drawing or writing a file: print a machine-readable diagnosis (JSON) to stdout and report the result in the exit code", short: :none
128
+ opt :notation, "Print the notation reference to stdout and exit", short: :none
122
129
  opt :help, "This is a custom help message", short: :h
123
130
  end
124
131
 
125
132
  Optimist.die :outdir, "must be an exsting directory path" unless FileTest.directory?(opts[:outdir])
126
- Optimist.die :direction, "must be ttb or ltr" unless /\A(ttb|ltr)\z/ =~ opts[:direction]
127
- Optimist.die :hyphen, "must be markup or literal" unless /\A(markup|literal)\z/ =~ opts[:hyphen]
128
- Optimist.die :format, "must be png, pdf, svg, or lsif" unless /\A(png|jpg|gif|pdf|svg|lsif)\z/ =~ opts[:format]
129
- Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless /\A(auto|triangle|bar|nothing)\z/ =~ opts[:leafstyle]
130
- Optimist.die :fontstyle, "must be sans, serif, cjk, or mono" unless /\A(sans|serif|cjk|mono)\z/ =~ opts[:fontstyle]
131
- Optimist.die :fontsize, "must be in the range of 8-26" unless opts[:fontsize] >= 8 && opts[:fontsize] <= 26
132
- Optimist.die :linewidth, "must be in the range of 1-5" unless opts[:linewidth] >= 1 && opts[:linewidth] <= 5
133
- Optimist.die :color, "must be either modern, traditional, gray, or off" unless /\A(modern|traditional|gray|grey|on|off|true|false)\z/ =~ opts[:color]
133
+ Optimist.die :direction, "must be ttb or ltr" unless OPTION_VALUES[:direction].include?(opts[:direction])
134
+ Optimist.die :hyphen, "must be markup or literal" unless OPTION_VALUES[:hyphen].include?(opts[:hyphen])
135
+ Optimist.die :format, "must be #{FORMAT_LIST_MESSAGE}" unless FORMATS.include?(opts[:format])
136
+ Optimist.die :leafstyle, "must be auto, triangle, bar, or nothing" unless OPTION_VALUES[:leafstyle].include?(opts[:leafstyle])
137
+ Optimist.die :fontstyle, "must be sans, serif, cjk, or mono" unless OPTION_VALUES[:fontstyle].include?(opts[:fontstyle])
138
+ Optimist.die :fontsize, "must be in the range of 6-26" unless opts[:fontsize] >= 6 && opts[:fontsize] <= 26
139
+ Optimist.die :linewidth, "must be in the range of 0.5-3.0" unless opts[:linewidth] >= 0.5 && opts[:linewidth] <= 3.0
140
+ Optimist.die :color, "must be either modern, traditional, gray, or off" unless OPTION_VALUES[:color].include?(opts[:color])
134
141
  Optimist.die :symmetrize, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:symmetrize]
135
142
  Optimist.die :vheight, "must be in the range of 0.5-5.0" if opts[:vheight] < 0.5 || opts[:vheight] > 5.0
136
143
  Optimist.die :transparent, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:transparent]
137
144
  Optimist.die :polyline, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:polyline]
138
145
  Optimist.die :hide_default_connectors, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:hide_default_connectors]
139
146
  Optimist.die :mirror, "must be either on or off" unless /\A(on|off|true|false)\z/ =~ opts[:mirror]
140
- Optimist.die :tidy, "must be off, symmetric, low, medium, or high" unless /\A(off|symmetric|low|medium|high|on|compact|true|false)\z/ =~ opts[:tidy]
147
+ Optimist.die :tidy, "must be off, symmetric, low, medium, or high" unless OPTION_VALUES[:tidy].include?(opts[:tidy])
141
148
  Optimist.die :hspacing, "must be in the range of 0.5-3.0" if opts[:hspacing] < 0.5 || opts[:hspacing] > 3.0
142
149
  Optimist.die :tidy_spacing, "must be in the range of 0.5-3.0" if opts[:tidy_spacing] < 0.5 || opts[:tidy_spacing] > 3.0
143
150
 
@@ -164,7 +171,10 @@ if %w[jpg gif].include?(string_opts[:format])
164
171
  warn "Warning: #{string_opts[:format].upcase} output is deprecated and will be removed in RSyntaxTree 2.0. Use PNG instead."
165
172
  end
166
173
 
167
- data = if ARGV[0] && File.exist?(File.expand_path(ARGV[0]))
174
+ # An argument names a file only if it really is one: File.exist? on an
175
+ # expanded empty string answers for the current directory, and reading that
176
+ # as the tree ended the run with a Ruby error.
177
+ data = if ARGV[0] && !ARGV[0].empty? && File.file?(File.expand_path(ARGV[0]))
168
178
  File.read ARGV[0]
169
179
  elsif ARGV[0]
170
180
  ARGV[0]
@@ -177,8 +187,26 @@ data = if ARGV[0] && File.exist?(File.expand_path(ARGV[0]))
177
187
  # Auto-convert Penn TreeBank format to bracket notation
178
188
  data = RSyntaxTree::FormatConverter.to_bracket(data) if data
179
189
 
190
+ if opts[:notation]
191
+ puts File.read(File.expand_path("../lib/rsyntaxtree/notation_core.md", __dir__))
192
+ exit 0
193
+ end
194
+
195
+ # Validate without drawing or writing anything: the diagnosis goes to stdout
196
+ # as JSON and the exit code carries the verdict.
197
+ if opts[:validate]
198
+ diagnosis = begin
199
+ RSyntaxTree::RSGenerator.check_data(data, string_opts)
200
+ { "schema" => "rsyntaxtree.error/1", "ok" => true }
201
+ rescue RSTError => e
202
+ { "schema" => "rsyntaxtree.error/1" }.merge(e.to_h)
203
+ end
204
+ puts JSON.pretty_generate(diagnosis)
205
+ exit(diagnosis["ok"] ? 0 : 1)
206
+ end
207
+
180
208
  begin
181
- RSyntaxTree::RSGenerator.check_data(data)
209
+ RSyntaxTree::RSGenerator.check_data(data, string_opts)
182
210
  rescue RSTError => e
183
211
  warn e
184
212
  exit 1
@@ -189,7 +217,7 @@ begin
189
217
  rsg = RSyntaxTree::RSGenerator.new(string_opts)
190
218
  ext = string_opts[:format]
191
219
  outfilename = string_opts[:outfilename] || "syntree"
192
- file_ext = ext == "lsif" ? "lsif.json" : ext
220
+ file_ext = FORMAT_EXTENSIONS[ext] || ext
193
221
  filepath = File.expand_path(string_opts[:outdir]) + "/#{outfilename}.#{file_ext}"
194
222
  draw_method = "draw_#{ext}"
195
223
  output = rsg.send(draw_method)
@@ -241,6 +241,11 @@ module RSyntaxTree
241
241
  target = @element_list.get_id(id)
242
242
  if target.children.empty?
243
243
  parent = @element_list.get_id(target.parent)
244
+ # A childless element with no parent is a root standing alone —
245
+ # text in front of the tree makes one. It has no siblings to match
246
+ # widths with, so its own width is already the answer.
247
+ return target.width if parent.nil?
248
+
244
249
  accum_array = []
245
250
  parent.children.each do |c|
246
251
  accum_array << @element_list.get_id(c).width
@@ -416,7 +421,7 @@ module RSyntaxTree
416
421
  x0 = node.horizontal_indent
417
422
  x1 = node.horizontal_indent + node.content_width
418
423
  if [:brackets, :rectangle, :brectangle].include?(node.enclosure)
419
- ext = @global[:h_gap_between_nodes] / 2 + ((@linewidth || 1) + BLINE_SCALING)
424
+ ext = @global[:h_gap_between_nodes] / 2 + @global[:stroke_bold]
420
425
  x0 -= ext
421
426
  x1 += ext
422
427
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ #==========================
4
+ # color_names.rb
5
+ #==========================
6
+ #
7
+ # The color names a label may use: the CSS Color 4 named colors plus
8
+ # "transparent", which is what librsvg accepts when it paints. Validation
9
+ # checks a name against this list because an unknown name does not fail —
10
+ # it silently comes out black, which breaks the promise that what
11
+ # validates also draws. The list is a fixed web standard, so it is
12
+ # embedded rather than asked from an outside source that cannot answer
13
+ # (fontconfig has no color names, and librsvg never rejects one).
14
+ module RSyntaxTree
15
+ COLOR_NAMES = [
16
+ "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige",
17
+ "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown",
18
+ "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue",
19
+ "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod",
20
+ "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen",
21
+ "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue",
22
+ "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue",
23
+ "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen",
24
+ "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray",
25
+ "green", "greenyellow", "grey", "honeydew", "hotpink", "indianred",
26
+ "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen",
27
+ "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray",
28
+ "lightgreen", "lightgrey", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue",
29
+ "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow", "lime", "limegreen",
30
+ "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid",
31
+ "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred",
32
+ "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy",
33
+ "oldlace", "olive", "olivedrab", "orange", "orangered", "orchid",
34
+ "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff",
35
+ "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple",
36
+ "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown",
37
+ "seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue",
38
+ "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan",
39
+ "teal", "thistle", "tomato", "transparent", "turquoise", "violet",
40
+ "wheat", "white", "whitesmoke", "yellow", "yellowgreen",
41
+ ].freeze
42
+ end
@@ -9,6 +9,7 @@
9
9
 
10
10
  require_relative "markup_parser"
11
11
  require_relative "utils"
12
+ require_relative "color_names"
12
13
 
13
14
  module RSyntaxTree
14
15
  class Element
@@ -37,22 +38,23 @@ module RSyntaxTree
37
38
  @fontsize = fontsize
38
39
  @raw_content = content.sub(/\^?(?:\+-?>?<?\d+)+\^?\z/, '')
39
40
 
40
- parsed = Markup.parse(@global[:literal_hyphen] ? swap_hyphen_markup(content) : content)
41
+ parsed = Markup.parse(prepare_markup(content))
41
42
 
42
43
  if parsed[:status] == :success
43
44
  results = parsed[:results]
44
45
  else
45
46
  error_text = +"Error: input text contains an invalid string"
46
47
  error_text += "\n > " + content
47
- raise RSTError, error_text
48
+ raise RSTError.new(error_text, **markup_failure_details(content, parsed[:charpos]))
48
49
  end
49
50
  @content = results[:contents]
50
- @paths = results[:paths]
51
51
  @enclosure = results[:enclosure]
52
52
  @triangle = results[:triangle]
53
53
  @color = results[:color]
54
54
  @region = results[:region]
55
55
  @region_color = results[:region_color]
56
+ validate_color!(@color, content)
57
+ validate_color!(@region_color, content)
56
58
 
57
59
  @contains_phrase = false
58
60
  setup
@@ -81,13 +83,19 @@ module RSyntaxTree
81
83
  # alone: a line of nothing but hyphens is the horizontal rule, and the dash
82
84
  # in a path suffix (+-1, +->2) is what makes that path dashed. Swapping
83
85
  # those turned a rule into the text "---" without a word of complaint.
84
- def swap_hyphen_markup(text)
86
+ # Split a label into lines with the trailing path markers detached, so
87
+ # hyphen handling can spare the two places a run of hyphens means
88
+ # something else: a `---` rule line of its own, and the `+-2` markers
89
+ # at the end. Shared by swap_hyphen_markup and escape_hyphens.
90
+ def self.hyphen_safe_lines(text)
85
91
  path = text[/\^?(?:\+-?>?<?\d+)+\^?\z/]
86
92
  body = path ? text[0...-path.length] : text
87
- swapped = body.split('\n', -1).map do |line|
88
- /\A-{3,}\z/.match?(line) ? line : swap_hyphens(line)
89
- end.join('\n')
90
- swapped + path.to_s
93
+ [body.split('\n', -1), path.to_s]
94
+ end
95
+
96
+ def swap_hyphen_markup(text)
97
+ lines, path = Element.hyphen_safe_lines(text)
98
+ lines.map { |line| /\A-{3,}\z/.match?(line) ? line : swap_hyphens(line) }.join('\n') + path
91
99
  end
92
100
 
93
101
  def swap_hyphens(text)
@@ -98,6 +106,114 @@ module RSyntaxTree
98
106
  text.gsub('\\-', placeholder).gsub("-", '\\-').gsub(placeholder, "-")
99
107
  end
100
108
 
109
+ # Escape the hyphens that open an underline. Shares its exemptions with
110
+ # swap_hyphen_markup through hyphen_safe_lines.
111
+ def self.escape_hyphens(text)
112
+ lines, path = hyphen_safe_lines(text)
113
+ lines.map { |line| /\A-{3,}\z/.match?(line) ? line : line.gsub(/(?<!\\)-/, '\\-') }.join('\n') + path
114
+ end
115
+
116
+ # What the markup parser is actually handed: under hyphen: literal a
117
+ # hyphen and its escape swap roles before parsing.
118
+ def prepare_markup(text)
119
+ @global[:literal_hyphen] ? swap_hyphen_markup(text) : text
120
+ end
121
+
122
+ # One candidate repair per way of getting the notation wrong, tried in
123
+ # order. Each is a whole edit of the label, not a pattern to recognise:
124
+ # the diagnosis below applies one and asks the parser whether the label
125
+ # now parses, so a cause is only ever reported when its fix is known to
126
+ # work. That keeps the list from drifting away from the grammar the way
127
+ # a set of hand-written patterns would — the grammar is the judge.
128
+ MARKUP_REPAIRS = [
129
+ # A malformed color spec ('@' then a bad name or a bad hex) leaves a
130
+ # bare '#' or '@' behind that the enclosure repair below would happily
131
+ # blame itself for. Tried first, so a color mistake is named as one.
132
+ [:invalid_color,
133
+ ->(s) { s.sub(/\A%?@(?:#[0-9a-zA-Z]+|[a-zA-Z]+)?:/, "") },
134
+ "A color is @name: with a CSS color name, or @#rgb: / @#rrggbb: with 3 or 6 hex digits (e.g. @blue:VP, @#3af:VP)."],
135
+ [:angle_brackets,
136
+ ->(s) { s.gsub(/(?<!\\)<([^<>]*[^<>\d][^<>]*)>/) { "〈#{$1}〉" } },
137
+ "'<' and '>' mark whitespace here, not a list. Write the angle bracket characters themselves: 〈NP〉, 'hand〈SUBJ,OBJ〉'."],
138
+ [:bare_hyphen,
139
+ ->(s) { Element.escape_hyphens(s) },
140
+ "A hyphen opens an underline. Escape it (e.g. f\\-structure, V\\-bar) or set the hyphen option to literal."],
141
+ # Every other repair leaves a label it has nothing to do with exactly
142
+ # as it was, and an unchanged label is not tried. Appending always
143
+ # changes one, so this one asks first whether there is an unclosed
144
+ # matrix at all: without that, a label of "^" parses once "#)" is
145
+ # stuck on the end, and would be reported as a matrix left open.
146
+ [:unclosed_matrix,
147
+ ->(s) { s.scan("#(").size > s.scan("#)").size ? s + "#)" : s },
148
+ "A matrix opened with '#(' is never closed with '#)'."],
149
+ # Neutralising every occurrence of one character, rather than adding a
150
+ # closing one, locates the culprit wherever it sits in the label — an
151
+ # opener left unclosed halfway down a matrix is not fixed by appending.
152
+ *{ "*" => "A '*' decoration (italic or bold) is never closed.",
153
+ "|" => "A '|' box is never closed.",
154
+ "_" => "A '_' subscript or superscript is never closed.",
155
+ "{" => "A '{...}' circle is never closed.",
156
+ "=" => "An '=' overline is never closed.",
157
+ "~" => "A '~' strikethrough is never closed.",
158
+ "#" => "A '#' enclosure is not one of #, ## or ###, or a matrix is left open.",
159
+ "<" => "'<' and '>' mark whitespace: <> is one space, <3> is three." }
160
+ .map { |ch, hint| [:unclosed_markup, ->(s) { s.gsub(/(?<!\\)#{Regexp.escape(ch)}/) { "\\#{ch}" } }, hint] },
161
+ [:stray_triangle,
162
+ ->(s) { s.sub(/\A\^+/, "^") }, "Only one '^' may prefix a label."],
163
+ [:incomplete_path,
164
+ ->(s) { s.sub(/(?<!\\)\+>?<?\z/, "") },
165
+ "A path marker needs a number: write +1, or +>1 for the arrowhead."]
166
+ ].freeze
167
+
168
+ # Turn a Markup.parse failure into structured error attributes: a code
169
+ # for machines, the label and the offset inside it for people, a fix
170
+ # that has been checked to work, and whether rewriting could help.
171
+ def markup_failure_details(label, charpos)
172
+ details = { label: label, position: charpos }
173
+
174
+ # Nothing left to parse: the raw spaces around it took the whole label
175
+ # away, one of them starting the split and the next ending it.
176
+ if label.strip.empty?
177
+ return details.merge(code: :label_split,
178
+ hint: "Raw spaces left this label empty. Write a space inside a label as <> (e.g. 〈<>NP<>〉).",
179
+ retryable: true)
180
+ end
181
+
182
+ MARKUP_REPAIRS.each do |code, repair, hint|
183
+ repaired = repair.call(label)
184
+ next if repaired == label
185
+ # Through the same preprocessing the real parse uses, or a label
186
+ # under hyphen: literal would be judged against a different string.
187
+ next unless Markup.parse(prepare_markup(repaired))[:status] == :success
188
+
189
+ return details.merge(code: code, hint: hint, retryable: true)
190
+ end
191
+
192
+ # No single repair worked. In practice this is where two mistakes in
193
+ # one label land — each of them one this could have named alone — so
194
+ # it is the most fixable bucket, not the least: still retryable, with
195
+ # a hint that admits no one cause was found rather than naming a wrong
196
+ # one. retryable: false is for what no rewriting reaches.
197
+ details.merge(code: :invalid_markup,
198
+ hint: "No single cause fits, which usually means more than one mistake in this label. Check that *, _, =, ~, |, { and #(...#) are paired, that hyphens are escaped, and that 〈 〉 are the angle bracket characters.",
199
+ retryable: true)
200
+ end
201
+
202
+ # A hex color is already constrained by the grammar; a named color is
203
+ # checked against the names librsvg can paint, because an unknown name
204
+ # does not fail downstream — it silently comes out black.
205
+ def validate_color!(color, content)
206
+ return if color.nil? || color.start_with?("#")
207
+ return if COLOR_NAMES.include?(color.downcase)
208
+
209
+ raise RSTError.new(+"Error: input text contains an unknown color '#{color}'" \
210
+ "\n > #{content}",
211
+ code: :unknown_color,
212
+ label: content,
213
+ hint: "Use a CSS color name or a hex code: @blue:VP, @#3af:VP, @#33aaff:VP.",
214
+ retryable: true)
215
+ end
216
+
101
217
  def setup
102
218
  layout = measure_lines(@content)
103
219
  @text_width = layout[:width]
@@ -79,8 +79,18 @@ class MarkupParser < Parslet::Parser
79
79
 
80
80
  rule(:markup) { (matrix | tabstop | text | decoration | shape | bstroke) }
81
81
 
82
+ # A label that is one whole matrix. The enclosure rules in :lines would
83
+ # eat the '#' of '#(' before the matrix rule could see it, so a matrix
84
+ # could only live inside a label, never be one. Trying this first is
85
+ # safe: a label starting '#(' that contains the closing '#)' could never
86
+ # parse before (a bare '#' is not consumable outside a matrix), so the
87
+ # only inputs this newly reaches are ones that used to fail. A matrix
88
+ # followed by anything but a path or the end does not match here and
89
+ # falls through to the enclosure reading, exactly as before.
90
+ rule(:whole_label_matrix) { matrix >> (str('+') | cr | eof).present? }
91
+
82
92
  rule(:line) { (cr.as(:extracr) | border | bborder | markup.repeat(1).as(:line) >> (cr | eof | str('+').present?)) }
83
- rule(:lines) { triangle.maybe.as(:triangle) >> (brectangle | rectangle | brackets).maybe.as(:enclosure) >> region.maybe.as(:region) >> color_spec.maybe.as(:color) >> line.repeat(1) >> path.repeat(0).as(:paths) >> (cr | eof) }
93
+ rule(:lines) { triangle.maybe.as(:triangle) >> (whole_label_matrix.as(:whole_label_matrix) | ((brectangle | rectangle | brackets).maybe.as(:enclosure) >> region.maybe.as(:region) >> color_spec.maybe.as(:color) >> line.repeat(1))) >> path.repeat(0).as(:paths) >> (cr | eof) }
84
94
  root :lines
85
95
  end
86
96
 
@@ -177,9 +187,6 @@ module Markup
177
187
  rule(circle: subtree(:text)) {
178
188
  text[:decoration] << :circle; text
179
189
  }
180
- rule(math: subtree(:text)) {
181
- text[:decoration] << :math; text
182
- }
183
190
  rule(border: simple(:border)) {
184
191
  { type: :border }
185
192
  }
@@ -199,15 +206,28 @@ module Markup
199
206
  }
200
207
  end
201
208
 
209
+ # Largest charpos anywhere in a parse-failure cause tree: the position
210
+ # the parser actually reached before giving up.
211
+ def deepest_charpos(cause, best = 0)
212
+ pos = cause.respond_to?(:pos) && cause.pos ? cause.pos.charpos : 0
213
+ best = [best, pos].max
214
+ cause.children&.each { |child| best = deepest_charpos(child, best) }
215
+ best
216
+ end
217
+
202
218
  def parse(txt)
203
219
  begin
204
220
  parsed = @parser.parse(txt)
205
- rescue Parslet::ParseFailed
206
- # puts e.parse_failure_cause.ascii_tree
207
- return { status: :error, text: txt }
221
+ rescue Parslet::ParseFailed => e
222
+ # The cause is kept: the deepest node of the failure tree is where the
223
+ # parse actually got stuck, which is what structured errors report.
224
+ return { status: :error, text: txt, charpos: deepest_charpos(e.parse_failure_cause) }
208
225
  end
209
226
 
210
227
  applied = @evaluator.apply(parsed)
228
+ # A label holding a single named part (a whole-label matrix, nothing
229
+ # else) comes back as one hash, not a one-element array.
230
+ applied = [applied] unless applied.is_a?(Array)
211
231
 
212
232
  results = { enclosure: :none, triangle: false, paths: [], contents: [], color: nil, region: false, region_color: nil }
213
233
  applied.each do |h|
@@ -239,11 +259,13 @@ module Markup
239
259
  results[:color] = color_value
240
260
  end
241
261
  results[:contents] << h if h[:type] == :text || h[:type] == :border || h[:type] == :bborder
262
+ # A label that is one whole matrix arrives under its own key in the
263
+ # top-level hash; the matrix element becomes the label's only line.
264
+ results[:contents] << { type: :text, elements: [h[:whole_label_matrix]] } if h[:whole_label_matrix]
242
265
  end
243
266
  { status: :success, results: results }
244
267
  end
245
268
 
246
- module_function :parse
269
+ module_function :parse, :deepest_charpos
247
270
  end
248
271
 
249
- # pp results = Markup.parse('^#\_\#\+あり\-がとう**_X_**\\n----\\n933\\n__|Y|__+>3+2+1343+>5464')