rsyntaxtree 1.3.1 → 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.
@@ -13,6 +13,11 @@ class MarkupParser < Parslet::Parser
13
13
  rule(:brackets) { str('#') }
14
14
  rule(:triangle) { str('^') }
15
15
 
16
+ # Color specification: @colorname: or @#hexcode:
17
+ rule(:color_name) { match('[a-zA-Z]').repeat(1) }
18
+ rule(:color_hex) { str('#') >> match('[0-9a-fA-F]').repeat(3, 6) }
19
+ rule(:color_spec) { str('@') >> (color_hex | color_name).as(:color_value) >> str(':') }
20
+
16
21
  rule(:path) { (str('+') >> str('-').maybe >> (str('>') | str('<')).maybe >> match('\d').repeat(1)).as(:path) }
17
22
  # rule(:escaped) { str('\\') >> match('[#<>{}\\^+*_=~\|\n\-]').as(:chr) }
18
23
  rule(:escaped) { str('\\') >> match('[#<>{}\\\\^+*_=~\\|\\n\\-\\[\\]]').as(:chr) }
@@ -51,7 +56,7 @@ class MarkupParser < Parslet::Parser
51
56
  rule(:markup) { (text | decoration | shape | bstroke) }
52
57
 
53
58
  rule(:line) { (cr.as(:extracr) | border | bborder | markup.repeat(1).as(:line) >> (cr | eof | str('+').present?)) }
54
- rule(:lines) { triangle.maybe.as(:triangle) >> (brectangle | rectangle | brackets).maybe.as(:enclosure) >> line.repeat(1) >> path.repeat(0).as(:paths) >> (cr | eof) }
59
+ rule(:lines) { triangle.maybe.as(:triangle) >> (brectangle | rectangle | brackets).maybe.as(:enclosure) >> color_spec.maybe.as(:color) >> line.repeat(1) >> path.repeat(0).as(:paths) >> (cr | eof) }
55
60
  root :lines
56
61
  end
57
62
 
@@ -171,7 +176,7 @@ module Markup
171
176
 
172
177
  applied = @evaluator.apply(parsed)
173
178
 
174
- results = { enclosure: :none, triangle: false, paths: [], contents: [] }
179
+ results = { enclosure: :none, triangle: false, paths: [], contents: [], color: nil }
175
180
  applied.each do |h|
176
181
  if h[:enclosure]
177
182
  results[:enclosure] = case h[:enclosure].to_s
@@ -187,6 +192,12 @@ module Markup
187
192
  end
188
193
  results[:triangle] = h[:triangle].to_s == '^' if h[:triangle]
189
194
  results[:paths] = h[:paths] if h[:paths]
195
+ # Handle color specification
196
+ if h[:color] && h[:color][:color_value]
197
+ color_value = h[:color][:color_value].to_s
198
+ # Prepend # if it's a hex color without it (parser captures just the hex part after #)
199
+ results[:color] = color_value
200
+ end
190
201
  results[:contents] << h if h[:type] == :text || h[:type] == :border || h[:type] == :bborder
191
202
  end
192
203
  { status: :success, results: results }
@@ -6,7 +6,7 @@
6
6
  #
7
7
  # Parses a phrase into leafs and nodes and store the result in an element list
8
8
  # (see element_list.rb)
9
- # Copyright (c) 2007-2024 Yoichiro Hasebe <yohasebe@gmail.com>
9
+ # Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
10
10
 
11
11
  require_relative 'elementlist'
12
12
  require_relative 'element'
@@ -5,9 +5,9 @@
5
5
  #==========================
6
6
  #
7
7
  # Parses an element list into an SVG tree.
8
- # Copyright (c) 2007-2024 Yoichiro Hasebe <yohasebe@gmail.com>
8
+ # Copyright (c) 2007-2026 Yoichiro Hasebe <yohasebe@gmail.com>
9
9
 
10
- require "tempfile"
10
+ # No tempfile usage in this file
11
11
  require_relative 'base_graph'
12
12
  require_relative 'utils'
13
13
 
@@ -149,7 +149,10 @@ module RSyntaxTree
149
149
  right = left + element.content_width
150
150
  txt_pos = left + (right - left) / 2
151
151
 
152
- col = if element.type == ETYPE_LEAF
152
+ # Use element's custom color if specified, otherwise use default based on type
153
+ col = if element.color
154
+ element.color
155
+ elsif element.type == ETYPE_LEAF
153
156
  @col_leaf
154
157
  else
155
158
  @col_node
@@ -444,7 +447,11 @@ module RSyntaxTree
444
447
 
445
448
  path_flags.uniq.each do |k|
446
449
  targets = path_pool_target[k]
450
+ next if targets.nil? || targets.empty?
451
+
447
452
  fst = targets.shift
453
+ next if fst.nil?
454
+
448
455
  targets.each do |t|
449
456
  paths << { x1: fst[0], y1: fst[1], x2: t[0], y2: t[1], arrow: :double }
450
457
  end
@@ -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
 
@@ -53,7 +53,9 @@ module FontMetrics
53
53
  gca.interline_spacing = 0
54
54
  gca.interword_spacing = 0
55
55
  end
56
- gc.get_multiline_type_metrics(background, text)
56
+ metrics = gc.get_multiline_type_metrics(background, text)
57
+ background.destroy! # Explicitly destroy the image
58
+ metrics
57
59
  end
58
60
  module_function :get_metrics
59
61
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RSyntaxTree
4
- VERSION = "1.3.1"
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,16 +164,19 @@ module RSyntaxTree
160
164
  context.render_rsvg_handle(rsvg)
161
165
  b = StringIO.new
162
166
  surface.write_to_png(b)
163
- if binary
164
- b
165
- else
166
- b.string
167
- end
167
+ binary ? b : b.string
168
168
  rescue Cairo::InvalidSize
169
169
  raise RSTError, +"Error: the result syntree is too big"
170
+ ensure
171
+ b&.close unless binary
172
+ surface&.finish
173
+ context&.destroy
170
174
  end
171
175
 
172
176
  def draw_pdf(binary = false)
177
+ surface = nil
178
+ context = nil
179
+ b = nil
173
180
  b = StringIO.new
174
181
  svg = draw_svg
175
182
  rsvg = RSVG::Handle.new_from_data(svg)
@@ -178,13 +185,12 @@ module RSyntaxTree
178
185
  context = Cairo::Context.new(surface)
179
186
  context.render_rsvg_handle(rsvg)
180
187
  surface.finish
181
- if binary
182
- b
183
- else
184
- b.string
185
- end
188
+ binary ? b : b.string
186
189
  rescue Cairo::InvalidSize
187
190
  raise RSTError, +"Error: the result syntree is too big"
191
+ ensure
192
+ b&.close unless binary
193
+ context&.destroy
188
194
  end
189
195
 
190
196
  def draw_svg
@@ -194,16 +200,31 @@ module RSyntaxTree
194
200
  graph.svg_data
195
201
  end
196
202
 
197
- # Currently not used
198
- def draw_tree
199
- svg = draw_svg
200
- image, _data = Magick::Image.from_blob(svg) do |im|
201
- im.format = 'svg'
202
- end
203
- image.to_blob do |im|
204
- im.format = @params[:format].upcase
205
- end
206
- image
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!)
210
+ blob
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)
207
228
  end
208
229
  end
209
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