rsyntaxtree 1.3.2 → 1.4.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.
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ #==========================
4
+ # tikz_generator.rb
5
+ #==========================
6
+ #
7
+ # Generates TikZ/forest LaTeX code from parsed tree elements
8
+ # Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
9
+
10
+ module RSyntaxTree
11
+ class TikZGenerator
12
+ LATEX_ESCAPE_MAP = {
13
+ '&' => '\\&',
14
+ '%' => '\\%',
15
+ '$' => '\\$',
16
+ '#' => '\\#',
17
+ '_' => '\\_',
18
+ '{' => '\\{',
19
+ '}' => '\\}',
20
+ '~' => '\\textasciitilde{}',
21
+ '^' => '\\textasciicircum{}'
22
+ }.freeze
23
+
24
+ def initialize(element_list, params)
25
+ @element_list = element_list
26
+ @params = params
27
+ end
28
+
29
+ # Generate TikZ forest code
30
+ # @param standalone [Boolean] whether to include LaTeX preamble
31
+ # @param font [String, nil] font name for XeLaTeX/fontspec (enables fontspec when specified)
32
+ # @return [String] TikZ/forest code
33
+ def generate(standalone: false, font: nil)
34
+ tree_code = generate_tree(1)
35
+
36
+ if standalone
37
+ generate_standalone(tree_code, font: font)
38
+ else
39
+ generate_forest_only(tree_code)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def generate_standalone(tree_code, font: nil)
46
+ if font
47
+ <<~LATEX
48
+ \\documentclass[border=10pt]{standalone}
49
+ \\usepackage{forest}
50
+ \\usepackage{fontspec}
51
+ \\setmainfont{#{font}}
52
+
53
+ \\begin{document}
54
+ #{generate_forest_only(tree_code)}
55
+ \\end{document}
56
+ LATEX
57
+ else
58
+ <<~LATEX
59
+ \\documentclass[border=10pt]{standalone}
60
+ \\usepackage{forest}
61
+
62
+ \\begin{document}
63
+ #{generate_forest_only(tree_code)}
64
+ \\end{document}
65
+ LATEX
66
+ end
67
+ end
68
+
69
+ def generate_forest_only(tree_code)
70
+ <<~LATEX
71
+ \\begin{forest}
72
+ for tree={
73
+ parent anchor=south,
74
+ child anchor=north,
75
+ align=center,
76
+ base=top
77
+ }
78
+ #{tree_code}
79
+ \\end{forest}
80
+ LATEX
81
+ end
82
+
83
+ # Recursively generate tree structure
84
+ def generate_tree(element_id, indent = 0)
85
+ element = @element_list.get_id(element_id)
86
+ return "" unless element
87
+
88
+ label = extract_label(element)
89
+ escaped_label = escape_latex(label)
90
+
91
+ children = element.children
92
+ indent_str = " " * indent
93
+
94
+ if children.empty?
95
+ "#{indent_str}[#{escaped_label}]"
96
+ else
97
+ child_code = children.map { |child_id| generate_tree(child_id, indent + 1) }.join("\n")
98
+ "#{indent_str}[#{escaped_label}\n#{child_code}\n#{indent_str}]"
99
+ end
100
+ end
101
+
102
+ # Extract plain text label from element content
103
+ def extract_label(element)
104
+ content = element.content
105
+ return "" if content.nil? || content.empty?
106
+
107
+ # content is an array of hashes with :type and :elements
108
+ texts = []
109
+ content.each do |line|
110
+ next unless line[:type] == :text
111
+
112
+ line[:elements].each do |el|
113
+ text = el[:text].to_s
114
+ # Remove RSyntaxTree-specific whitespace block
115
+ text = text.gsub("■", " ")
116
+ texts << text unless text.strip.empty?
117
+ end
118
+ end
119
+ texts.join(" ")
120
+ end
121
+
122
+ # Escape LaTeX special characters
123
+ def escape_latex(text)
124
+ result = text.dup
125
+ LATEX_ESCAPE_MAP.each do |char, escaped|
126
+ result.gsub!(char, escaped)
127
+ end
128
+ result
129
+ end
130
+ end
131
+ end
@@ -5,7 +5,7 @@
5
5
  #==========================
6
6
  #
7
7
  # Image utility functions to inspect text font metrics
8
- # Copyright (c) 2007-2024 Yoichiro Hasebe <yohasebe@gmail.com>
8
+ # Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
9
9
 
10
10
  require 'rmagick'
11
11
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "1.3.2"
4
+ VERSION = "1.4.0"
5
5
  end
data/lib/rsyntaxtree.rb CHANGED
@@ -6,7 +6,7 @@
6
6
  #
7
7
  # Facade of rsyntaxtree library. When loaded by a driver script, it does all
8
8
  # the necessary 'require' to use the library.
9
- # Copyright (c) 2007-2024 Yoichiro Hasebe <yohasebe@gmail.com>
9
+ # Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
10
10
 
11
11
  FONT_DIR = File.expand_path(File.join(__dir__, "/../fonts"))
12
12
  ETYPE_NODE = 1
@@ -24,7 +24,7 @@ DEFAULT_OPTS = {
24
24
  linewidth: 1,
25
25
  vheight: 2.0,
26
26
  color: "modern",
27
- symmetrize: "on",
27
+ symmetrize: "off",
28
28
  transparent: "off",
29
29
  polyline: "off",
30
30
  hide_default_connectors: "off"
@@ -41,6 +41,7 @@ require_relative 'rsyntaxtree/utils'
41
41
  require_relative 'rsyntaxtree/element'
42
42
  require_relative 'rsyntaxtree/elementlist'
43
43
  require_relative 'rsyntaxtree/svg_graph'
44
+ require_relative 'rsyntaxtree/tikz_generator'
44
45
  require_relative 'rsyntaxtree/version'
45
46
  require_relative 'rsyntaxtree/string_parser'
46
47
 
@@ -152,6 +153,9 @@ module RSyntaxTree
152
153
  end
153
154
 
154
155
  def draw_png(binary = false)
156
+ surface = nil
157
+ context = nil
158
+ b = nil
155
159
  svg = draw_svg
156
160
  rsvg = RSVG::Handle.new_from_data(svg)
157
161
  dim = rsvg.dimensions
@@ -160,17 +164,19 @@ module RSyntaxTree
160
164
  context.render_rsvg_handle(rsvg)
161
165
  b = StringIO.new
162
166
  surface.write_to_png(b)
163
- result = binary ? b : b.string
164
- # Clean up resources
165
- b.close unless binary
166
- surface.finish
167
- context.destroy
168
- result
167
+ binary ? b : b.string
169
168
  rescue Cairo::InvalidSize
170
169
  raise RSTError, +"Error: the result syntree is too big"
170
+ ensure
171
+ b&.close unless binary
172
+ surface&.finish
173
+ context&.destroy
171
174
  end
172
175
 
173
176
  def draw_pdf(binary = false)
177
+ surface = nil
178
+ context = nil
179
+ b = nil
174
180
  b = StringIO.new
175
181
  svg = draw_svg
176
182
  rsvg = RSVG::Handle.new_from_data(svg)
@@ -179,13 +185,12 @@ module RSyntaxTree
179
185
  context = Cairo::Context.new(surface)
180
186
  context.render_rsvg_handle(rsvg)
181
187
  surface.finish
182
- result = binary ? b : b.string
183
- # Clean up resources
184
- b.close unless binary
185
- context.destroy
186
- result
188
+ binary ? b : b.string
187
189
  rescue Cairo::InvalidSize
188
190
  raise RSTError, +"Error: the result syntree is too big"
191
+ ensure
192
+ b&.close unless binary
193
+ context&.destroy
189
194
  end
190
195
 
191
196
  def draw_svg
@@ -195,18 +200,31 @@ module RSyntaxTree
195
200
  graph.svg_data
196
201
  end
197
202
 
198
- # Currently not used
199
- def draw_tree
200
- svg = draw_svg
201
- image, _data = Magick::Image.from_blob(svg) do |im|
202
- im.format = 'svg'
203
- end
204
- blob = image.to_blob do |im|
205
- im.format = @params[:format].upcase
206
- end
207
- # Clean up resources
208
- image.destroy!
203
+ def draw_jpg
204
+ png_data = draw_png
205
+ images = Magick::Image.from_blob(png_data)
206
+ image = images.first
207
+ image.format = 'JPEG'
208
+ blob = image.to_blob
209
+ images.each(&:destroy!)
209
210
  blob
210
211
  end
212
+
213
+ def draw_gif
214
+ png_data = draw_png
215
+ images = Magick::Image.from_blob(png_data)
216
+ image = images.first
217
+ image.format = 'GIF'
218
+ blob = image.to_blob
219
+ images.each(&:destroy!)
220
+ blob
221
+ end
222
+
223
+ def draw_tikz(standalone: false, font: nil)
224
+ sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
225
+ sp.parse
226
+ generator = TikZGenerator.new(sp.get_elementlist, @params)
227
+ generator.generate(standalone: standalone, font: font)
228
+ end
211
229
  end
212
230
  end
data/rsyntaxtree.gemspec CHANGED
@@ -22,6 +22,8 @@ Gem::Specification.new do |s|
22
22
  s.add_runtime_dependency "rmagick", ">= 4.3"
23
23
  s.add_runtime_dependency "rsvg2"
24
24
 
25
+ s.add_development_dependency "minitest"
25
26
  s.add_development_dependency "nokogiri"
27
+ s.add_development_dependency "rake"
26
28
  s.add_development_dependency "yaml"
27
29
  end
data/syntree.svg ADDED
@@ -0,0 +1,41 @@
1
+ <?xml version="1.0" standalone="no"?>
2
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3
+ <svg width="118.60000000000001" height="434.25" viewBox="-15.200000000000001, 0, 133.8, 445.5" version="1.1" xmlns="http://www.w3.org/2000/svg">
4
+ <defs>
5
+ <marker id="arrow" markerUnits="userSpaceOnUse" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="15.200000000000001" markerHeight="15.200000000000001" orient="auto">
6
+ <path d="M 0 0 L 10 5 L 0 10" fill="#CC79A7"/>
7
+ </marker>
8
+ <marker id="arrowBackward" markerUnits="userSpaceOnUse" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="15.200000000000001" markerHeight="15.200000000000001" orient="auto">
9
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#CC79A7"/>
10
+ </marker>
11
+ <marker id="arrowForward" markerUnits="userSpaceOnUse" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="15.200000000000001" markerHeight="15.200000000000001" orient="auto">
12
+ <path d="M 10 0 L 0 5 L 10 10 z" fill="#CC79A7"/>
13
+ </marker>
14
+ <marker id="arrowBothways" markerUnits="userSpaceOnUse" viewBox="0 0 30 10" refX="15" refY="5" markerWidth="45.6" markerHeight="15.200000000000001" orient="auto">
15
+ <path d="M 0 5 L 10 0 L 10 5 L 20 5 L 20 0 L 30 5 L 20 10 L 20 5 L 10 5 L 10 10 z" fill="#CC79A7"/>
16
+ </marker>
17
+ <pattern id="hatchBlack" x="10" y="10" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
18
+ <line x1="0" y="0" x2="0" y2="10" stroke="black" stroke-width="4"></line>
19
+ </pattern>
20
+ <pattern id="hatchForNode" x="10" y="10" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
21
+ <line x1="0" y="0" x2="0" y2="10" stroke="#0072B2" stroke-width="4"></line>
22
+ </pattern>
23
+ <pattern id="hatchForLeaf" x="10" y="10" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
24
+ <line x1="0" y="0" x2="0" y2="10" stroke="#009E73" stroke-width="4"></line>
25
+ </pattern>
26
+ </defs>
27
+ <rect x="-15.200000000000001" y="0" width="133.8" height="445.5" stroke="none" fill="white" />"
28
+ <text white-space='pre' alignment-baseline='text-top' style='fill: #0072B2; storoke-width: 0; font-size: 32px;' x='42.7' y='67.5'><tspan x='42.7' y='67.5' style="" text-decoration="" font-family="'Noto Sans', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif">S</tspan>
29
+ </text>
30
+ <text white-space='pre' alignment-baseline='text-top' style='fill: red; storoke-width: 0; font-size: 32px;' x='30.200000000000003' y='238.5'><tspan x='30.200000000000003' y='238.5' style="" text-decoration="" font-family="'Noto Sans', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif">NP</tspan>
31
+ </text>
32
+ <text white-space='pre' alignment-baseline='text-top' style='fill: #009E73; storoke-width: 0; font-size: 32px;' x='15.200000000000003' y='409.5'><tspan x='15.200000000000003' y='409.5' style="" text-decoration="" font-family="'Noto Sans', 'Noto Sans JP', OpenMoji, 'OpenMoji Color', 'OpenMoji Black', sans-serif">hello</tspan>
33
+ </text>
34
+ <line style='fill: none; stroke:black; stroke-width:2; stroke-linejoin:round; stroke-linecap:round;' x1='51.7' y1='182.25' x2='51.7' y2='95.625' />
35
+ <line style='fill: none; stroke:black; stroke-width:2; stroke-linejoin:round; stroke-linecap:round;' x1='51.7' y1='353.25' x2='51.7' y2='266.625' />
36
+ <polyline style='stroke:red; stroke-width:2; fill:none; stroke-linejoin:round; stroke-linecap:round;'
37
+ points='30.200000000000003,187.875 22.6,187.875 22.6,261.0 30.200000000000003,261.0' />
38
+
39
+ <polyline style='stroke:red; stroke-width:2; fill:none; stroke-linejoin:round; stroke-linecap:round;'
40
+ points='73.20000000000002,187.875 80.80000000000001,187.875 80.80000000000001,261.0 73.20000000000002,261.0' />
41
+ </svg>
data/test/cli_test.rb ADDED
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "minitest/pride"
5
+ require "tempfile"
6
+ require "open3"
7
+
8
+ class CLITest < Minitest::Test
9
+ BIN_PATH = File.expand_path("../bin/rsyntaxtree", __dir__)
10
+ RUBY = "ruby"
11
+
12
+ def setup
13
+ @tmpdir = Dir.mktmpdir
14
+ end
15
+
16
+ def teardown
17
+ FileUtils.remove_entry(@tmpdir) if @tmpdir && Dir.exist?(@tmpdir)
18
+ end
19
+
20
+ # Helper to run CLI and capture output (defined at bottom as private)
21
+
22
+ # ===================
23
+ # Feature 1: stdin support
24
+ # ===================
25
+
26
+ def test_stdin_input_generates_output
27
+ tree_data = "[S [NP test] [VP works]]"
28
+ outfile = File.join(@tmpdir, "syntree.svg")
29
+
30
+ result = run_cli("-f", "svg", "-o", @tmpdir, stdin_data: tree_data)
31
+
32
+ assert result[:status].success?, "CLI should succeed with stdin input: #{result[:stderr]}"
33
+ assert File.exist?(outfile), "Output file should be created"
34
+ assert File.size(outfile) > 0, "Output file should not be empty"
35
+ end
36
+
37
+ def test_stdin_input_with_pipe_simulation
38
+ tree_data = "[S [NP hello] [VP world]]"
39
+ outfile = File.join(@tmpdir, "syntree.png")
40
+
41
+ result = run_cli("-f", "png", "-o", @tmpdir, stdin_data: tree_data)
42
+
43
+ assert result[:status].success?, "CLI should succeed with piped input"
44
+ assert File.exist?(outfile), "PNG output file should be created"
45
+ end
46
+
47
+ def test_argument_takes_precedence_over_stdin
48
+ tree_data_stdin = "[S [NP stdin] [VP data]]"
49
+ tree_data_arg = "[S [NP arg] [VP data]]"
50
+ outfile = File.join(@tmpdir, "syntree.svg")
51
+
52
+ # When both arg and stdin provided, arg should win
53
+ result = run_cli("-f", "svg", "-o", @tmpdir, tree_data_arg, stdin_data: tree_data_stdin)
54
+
55
+ assert result[:status].success?, "CLI should succeed"
56
+ assert File.exist?(outfile), "Output file should be created"
57
+ content = File.read(outfile)
58
+ assert content.include?("arg"), "Output should contain data from argument, not stdin"
59
+ end
60
+
61
+ def test_file_input_still_works
62
+ tree_file = File.join(@tmpdir, "input.txt")
63
+ File.write(tree_file, "[S [NP file] [VP input]]")
64
+ outfile = File.join(@tmpdir, "syntree.svg")
65
+
66
+ result = run_cli("-f", "svg", "-o", @tmpdir, tree_file)
67
+
68
+ assert result[:status].success?, "CLI should succeed with file input"
69
+ assert File.exist?(outfile), "Output file should be created"
70
+ content = File.read(outfile)
71
+ assert content.include?("file"), "Output should contain data from file"
72
+ end
73
+
74
+ # ===================
75
+ # Feature 2: Config file support
76
+ # ===================
77
+
78
+ def test_config_file_invalid_format_shows_error
79
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
80
+ File.write(config_file, <<~YAML)
81
+ format: invalid_format
82
+ YAML
83
+
84
+ tree_data = "[S [NP test] [VP config]]"
85
+ result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
86
+
87
+ refute result[:status].success?, "CLI should fail with invalid format in config"
88
+ assert result[:stderr].include?("format") || result[:stdout].include?("format"),
89
+ "Error should mention 'format'"
90
+ end
91
+
92
+ def test_config_file_invalid_fontsize_shows_error
93
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
94
+ File.write(config_file, <<~YAML)
95
+ fontsize: 999
96
+ YAML
97
+
98
+ tree_data = "[S [NP test] [VP config]]"
99
+ result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
100
+
101
+ refute result[:status].success?, "CLI should fail with invalid fontsize in config"
102
+ assert result[:stderr].include?("fontsize") || result[:stdout].include?("fontsize"),
103
+ "Error should mention 'fontsize'"
104
+ end
105
+
106
+ def test_config_file_invalid_color_shows_error
107
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
108
+ File.write(config_file, <<~YAML)
109
+ color: rainbow
110
+ YAML
111
+
112
+ tree_data = "[S [NP test] [VP config]]"
113
+ result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
114
+
115
+ refute result[:status].success?, "CLI should fail with invalid color in config"
116
+ assert result[:stderr].include?("color") || result[:stdout].include?("color"),
117
+ "Error should mention 'color'"
118
+ end
119
+
120
+ def test_config_file_unknown_key_shows_warning
121
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
122
+ File.write(config_file, <<~YAML)
123
+ format: svg
124
+ unknown_option: value
125
+ YAML
126
+
127
+ tree_data = "[S [NP test] [VP config]]"
128
+ outfile = File.join(@tmpdir, "syntree.svg")
129
+ result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
130
+
131
+ # Should succeed but warn about unknown key
132
+ assert result[:status].success?, "CLI should succeed with unknown key"
133
+ assert File.exist?(outfile), "Output should be created"
134
+ assert result[:stderr].include?("unknown_option") || result[:stdout].include?("unknown_option"),
135
+ "Should warn about unknown option"
136
+ end
137
+
138
+ def test_config_file_shows_path_in_error
139
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
140
+ File.write(config_file, <<~YAML)
141
+ format: invalid
142
+ YAML
143
+
144
+ tree_data = "[S [NP test] [VP config]]"
145
+ result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
146
+
147
+ refute result[:status].success?, "CLI should fail"
148
+ # Error message should include the config file path
149
+ assert result[:stderr].include?(".rsyntaxtreerc") || result[:stdout].include?(".rsyntaxtreerc"),
150
+ "Error should mention config file path"
151
+ end
152
+
153
+ def test_config_file_in_current_dir
154
+ # Create config file in tmpdir
155
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
156
+ File.write(config_file, <<~YAML)
157
+ format: svg
158
+ color: off
159
+ fontsize: 20
160
+ YAML
161
+
162
+ tree_data = "[S [NP config] [VP test]]"
163
+ outfile = File.join(@tmpdir, "syntree.svg")
164
+
165
+ # Run from tmpdir with config
166
+ result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
167
+
168
+ assert result[:status].success?, "CLI should succeed with config file: #{result[:stderr]}"
169
+ assert File.exist?(outfile), "Should output SVG as specified in config"
170
+ end
171
+
172
+ def test_cli_args_override_config
173
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
174
+ File.write(config_file, <<~YAML)
175
+ format: svg
176
+ YAML
177
+
178
+ tree_data = "[S [NP override] [VP test]]"
179
+ outfile_png = File.join(@tmpdir, "syntree.png")
180
+
181
+ # CLI arg -f png should override config's svg
182
+ result = run_cli("-f", "png", "-o", @tmpdir, tree_data, chdir: @tmpdir)
183
+
184
+ assert result[:status].success?, "CLI should succeed"
185
+ assert File.exist?(outfile_png), "Should output PNG (CLI override)"
186
+ end
187
+
188
+ def test_config_file_with_custom_filename
189
+ config_file = File.join(@tmpdir, ".rsyntaxtreerc")
190
+ File.write(config_file, <<~YAML)
191
+ format: svg
192
+ outfilename: custom_tree
193
+ YAML
194
+
195
+ tree_data = "[S [NP custom] [VP name]]"
196
+ outfile = File.join(@tmpdir, "custom_tree.svg")
197
+
198
+ result = run_cli("-o", @tmpdir, tree_data, chdir: @tmpdir)
199
+
200
+ assert result[:status].success?, "CLI should succeed"
201
+ assert File.exist?(outfile), "Should use custom filename from config"
202
+ end
203
+
204
+ # ===================
205
+ # Feature 3: Penn TreeBank format
206
+ # ===================
207
+
208
+ def test_penn_treebank_input
209
+ tree_data = "(S (NP hello) (VP world))"
210
+ outfile = File.join(@tmpdir, "syntree.svg")
211
+
212
+ result = run_cli("-f", "svg", "-o", @tmpdir, tree_data)
213
+
214
+ assert result[:status].success?, "CLI should succeed with Penn TreeBank input: #{result[:stderr]}"
215
+ assert File.exist?(outfile), "Output file should be created"
216
+ content = File.read(outfile)
217
+ assert content.include?("hello"), "Output should contain tree content"
218
+ end
219
+
220
+ def test_penn_treebank_nested
221
+ tree_data = "(S (NP (Det the) (N dog)) (VP (V runs)))"
222
+ outfile = File.join(@tmpdir, "syntree.svg")
223
+
224
+ result = run_cli("-f", "svg", "-o", @tmpdir, tree_data)
225
+
226
+ assert result[:status].success?, "CLI should succeed with nested Penn TreeBank"
227
+ assert File.exist?(outfile), "Output file should be created"
228
+ end
229
+
230
+ def test_penn_treebank_from_file
231
+ tree_file = File.join(@tmpdir, "tree.penn")
232
+ File.write(tree_file, "(S (NP test) (VP file))")
233
+ outfile = File.join(@tmpdir, "syntree.svg")
234
+
235
+ result = run_cli("-f", "svg", "-o", @tmpdir, tree_file)
236
+
237
+ assert result[:status].success?, "CLI should succeed with Penn file input"
238
+ assert File.exist?(outfile), "Output file should be created"
239
+ end
240
+
241
+ def test_penn_treebank_from_stdin
242
+ tree_data = "(S (NP stdin) (VP penn))"
243
+ outfile = File.join(@tmpdir, "syntree.svg")
244
+
245
+ result = run_cli("-f", "svg", "-o", @tmpdir, stdin_data: tree_data)
246
+
247
+ assert result[:status].success?, "CLI should succeed with Penn from stdin"
248
+ assert File.exist?(outfile), "Output file should be created"
249
+ end
250
+
251
+ private
252
+
253
+ # Updated helper to support chdir
254
+ def run_cli(*args, stdin_data: nil, chdir: nil)
255
+ cmd = [RUBY, BIN_PATH] + args
256
+ opts = {}
257
+ opts[:stdin_data] = stdin_data if stdin_data
258
+ opts[:chdir] = chdir if chdir
259
+ stdout, stderr, status = Open3.capture3(*cmd, **opts)
260
+ { stdout: stdout, stderr: stderr, status: status }
261
+ end
262
+ end