rsyntaxtree 2.0.0 → 2.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: 9ccd433053f296c9189c9eceb87f61c91ed815ff6b1be77f1cd2115a73eaf1cd
4
- data.tar.gz: f769d68efb73c1aafc9dc9af9f0e4e7cbd7a0c741caf253905ebe11dc3c454be
3
+ metadata.gz: fe4cd7a8b28ffa968f99ff228e3a182a64d420e0405cd27e120b5f4ff02fe48e
4
+ data.tar.gz: 23a64934f448707de2b55cad4c206d65fab18aa7d6c1cd3aab0b1871efd9be1e
5
5
  SHA512:
6
- metadata.gz: 0e6218bc0ddf1f4c49e8fac2c8972cbd3e0eb251fe7087150ea73f63b054fe546483588e582a0f01e52ab26c20fd883ab2c67da07adf1f3120755447a7956945
7
- data.tar.gz: b0810c0df9ede2cd1c36100ae9a3c055ece2d59261f8278ef8de8b79e07921702c7b8db83fa43f81810184c363b860bae3d9a9c1f45b4a02307f5f8220354fa8
6
+ metadata.gz: 2dfd53e10a62187649fb3d7285fa38189ef25cf56d6af7ceea27fba3cc7358b1f7d528db6466607061120fab61a2ff7bd9b84fa0b989e4313faaac306af42235
7
+ data.tar.gz: 7399787ea179bdd17910fa9695cc292a18cb5b8e9b3948e943bf32fd52fd05f89e6ec0e2792138197d56c839b9960bab678489a976590667eca9b4dd80679029
data/.dockerignore ADDED
@@ -0,0 +1,5 @@
1
+ # The image needs the working tree, not the repository: without this, ADD .
2
+ # copies .git into every image — hundreds of megabytes of history, including
3
+ # refs that exist only on the build machine.
4
+ .git
5
+ pkg
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.0] - 2026-09-01
4
+
5
+ ### Changed
6
+ - Validation reports every error of a stage, not just the first one found.
7
+ An input with three bad labels lists all three in the `errors` array of
8
+ the JSON diagnosis, so one round of fixes covers them. The stages are
9
+ ordered — options, bracket structure, labels, whole-tree checks — and a
10
+ mistake stops the later stages, with a `note` saying that fixing what is
11
+ listed may reveal more; nothing is reported whose appearance is an
12
+ artifact of an earlier mistake. Duplicate failures collapse to one entry
13
+ and the list stops at twenty. The schema is unchanged
14
+ (`rsyntaxtree.error/1`): the same array, more of it.
15
+
16
+ ### Added
17
+ - `RSGenerator.diagnose(text, options)` returns the full diagnosis as a
18
+ hash — what `--validate` prints — for programmatic callers.
19
+ `check_data` keeps its contract of raising the first error.
20
+
21
+ ## [2.1.0] - 2026-08-29
22
+
23
+ ### Changed
24
+ - The JSON output's format name is `json`: `-f json`, writing `syntree.json`.
25
+ The old name `lsif` collided with an established code-intelligence format
26
+ of the same initials, and called itself an interchange format when only
27
+ one tool had ever written or read it. It remains an accepted alias — the
28
+ CLI says so on stderr — until 3.0. The document now identifies itself in a
29
+ top-level `format` object (`name: "rsyntaxtree-json"`, schema 0.4.0) in
30
+ place of the `lsif` key.
31
+
32
+ ### Added
33
+ - A feature matrix in the JSON output carries its rows: cells split on the
34
+ tab stops, rules as rows of their own, and a matrix inside a cell
35
+ recursing. It used to arrive as an empty segment marked "matrix", its
36
+ attribute-value pairs recoverable only by re-parsing the raw label.
37
+
3
38
  ## [2.0.0] - 2026-08-28
4
39
 
5
40
  The removals announced in 1.10.0 and 1.12.0, and the fixes that came out of
@@ -0,0 +1,85 @@
1
+
2
+ # Contributor Covenant Code of Conduct
3
+
4
+ ## Our Pledge
5
+
6
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
7
+
8
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
9
+
10
+ ## Our Standards
11
+
12
+ Examples of behavior that contributes to a positive environment for our community include:
13
+
14
+ * Demonstrating empathy and kindness toward other people
15
+ * Being respectful of differing opinions, viewpoints, and experiences
16
+ * Giving and gracefully accepting constructive feedback
17
+ * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
18
+ * Focusing on what is best not just for us as individuals, but for the overall community
19
+
20
+ Examples of unacceptable behavior include:
21
+
22
+ * The use of sexualized language or imagery, and sexual attention or advances of any kind
23
+ * Trolling, insulting or derogatory comments, and personal or political attacks
24
+ * Public or private harassment
25
+ * Publishing others' private information, such as a physical or email address, without their explicit permission
26
+ * Other conduct which could reasonably be considered inappropriate in a professional setting
27
+
28
+ ## Enforcement Responsibilities
29
+
30
+ Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
31
+
32
+ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
33
+
34
+ ## Scope
35
+
36
+ This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
37
+
38
+ ## Enforcement
39
+
40
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at <yohasebe@gmail.com>. All complaints will be reviewed and investigated promptly and fairly.
41
+
42
+ All community leaders are obligated to respect the privacy and security of the reporter of any incident.
43
+
44
+ ## Enforcement Guidelines
45
+
46
+ Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
47
+
48
+ ### 1. Correction
49
+
50
+ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
51
+
52
+ **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
53
+
54
+ ### 2. Warning
55
+
56
+ **Community Impact**: A violation through a single incident or series of actions.
57
+
58
+ **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
59
+
60
+ ### 3. Temporary Ban
61
+
62
+ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
63
+
64
+ **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
65
+
66
+ ### 4. Permanent Ban
67
+
68
+ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
69
+
70
+ **Consequence**: A permanent ban from any sort of public interaction within the community.
71
+
72
+ ## Attribution
73
+
74
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
75
+
76
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
77
+
78
+ For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
79
+
80
+ [homepage]: https://www.contributor-covenant.org
81
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
82
+ [Mozilla CoC]: https://github.com/mozilla/diversity
83
+ [FAQ]: https://www.contributor-covenant.org/faq
84
+ [translations]: https://www.contributor-covenant.org/translations
85
+
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,89 @@
1
+ # Contributing to RSyntaxTree
2
+
3
+ This page explains how to report a problem, propose a change, and get a
4
+ development environment running.
5
+
6
+ ## Reporting bugs
7
+
8
+ Open an issue at <https://github.com/yohasebe/rsyntaxtree/issues> with:
9
+
10
+ - the input text (the bracket notation) and the options you used
11
+ - what you expected and what you got — for drawing problems, attach the image
12
+ - your platform and how you run RSyntaxTree (gem, Docker, or the
13
+ [web interface](https://yohasebe.com/rsyntaxtree))
14
+
15
+ If the input fails to draw, include the output of:
16
+
17
+ ```bash
18
+ rsyntaxtree --validate "your input here"
19
+ ```
20
+
21
+ It reports the problem in a structured form that is usually enough to
22
+ diagnose the issue.
23
+
24
+ ## Suggesting features
25
+
26
+ Open an issue describing what you want to draw and, ideally, a hand-drawn or
27
+ published example of the intended output. The
28
+ [gallery](https://yohasebe.github.io/rsyntaxtree/examples) shows the range of
29
+ figures the notation covers today; a suggestion that names the linguistic
30
+ framework it serves is easier to evaluate than one described purely in terms
31
+ of graphics.
32
+
33
+ Not every good idea belongs in RSyntaxTree. The project deliberately keeps
34
+ the notation small, so proposals are weighed against what they add for
35
+ users who do not need them.
36
+
37
+ ## Setting up for development
38
+
39
+ System libraries first (Pango for text measurement, librsvg for rasterizing):
40
+
41
+ ```bash
42
+ # Debian/Ubuntu
43
+ apt install libpango1.0-dev librsvg2-dev libgirepository1.0-dev gobject-introspection
44
+
45
+ # macOS
46
+ brew install pkg-config pango librsvg gobject-introspection
47
+ ```
48
+
49
+ The tests measure text with the Noto fonts, so install them too — the
50
+ [Fonts](https://yohasebe.github.io/rsyntaxtree/documentation#fonts) section
51
+ of the manual lists the package names for each platform.
52
+
53
+ Then:
54
+
55
+ ```bash
56
+ git clone https://github.com/yohasebe/rsyntaxtree.git
57
+ cd rsyntaxtree
58
+ bundle install
59
+ bundle exec rake test
60
+ ```
61
+
62
+ The tests should pass before and after your change.
63
+
64
+ ## Making changes
65
+
66
+ - Every behavioral change needs a test that fails without it.
67
+ - The committed gallery figures are generated inside Docker
68
+ (`rake docker_build && rake docker_generate`) so that font differences
69
+ between machines do not rewrite them. Do not regenerate them with a local
70
+ `rake generate`; if your change intentionally alters figures, say so in
71
+ the pull request and the maintainer will regenerate them.
72
+ - The manual lives in `docs/documentation.md` (English) and
73
+ `docs/documentation_ja.md` (Japanese). If your change affects the
74
+ notation or an option, update at least the English manual; the reference
75
+ the gem installs (`lib/rsyntaxtree/notation_core.md`) and the LLM payload
76
+ (`rake llm_payload`) must be regenerated when examples or the manual
77
+ change — the test suite tells you when they are stale.
78
+
79
+ ## Pull requests
80
+
81
+ Fork, branch, and open a pull request against `master`. A good pull request
82
+ explains the problem before the solution and stays small enough to review.
83
+ CI runs the test suite on Linux and an installation check on macOS; both
84
+ must pass.
85
+
86
+ ## Questions
87
+
88
+ For anything that does not fit an issue, contact the maintainer:
89
+ Yoichiro Hasebe <yohasebe@gmail.com>.
data/README.md CHANGED
@@ -69,7 +69,7 @@ Connect nodes with lines or arrows:
69
69
 
70
70
  ### Multiple Output Formats
71
71
 
72
- Generate trees in PNG, SVG, PDF, TikZ, or LSIF (JSON) format.
72
+ Generate trees in PNG, SVG, PDF, TikZ, or JSON format — the last carrying the laid-out tree as structured data.
73
73
 
74
74
  ## Web Interface
75
75
 
@@ -135,23 +135,17 @@ See [Fonts](https://yohasebe.github.io/rsyntaxtree/documentation#install-fonts-f
135
135
  gem install rsyntaxtree
136
136
  ```
137
137
 
138
- ### macOS Installation Notice
138
+ ### macOS
139
139
 
140
- **Important for macOS users:** If you encounter build errors for native extensions (`gobject-introspection`, `cairo-gobject`, `gio2`), run the following commands before installing RSyntaxTree:
141
-
142
- ```bash
143
- gem install gobject-introspection -- --with-ldflags="-Wl,-undefined,dynamic_lookup"
144
- gem install cairo-gobject -- --with-ldflags="-Wl,-undefined,dynamic_lookup"
145
- gem install gio2 -- --with-ldflags="-Wl,-undefined,dynamic_lookup"
146
- ```
147
-
148
- Then install RSyntaxTree:
149
-
150
- ```bash
151
- gem install rsyntaxtree
152
- ```
153
-
154
- Alternatively, use the [Docker image](https://hub.docker.com/r/yohasebe/rsyntaxtree) or the [web interface](https://yohasebe.com/rsyntaxtree).
140
+ With the libraries in place (`brew install pkg-config pango librsvg
141
+ gobject-introspection`), a plain `gem install rsyntaxtree` builds the native
142
+ extensions without further options — CI verifies this on every change. If an
143
+ older setup still fails to build `gobject-introspection`, `cairo-gobject` or
144
+ `gio2`, install those three first with
145
+ `-- --with-ldflags="-Wl,-undefined,dynamic_lookup"` appended, then install
146
+ RSyntaxTree; or skip the build entirely with the
147
+ [Docker image](https://hub.docker.com/r/yohasebe/rsyntaxtree) or the
148
+ [web interface](https://yohasebe.com/rsyntaxtree).
155
149
 
156
150
  ## Usage
157
151
 
@@ -170,7 +164,7 @@ Usage:
170
164
  | Option | Description | Default |
171
165
  |--------|-------------|---------|
172
166
  | `-o, --outdir` | Output directory | `./` |
173
- | `-f, --format` | Output format: png, svg, pdf, lsif, tikz | `png` |
167
+ | `-f, --format` | Output format: png, svg, pdf, json, tikz | `png` |
174
168
  | `-l, --leafstyle` | Leaf style: auto, triangle, bar, nothing | `auto` |
175
169
  | `-n, --fontstyle` | Font style: sans, serif, cjk, mono | `sans` |
176
170
  | `-s, --fontsize` | Font size: 6-26 | `16` |
data/bin/rsyntaxtree CHANGED
@@ -9,7 +9,7 @@ require_relative '../lib/rsyntaxtree'
9
9
  require_relative '../lib/rsyntaxtree/utils'
10
10
  require_relative '../lib/rsyntaxtree/format_converter'
11
11
 
12
- # "png, pdf, svg, lsif, or tikz" — built once from the library's
12
+ # "png, pdf, svg, json, or tikz" — built once from the library's
13
13
  # own list so the three places that name the formats cannot drift apart.
14
14
  FORMAT_LIST_MESSAGE = FORMATS.join(", ").sub(/, ([^,]+)\z/, ', or \1')
15
15
 
@@ -17,7 +17,7 @@ FORMAT_LIST_MESSAGE = FORMATS.join(", ").sub(/, ([^,]+)\z/, ', or \1')
17
17
  CONFIG_VALIDATORS = {
18
18
  outdir: ->(v) { FileTest.directory?(v) ? nil : "must be an existing directory path" },
19
19
  outfilename: ->(v) { v.is_a?(String) ? nil : "must be a string" },
20
- format: ->(v) { FORMATS.include?(v.to_s) ? nil : "must be #{FORMAT_LIST_MESSAGE}" },
20
+ format: ->(v) { FORMATS.include?(v.to_s) || v.to_s == "lsif" ? nil : "must be #{FORMAT_LIST_MESSAGE}" },
21
21
  leafstyle: ->(v) { OPTION_VALUES[:leafstyle].include?(v.to_s) ? nil : "must be auto, triangle, bar, or nothing" },
22
22
  fontstyle: ->(v) { OPTION_VALUES[:fontstyle].include?(v.to_s) ? nil : "must be sans, serif, cjk, or mono" },
23
23
  fontsize: ->(v) { v.is_a?(Integer) && v >= 6 && v <= 26 ? nil : "must be in the range of 6-26" },
@@ -133,6 +133,13 @@ opts = Optimist.options do
133
133
  opt :help, "This is a custom help message", short: :h
134
134
  end
135
135
 
136
+ # The name the JSON format wore from 1.11.0 to 2.0.0. Accepted until 3.0,
137
+ # and said out loud, the way JPG and GIF were seen out.
138
+ if opts[:format] == "lsif"
139
+ warn "Warning: the format name 'lsif' is now 'json'. The old name will be removed in RSyntaxTree 3.0."
140
+ opts[:format] = "json"
141
+ end
142
+
136
143
  Optimist.die :outdir, "must be an exsting directory path" unless FileTest.directory?(opts[:outdir])
137
144
  Optimist.die :direction, "must be one of: #{OPTION_VALUES[:direction].join(', ')}" unless OPTION_VALUES[:direction].include?(opts[:direction])
138
145
  Optimist.die :derivation, "must be one of: #{OPTION_VALUES[:derivation].join(', ')}" unless OPTION_VALUES[:derivation].include?(opts[:derivation])
@@ -207,12 +214,9 @@ data = RSyntaxTree::FormatConverter.to_bracket(data) if data
207
214
  # Validate without drawing or writing anything: the diagnosis goes to stdout
208
215
  # as JSON and the exit code carries the verdict.
209
216
  if opts[:validate]
210
- diagnosis = begin
211
- RSyntaxTree::RSGenerator.check_data(data, string_opts)
212
- { "schema" => "rsyntaxtree.error/1", "ok" => true }
213
- rescue RSTError => e
214
- { "schema" => "rsyntaxtree.error/1" }.merge(e.to_h)
215
- end
217
+ diagnosis = { "schema" => "rsyntaxtree.error/1" }.merge(
218
+ RSyntaxTree::RSGenerator.diagnose(data, string_opts)
219
+ )
216
220
  puts JSON.pretty_generate(diagnosis)
217
221
  exit(diagnosis["ok"] ? 0 : 1)
218
222
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #==========================
4
- # lsif_graph.rb
4
+ # json_graph.rb
5
5
  #==========================
6
6
  #
7
7
  # Generates LSIF (Linguistic Structure Interchange Format) JSON output.
@@ -12,7 +12,7 @@ require_relative 'base_graph'
12
12
  require_relative 'utils'
13
13
 
14
14
  module RSyntaxTree
15
- class LsifGraph < BaseGraph
15
+ class JSONGraph < BaseGraph
16
16
  attr_accessor :width, :height
17
17
 
18
18
  def initialize(element_list, params, global)
@@ -30,7 +30,7 @@ module RSyntaxTree
30
30
  @global = global
31
31
  end
32
32
 
33
- def lsif_data
33
+ def json_data
34
34
  metrics = parse_list
35
35
  @width = metrics[:width] + @global[:h_gap_between_nodes] * 2
36
36
  @height = metrics[:height] + @global[:height_connector_to_text] / 2
@@ -241,14 +241,7 @@ module RSyntaxTree
241
241
  element.content.each do |l|
242
242
  next unless l[:type] == :text
243
243
 
244
- segments = l[:elements].map do |e|
245
- next if e[:decoration].include?(:whitespace) && e[:text].strip.empty?
246
-
247
- {
248
- text: e[:text].gsub(WHITESPACE_BLOCK, " ").gsub('&#62;', '>').gsub('&#60;', '<'),
249
- decorations: e[:decoration].map(&:to_s).reject { |d| d == "whitespace" }
250
- }
251
- end.compact
244
+ segments = l[:elements].map { |e| build_segment(e) }.compact
252
245
  lines << { segments: segments } unless segments.empty?
253
246
  end
254
247
 
@@ -258,6 +251,43 @@ module RSyntaxTree
258
251
  }
259
252
  end
260
253
 
254
+ def build_segment(run)
255
+ return nil if run[:decoration].include?(:whitespace) && run[:text].strip.empty?
256
+
257
+ segment = {
258
+ text: run[:text].gsub(WHITESPACE_BLOCK, " ").gsub('&#62;', '>').gsub('&#60;', '<'),
259
+ decorations: run[:decoration].map(&:to_s).reject { |d| d == "whitespace" }
260
+ }
261
+ # A feature matrix used to reach this document as an empty segment
262
+ # marked "matrix", its attribute-value pairs recoverable only by
263
+ # re-parsing the raw label — which is no way to score whether a model
264
+ # wrote CASE nom where CASE nom was wanted. The rows come through now:
265
+ # cells split on the tab stops, rules kept as rows of their own, a
266
+ # matrix inside a cell recursing.
267
+ segment[:matrix] = { rows: build_matrix_rows(run[:matrix]) } if run[:decoration].include?(:matrix) && run[:matrix]
268
+ segment
269
+ end
270
+
271
+ def build_matrix_rows(matrix_lines)
272
+ matrix_lines.filter_map do |line|
273
+ case line[:type]
274
+ when :border then { rule: "single" }
275
+ when :bborder then { rule: "double" }
276
+ when :text
277
+ cells = [[]]
278
+ line[:elements].each do |run|
279
+ if run[:decoration].include?(:tabstop)
280
+ cells << []
281
+ else
282
+ segment = build_segment(run)
283
+ cells.last << segment if segment
284
+ end
285
+ end
286
+ { cells: cells }
287
+ end
288
+ end
289
+ end
290
+
261
291
  def map_enclosure(enclosure)
262
292
  case enclosure
263
293
  when :brackets then "brackets"
@@ -273,8 +303,14 @@ module RSyntaxTree
273
303
 
274
304
  def build_json
275
305
  data = {
276
- lsif: {
277
- version: "0.3.0",
306
+ # What this document is, said by a field rather than by the key, so a
307
+ # reader checks ["format"]["name"] and the key never has to change
308
+ # again. Documents up to schema 0.3.0 said it with a top-level "lsif"
309
+ # key instead — a name that collided with the code-intelligence
310
+ # format of the same initials.
311
+ format: {
312
+ name: "rsyntaxtree-json",
313
+ version: "0.4.0",
278
314
  generator: "rsyntaxtree #{RSyntaxTree::VERSION}",
279
315
  level: "rendered"
280
316
  },
@@ -87,7 +87,7 @@ Options (the command line spells them --like-this):
87
87
 
88
88
  | option | what it decides |
89
89
  |------------|-----------------|
90
- | format | png, svg, pdf, tikz, lsif |
90
+ | format | png, svg, pdf, tikz, json (the tree as structured data) |
91
91
  | fontstyle | sans, serif, mono, cjk |
92
92
  | fontsize | 6–26 |
93
93
  | color | modern, traditional, off, gray |
@@ -1,4 +1,4 @@
1
- RSyntaxTree examples: 80 trees, every one verified to draw.
1
+ RSyntaxTree examples: 81 trees, every one verified to draw.
2
2
 
3
3
  Each is the input behind a figure in the gallery at https://yohasebe.github.io/rsyntaxtree/examples.
4
4
  The settings line names the options the gallery records for that figure;
@@ -2586,30 +2586,56 @@ VAL\t#(SPR\t⟨<>⟩#)#)#)
2586
2586
  VAL\t#(SPR\t⟨<>*NP*<>⟩#)#)#) [barks]]]
2587
2587
  ```
2588
2588
 
2589
- ## 085 — A tree on a tilted plane
2589
+ ## 085 — A tree on a tilted plane, with a movement path
2590
2590
 
2591
2591
  Category: General
2592
2592
  Settings: fontstyle=noto-serif shear=20 vheight=1.0
2593
2593
 
2594
2594
  ```
2595
- [S
2596
- [NP
2597
- [D the]
2598
- [N man]
2599
- ]
2600
- [VP
2601
- [V put]
2602
- [NP
2603
- [D the]
2604
- [N book]
2605
- ]
2606
- [PP
2607
- [P on]
2595
+ [CP
2596
+ [NP what+>1]
2597
+ [C'
2598
+ [C did]
2599
+ [TP
2608
2600
  [NP
2609
2601
  [D the]
2610
- [N table]
2602
+ [N man]
2603
+ ]
2604
+ [VP
2605
+ [V'
2606
+ [V put]
2607
+ [NP *t*+1]
2608
+ ]
2609
+ [PP
2610
+ [P on]
2611
+ [NP
2612
+ [D the]
2613
+ [N table]
2614
+ ]
2615
+ ]
2611
2616
  ]
2612
2617
  ]
2613
2618
  ]
2614
2619
  ]
2615
2620
  ```
2621
+
2622
+ ## 086 — Feature structures on a tilted plane
2623
+
2624
+ Category: General
2625
+ Settings: color=none fontstyle=noto-serif leafstyle=nothing shear=20 vheight=1.5
2626
+
2627
+ ```
2628
+ [#(*phrase*\
2629
+ ---\
2630
+ SYN\t#(HEAD\t|1|#)#)
2631
+ [#(*word*\
2632
+ ---\
2633
+ SYN\t#(HEAD\t#(*noun*\
2634
+ ---\
2635
+ AGR\t|2|#(NUM\t*pl*#)#)#)#) [dogs]]
2636
+ [#(*word*\
2637
+ ---\
2638
+ SYN\t#(HEAD\t|1|#(*verb*\
2639
+ ---\
2640
+ AGR\t|2|#)#)#) [bark]]]
2641
+ ```
@@ -15,9 +15,23 @@ require_relative 'utils'
15
15
  module RSyntaxTree
16
16
  class StringParser
17
17
  attr_accessor :data, :elist, :pos, :id, :level
18
+ attr_reader :collected_errors
18
19
 
19
- def initialize(str, fontset, fontsize, global)
20
+ # In collect mode, reporting stops here but the walk does not: labels
21
+ # past this many failures are still checked (the tree must stay whole)
22
+ # without adding to the list.
23
+ COLLECTED_ERRORS_LIMIT = 20
24
+
25
+ # What stands in for a label that would not parse, when collecting.
26
+ # Any label that always parses will do; what matters is that it keeps
27
+ # the tree's shape.
28
+ PLACEHOLDER_LABEL = "x"
29
+
30
+ def initialize(str, fontset, fontsize, global, collect_errors: false)
20
31
  @global = global
32
+ @collect_errors = collect_errors
33
+ @collected_errors = []
34
+ @more_errors = false
21
35
  # Clean up the data a little to make processing easier
22
36
  # repeated newlines => a newline
23
37
  string = str.gsub(/[\n\r]+/m, "\n")
@@ -113,6 +127,41 @@ module RSyntaxTree
113
127
  restore_rule_names_without_a_rule
114
128
  end
115
129
 
130
+ def more_errors?
131
+ @more_errors
132
+ end
133
+
134
+ # One element, or its recorded failure. Outside collect mode this is
135
+ # exactly the Element.new it wraps. In collect mode an RSTError is
136
+ # recorded and a placeholder element stands in, so the walk continues
137
+ # and every bad label is seen. The placeholder keeps the tree's shape —
138
+ # parenthood, childlessness, level — which is all a later label's
139
+ # verdict can depend on; what it cannot keep (a path marker the broken
140
+ # label carried) only matters to the whole-tree checks, and those do
141
+ # not run while collected errors stand.
142
+ def element_or_recorded_failure(id, parent, level, names_a_rule = false)
143
+ yield
144
+ rescue RSTError => e
145
+ raise unless @collect_errors
146
+
147
+ record_failure(e)
148
+ Element.new(id, parent, PLACEHOLDER_LABEL, level, @fontset, @fontsize, @global, names_a_rule)
149
+ end
150
+
151
+ # The same mistake in two copies of a label is one thing to fix, so a
152
+ # failure is recorded once per (code, label, position). Past the limit
153
+ # the list stops growing and only the fact that there was more is kept.
154
+ def record_failure(error)
155
+ key = [error.code, error.label, error.position]
156
+ return if @collected_errors.any? { |c| [c.code, c.label, c.position] == key }
157
+
158
+ if @collected_errors.length >= COLLECTED_ERRORS_LIMIT
159
+ @more_errors = true
160
+ else
161
+ @collected_errors << error
162
+ end
163
+ end
164
+
116
165
  # A rule name names the step that produced a node from its daughters. A node
117
166
  # with no daughters is the product of no step, so what looked like a name is
118
167
  # a column of the label like any other, and it goes back.
@@ -128,8 +177,17 @@ module RSyntaxTree
128
177
  next unless e.children.empty?
129
178
  next if e.label_with_rule_name.nil?
130
179
 
131
- restored = Element.new(e.id, e.parent, e.label_with_rule_name,
132
- e.level, @fontset, @fontsize, @global)
180
+ # In collect mode the failed restoration is recorded and the element
181
+ # kept as it is; the tree stays whole either way.
182
+ restored = begin
183
+ Element.new(e.id, e.parent, e.label_with_rule_name,
184
+ e.level, @fontset, @fontsize, @global)
185
+ rescue RSTError => err
186
+ raise unless @collect_errors
187
+
188
+ record_failure(err)
189
+ next
190
+ end
133
191
  restored.children = e.children
134
192
  restored.type = e.type
135
193
  @elist.elements[i] = restored
@@ -226,7 +284,9 @@ module RSyntaxTree
226
284
  # Check for escaped square brackets
227
285
  if token =~ /\A\\\[/ || token =~ /\A\\\]/
228
286
  # Treat escaped brackets as regular text
229
- element = Element.new(@id, parent, token, @level, @fontset, @fontsize, @global)
287
+ element = element_or_recorded_failure(@id, parent, @level) do
288
+ Element.new(@id, parent, token, @level, @fontset, @fontsize, @global)
289
+ end
230
290
  @id += 1
231
291
  @elist.add(element)
232
292
  else
@@ -241,7 +301,7 @@ module RSyntaxTree
241
301
  tl = token_r.length
242
302
  parts[1] = token_r[spaceat, tl - spaceat].join
243
303
 
244
- element = begin
304
+ element = element_or_recorded_failure(@id, parent, @level, true) do
245
305
  Element.new(@id, parent, parts[0], @level, @fontset, @fontsize, @global, true)
246
306
  rescue RSTError => e
247
307
  # The first raw space splits a token into the node's label
@@ -277,11 +337,15 @@ module RSyntaxTree
277
337
  @elist.add(element)
278
338
  newparent = element.id
279
339
 
280
- element = Element.new(@id, @id - 1, parts[1], @level + 1, @fontset, @fontsize, @global)
340
+ element = element_or_recorded_failure(@id, @id - 1, @level + 1) do
341
+ Element.new(@id, @id - 1, parts[1], @level + 1, @fontset, @fontsize, @global)
342
+ end
281
343
  @id += 1
282
344
  else
283
345
  joined = token_r.join
284
- element = Element.new(@id, parent, joined, @level, @fontset, @fontsize, @global, true)
346
+ element = element_or_recorded_failure(@id, parent, @level, true) do
347
+ Element.new(@id, parent, joined, @level, @fontset, @fontsize, @global, true)
348
+ end
285
349
  @id += 1
286
350
  newparent = element.id
287
351
  end
@@ -291,7 +355,9 @@ module RSyntaxTree
291
355
  end
292
356
  else
293
357
  if token.strip != ""
294
- element = Element.new(@id, parent, token, @level, @fontset, @fontsize, @global)
358
+ element = element_or_recorded_failure(@id, parent, @level) do
359
+ Element.new(@id, parent, token, @level, @fontset, @fontsize, @global)
360
+ end
295
361
  @id += 1
296
362
  @elist.add(element)
297
363
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "2.0.0"
4
+ VERSION = "2.2.0"
5
5
  end
data/lib/rsyntaxtree.rb CHANGED
@@ -34,12 +34,12 @@ WHITESPACE_BLOCK = "■"
34
34
  # Every format the library can produce, and the file extension each one
35
35
  # writes. The CLI builds its --format validation, help text, and output
36
36
  # filename from these, so a new format is added here and nowhere else.
37
- FORMATS = %w[png pdf svg lsif tikz].freeze
37
+ FORMATS = %w[png pdf svg json tikz].freeze
38
38
  FORMAT_EXTENSIONS = {
39
39
  "png" => "png",
40
40
  "pdf" => "pdf",
41
41
  "svg" => "svg",
42
- "lsif" => "lsif.json",
42
+ "json" => "json",
43
43
  "tikz" => "tex" # forest code, to be included in a LaTeX document
44
44
  }.freeze
45
45
 
@@ -51,7 +51,10 @@ FORMAT_EXTENSIONS = {
51
51
  # purpose (see switched_on?); unknown keys are not rejected, because callers
52
52
  # pass their own extra parameters through.
53
53
  OPTION_VALUES = {
54
- format: FORMATS,
54
+ # lsif is the name the JSON format wore from 1.11.0 to 2.0.0, accepted as
55
+ # an alias until 3.0. It collided with a code-intelligence format of the
56
+ # same initials, and claimed an interchange nobody else had implemented.
57
+ format: FORMATS + ["lsif"],
55
58
  leafstyle: %w[auto triangle bar nothing],
56
59
  fontstyle: ["sans", "serif", "cjk", "mono", "noto-sans", "noto-serif", "noto-sans-mono", "cjk zenhei"],
57
60
  color: %w[modern traditional gray grey off none on true false],
@@ -136,14 +139,20 @@ class RSTError < StandardError
136
139
  # the top, rather than repeated on every error.
137
140
  REFERENCE = "rsyntaxtree --notation, or https://yohasebe.github.io/rsyntaxtree/llms-full.txt"
138
141
 
142
+ # One error as the hash the JSON diagnosis carries; to_h wraps a single
143
+ # one and diagnose collects many.
144
+ def error_entry
145
+ { "code" => code.to_s,
146
+ "message" => message,
147
+ "label" => label,
148
+ "position" => position,
149
+ "hint" => hint,
150
+ "retryable" => retryable }.compact
151
+ end
152
+
139
153
  def to_h
140
154
  { "ok" => false,
141
- "errors" => [{ "code" => code.to_s,
142
- "message" => message,
143
- "label" => label,
144
- "position" => position,
145
- "hint" => hint,
146
- "retryable" => retryable }.compact],
155
+ "errors" => [error_entry],
147
156
  "reference" => REFERENCE }
148
157
  end
149
158
  end
@@ -152,7 +161,7 @@ require_relative 'rsyntaxtree/utils'
152
161
  require_relative 'rsyntaxtree/element'
153
162
  require_relative 'rsyntaxtree/elementlist'
154
163
  require_relative 'rsyntaxtree/svg_graph'
155
- require_relative 'rsyntaxtree/lsif_graph'
164
+ require_relative 'rsyntaxtree/json_graph'
156
165
  require_relative 'rsyntaxtree/tikz_generator'
157
166
  require_relative 'rsyntaxtree/version'
158
167
  require_relative 'rsyntaxtree/string_parser'
@@ -182,13 +191,13 @@ module RSyntaxTree
182
191
 
183
192
  if REMOVED_OPTIONS.key?(key)
184
193
  raise RSTError.new(+"Error: option '#{key}' was removed in RSyntaxTree 2.0",
185
- code: :invalid_option,
194
+ code: :invalid_option, label: key.to_s,
186
195
  hint: "Use #{REMOVED_OPTIONS[key]} instead.",
187
196
  retryable: false)
188
197
  end
189
198
  if OPTION_VALUES.key?(key) && !OPTION_VALUES[key].include?(value.to_s)
190
199
  raise RSTError.new(+"Error: invalid value for option '#{key}': #{value.inspect}",
191
- code: :invalid_option,
200
+ code: :invalid_option, label: key.to_s,
192
201
  hint: "'#{key}' must be one of: #{OPTION_VALUES[key].join(', ')}.",
193
202
  retryable: false)
194
203
  end
@@ -199,14 +208,14 @@ module RSyntaxTree
199
208
  if NUMERIC_RANGES.key?(key) && !value.is_a?(Numeric) &&
200
209
  value.to_s.strip !~ /\A-?(\d+(\.\d+)?|\.\d+)\z/
201
210
  raise RSTError.new(+"Error: invalid value for option '#{key}': #{value.inspect}",
202
- code: :invalid_option,
211
+ code: :invalid_option, label: key.to_s,
203
212
  hint: "'#{key}' takes a number.",
204
213
  retryable: false)
205
214
  end
206
215
  if NUMERIC_RANGES.key?(key) && !NUMERIC_RANGES[key].cover?(value.to_f)
207
216
  range = NUMERIC_RANGES[key]
208
217
  raise RSTError.new(+"Error: invalid value for option '#{key}': #{value.inspect}",
209
- code: :invalid_option,
218
+ code: :invalid_option, label: key.to_s,
210
219
  hint: "'#{key}' must be in the range of #{range.begin}-#{range.end}.",
211
220
  retryable: false)
212
221
  end
@@ -263,6 +272,8 @@ module RSyntaxTree
263
272
  else
264
273
  "off"
265
274
  end
275
+ when :format
276
+ new_params[key] = value.to_s == "lsif" ? "json" : value.to_s
266
277
  when :hyphen
267
278
  new_params[key] = value.to_s == "literal" ? "literal" : "markup"
268
279
  when :fontsize
@@ -283,7 +294,7 @@ module RSyntaxTree
283
294
  else
284
295
  unless COLOR_NAMES.include?(v.downcase) || v =~ /\A#(\h{3}|\h{6})\z/
285
296
  raise RSTError.new(+"Error: invalid value for option 'shear_plane': #{value.inspect}",
286
- code: :invalid_option,
297
+ code: :invalid_option, label: "shear_plane",
287
298
  hint: "'shear_plane' is on, off, a colour name, " \
288
299
  "or a hex colour of 3 or 6 digits.",
289
300
  retryable: false)
@@ -328,7 +339,7 @@ module RSyntaxTree
328
339
  # the combination is refused rather than approximated.
329
340
  if @params[:derivation] == true && @params[:direction] == "ltr"
330
341
  raise RSTError.new(+"Error: a derivation cannot be drawn left to right",
331
- code: :invalid_option,
342
+ code: :invalid_option, label: "derivation",
332
343
  hint: "A derivation runs down the page. Use direction ttb or btt, " \
333
344
  "or turn derivation off.",
334
345
  retryable: false)
@@ -341,7 +352,7 @@ module RSyntaxTree
341
352
  # them. Refused for the same reason as left to right.
342
353
  if @params[:derivation] == true && @params[:hide_default_connectors] == true
343
354
  raise RSTError.new(+"Error: a derivation's rules cannot be hidden",
344
- code: :invalid_option,
355
+ code: :invalid_option, label: "derivation",
345
356
  hint: "The rules are what a derivation is drawn with, not a " \
346
357
  "connector added to it. Turn off hide default connectors, " \
347
358
  "or turn derivation off.",
@@ -443,6 +454,101 @@ module RSyntaxTree
443
454
  end
444
455
  end
445
456
 
457
+ # Every option error at once. The constructor is the only judge of an
458
+ # option, and its rules are not written out a second time here: it
459
+ # stops at its first complaint, so it is asked again with the option
460
+ # it complained about set aside, until it accepts what is left or
461
+ # names nothing to set aside. Each round removes one option, so the
462
+ # loop is as bounded as the option list.
463
+ def self.option_errors(params)
464
+ remaining = params.reject { |k, _| k.to_sym == :data }
465
+ errors = []
466
+ loop do
467
+ begin
468
+ new(remaining.merge(data: "[A a]"))
469
+ break
470
+ rescue RSTError => e
471
+ errors << e
472
+ key = e.label
473
+ break if key.nil? || remaining.keys.none? { |k| k.to_s == key }
474
+
475
+ remaining = remaining.reject { |k, _| k.to_s == key }
476
+ end
477
+ end
478
+ errors
479
+ end
480
+
481
+ # Parse in collect mode: every label that will not parse, and every
482
+ # rule name that turns out to have no rule behind it, recorded instead
483
+ # of raised. The walk is the real one — the same tokens, the same
484
+ # judgments — so what this reports and what drawing rejects cannot
485
+ # drift apart. Returns the errors and whether the list was cut short.
486
+ def collect_input_errors
487
+ sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset],
488
+ @params[:fontsize], @global, collect_errors: true)
489
+ sp.parse
490
+ [sp.collected_errors, sp.more_errors?]
491
+ end
492
+
493
+ NOTE_OPTIONS = "Not every option could be read, so the input itself has " \
494
+ "not been checked yet; fixing the options may reveal more."
495
+ NOTE_STRUCTURE = "The bracket structure could not be read, so the labels " \
496
+ "have not been checked yet; fixing it may reveal more."
497
+ NOTE_LABELS = "Whole-tree checks (paths, output limits) run only once " \
498
+ "every label reads, so fixing these may reveal more."
499
+ NOTE_TRUNCATED = " Only the first #{StringParser::COLLECTED_ERRORS_LIMIT} " \
500
+ "problems are listed."
501
+
502
+ # The whole diagnosis at once, as the hash the CLI prints: every error
503
+ # of the first stage that finds any, not just the first error found.
504
+ # Stages are ordered so that nothing is reported whose appearance is an
505
+ # artifact of an earlier mistake — a missing bracket shifts every token
506
+ # after it, an unreadable hyphen option changes what counts as markup —
507
+ # and when a stage stops the walk, the note says that fixing what is
508
+ # listed may reveal more. check_data keeps its contract (raise the
509
+ # first error) for callers that want a verdict rather than a list.
510
+ def self.diagnose(text, params = {})
511
+ errors = []
512
+ note = nil
513
+ if text.to_s == ""
514
+ errors << RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false)
515
+ else
516
+ begin
517
+ errors = option_errors(params)
518
+ if errors.any?
519
+ note = NOTE_OPTIONS
520
+ else
521
+ StringParser.valid?(text)
522
+ gen = new(params.merge(data: text))
523
+ collected, truncated = gen.collect_input_errors
524
+ if collected.any?
525
+ errors = collected
526
+ note = NOTE_LABELS
527
+ note += NOTE_TRUNCATED if truncated
528
+ else
529
+ gen.validate!
530
+ end
531
+ end
532
+ rescue RSTError => e
533
+ errors << e
534
+ note = NOTE_STRUCTURE if %i[empty_brackets unbalanced_brackets].include?(e.code)
535
+ rescue StandardError => e
536
+ # The same promise check_data makes: a defect anywhere in here —
537
+ # the option probing included — is still a verdict of "no", in
538
+ # the same shape, not a backtrace.
539
+ errors << RSTError.new(+"Error: input could not be processed (#{e.class})",
540
+ code: :internal_error, retryable: false)
541
+ end
542
+ end
543
+
544
+ return { "ok" => true } if errors.empty?
545
+
546
+ result = { "ok" => false, "errors" => errors.map(&:error_entry) }
547
+ result["note"] = note if note
548
+ result["reference"] = RSTError::REFERENCE
549
+ result
550
+ end
551
+
446
552
  # Generate, and throw the result away. Parsing alone leaves out the
447
553
  # checks that only happen once the tree is laid out — a movement path
448
554
  # with one end, a line with three — so validation that stopped at the
@@ -461,7 +567,7 @@ module RSyntaxTree
461
567
  case @params[:format]
462
568
  when "png" then raster_surface_for(draw_svg, &:finish)
463
569
  when "pdf" then pdf_surface_for(draw_svg, StringIO.new, &:finish)
464
- when "lsif" then draw_lsif
570
+ when "json" then draw_json
465
571
  when "tikz" then draw_tikz
466
572
  else draw_svg
467
573
  end
@@ -535,11 +641,16 @@ module RSyntaxTree
535
641
  graph.svg_data
536
642
  end
537
643
 
538
- def draw_lsif
644
+ def draw_json
539
645
  sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
540
646
  sp.parse
541
- graph = LsifGraph.new(sp.get_elementlist, @params, @global)
542
- graph.lsif_data
647
+ graph = JSONGraph.new(sp.get_elementlist, @params, @global)
648
+ graph.json_data
649
+ end
650
+
651
+ # The name the JSON output wore from 1.11.0 to 2.0.0; removed in 3.0.
652
+ def draw_lsif
653
+ draw_json
543
654
  end
544
655
 
545
656
  def draw_tikz(standalone: false, font: nil)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rsyntaxtree
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
@@ -115,8 +115,11 @@ executables:
115
115
  extensions: []
116
116
  extra_rdoc_files: []
117
117
  files:
118
+ - ".dockerignore"
118
119
  - CHANGELOG.md
119
120
  - CITATION.cff
121
+ - CODE_OF_CONDUCT.md
122
+ - CONTRIBUTING.md
120
123
  - LICENSE
121
124
  - README.md
122
125
  - bin/rsyntaxtree
@@ -126,7 +129,7 @@ files:
126
129
  - lib/rsyntaxtree/element.rb
127
130
  - lib/rsyntaxtree/elementlist.rb
128
131
  - lib/rsyntaxtree/format_converter.rb
129
- - lib/rsyntaxtree/lsif_graph.rb
132
+ - lib/rsyntaxtree/json_graph.rb
130
133
  - lib/rsyntaxtree/markup_parser.rb
131
134
  - lib/rsyntaxtree/notation_core.md
132
135
  - lib/rsyntaxtree/notation_examples.md