Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Always use local variables in current context to parse code #397

Merged
merged 11 commits into from
Oct 18, 2022
12 changes: 8 additions & 4 deletions lib/irb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -506,13 +506,14 @@ def eval_input

@scanner.set_auto_indent(@context) if @context.auto_indent_mode

@scanner.each_top_level_statement do |line, line_no|
@scanner.each_top_level_statement(@context) do |line, line_no|
signal_status(:IN_EVAL) do
begin
line.untaint if RUBY_VERSION < '2.7'
if IRB.conf[:MEASURE] && IRB.conf[:MEASURE_CALLBACKS].empty?
IRB.set_measure_callback
end
is_assignment = assignment_expression?(line)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why moving this line here though? It doesn't look like to change the behavior?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually changes the behavior in a rare case.

irb = IRB::Irb.new
context = irb.instance_variable_get('@context')

code = "a /'/i if false; a=1; x=1000.times.to_a#'.size"
irb.assignment_expression?(code) #=> true
context.evaluate(code, 1) #=> [0,1,2,3,...] assignment to local variable x
irb.assignment_expression?(code) #=> false
context.evaluate(code, 1) #=> 0 not an assignment expression

assignment_expression? check should be done before @context.evaluate (in L519 and L530)

I added test case for this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow thanks for spotting this. I think it also worth a comment then?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the example 👍

if IRB.conf[:MEASURE] && !IRB.conf[:MEASURE_CALLBACKS].empty?
result = nil
last_proc = proc{ result = @context.evaluate(line, line_no, exception: exc) }
Expand All @@ -529,7 +530,7 @@ def eval_input
@context.evaluate(line, line_no, exception: exc)
end
if @context.echo?
if assignment_expression?(line)
if is_assignment
if @context.echo_on_assignment?
output_value(@context.echo_on_assignment? == :truncate)
end
Expand Down Expand Up @@ -827,9 +828,12 @@ def assignment_expression?(line)
# array of parsed expressions. The first element of each expression is the
# expression's type.
verbose, $VERBOSE = $VERBOSE, nil
result = ASSIGNMENT_NODE_TYPES.include?(Ripper.sexp(line)&.dig(1,-1,0))
code = "#{RubyLex.generate_local_variables_assign_code(@context.local_variables) || 'nil;'}\n#{line}"
# Get the last node_type of the line. drop(1) is to ignore the local_variables_assign_code part.
node_type = Ripper.sexp(code)&.dig(1)&.drop(1)&.dig(-1, 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not super easy to understand. Would you mind adding a comment for it?

ASSIGNMENT_NODE_TYPES.include?(node_type)
ensure
$VERBOSE = verbose
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this should be in an ensure block.

result
end

ATTR_TTY = "\e[%sm"
Expand Down
13 changes: 10 additions & 3 deletions lib/irb/color.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,15 @@ def colorize(text, seq, colorable: colorable?)
# If `complete` is false (code is incomplete), this does not warn compile_error.
# This option is needed to avoid warning a user when the compile_error is happening
# because the input is not wrong but just incomplete.
def colorize_code(code, complete: true, ignore_error: false, colorable: colorable?)
def colorize_code(code, complete: true, ignore_error: false, colorable: colorable?, local_variables: [])
return code unless colorable

symbol_state = SymbolState.new
colored = +''
lvars_code = RubyLex.generate_local_variables_assign_code(local_variables)
code_with_lvars = lvars_code ? "#{lvars_code}\n#{code}" : code

scan(code, allow_last_error: !complete) do |token, str, expr|
scan(code_with_lvars, allow_last_error: !complete) do |token, str, expr|
# handle uncolorable code
if token.nil?
colored << Reline::Unicode.escape_for_print(str)
Expand All @@ -152,7 +154,12 @@ def colorize_code(code, complete: true, ignore_error: false, colorable: colorabl
end
end
end
colored

if lvars_code
colored.sub(/\A.+\n/, '')
else
colored
end
end

private
Expand Down
4 changes: 4 additions & 0 deletions lib/irb/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -518,5 +518,9 @@ def inspect # :nodoc:
end
alias __to_s__ to_s
alias to_s inspect

def local_variables # :nodoc:
workspace.binding.local_variables
end
end
end
3 changes: 2 additions & 1 deletion lib/irb/input-method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ def initialize
if IRB.conf[:USE_COLORIZE]
proc do |output, complete: |
next unless IRB::Color.colorable?
IRB::Color.colorize_code(output, complete: complete)
lvars = IRB.CurrentContext&.workspace&.binding&.local_variables || []
tompng marked this conversation as resolved.
Show resolved Hide resolved
IRB::Color.colorize_code(output, complete: complete, local_variables: lvars)
end
else
proc do |output|
Expand Down
33 changes: 17 additions & 16 deletions lib/irb/ruby-lex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,18 @@ def set_prompt(p = nil, &block)
:on_param_error
]

def self.generate_local_variables_assign_code(local_variables)
"#{local_variables.join('=')}=nil;" unless local_variables.empty?
end

def self.ripper_lex_without_warning(code, context: nil)
verbose, $VERBOSE = $VERBOSE, nil
if context
lvars = context.workspace&.binding&.local_variables
if lvars && !lvars.empty?
code = "#{lvars.join('=')}=nil\n#{code}"
line_no = 0
else
line_no = 1
end
lvars_code = generate_local_variables_assign_code(context&.local_variables || [])
if lvars_code
code = "#{lvars_code}\n#{code}"
line_no = 0
else
line_no = 1
end

compile_with_errors_suppressed(code, line_no: line_no) do |inner_code, line_no|
Expand Down Expand Up @@ -214,6 +216,8 @@ def check_state(code, tokens = nil, context: nil)
ltype = process_literal_type(tokens)
indent = process_nesting_level(tokens)
continue = process_continue(tokens)
lvars_code = self.class.generate_local_variables_assign_code(context&.local_variables || [])
code = "#{lvars_code}\n#{code}" if lvars_code
code_block_open = check_code_block(code, tokens)
[ltype, indent, continue, code_block_open]
end
Expand All @@ -233,13 +237,13 @@ def initialize_input
@code_block_open = false
end

def each_top_level_statement
def each_top_level_statement(context)
initialize_input
catch(:TERM_INPUT) do
loop do
begin
prompt
unless l = lex
unless l = lex(context)
throw :TERM_INPUT if @line == ''
else
@line_no += l.count("\n")
Expand Down Expand Up @@ -269,18 +273,15 @@ def each_top_level_statement
end
end

def lex
def lex(context)
line = @input.call
if @io.respond_to?(:check_termination)
return line # multiline
end
code = @line + (line.nil? ? '' : line)
code.gsub!(/\s*\z/, '').concat("\n")
@tokens = self.class.ripper_lex_without_warning(code)
@continue = process_continue
@code_block_open = check_code_block(code)
@indent = process_nesting_level
@ltype = process_literal_type
@tokens = self.class.ripper_lex_without_warning(code, context: context)
@ltype, @indent, @continue, @code_block_open = check_state(code, @tokens, context: context)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on using check_state instead 👍

line
end

Expand Down
11 changes: 11 additions & 0 deletions test/irb/test_color.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ def test_colorize_code
end
end

def test_colorize_code_with_local_variables
code = "a /(b +1)/i"
result_without_lvars = "a #{RED}#{BOLD}/#{CLEAR}#{RED}(b +1)#{CLEAR}#{RED}#{BOLD}/i#{CLEAR}"
result_with_lvar = "a /(b #{BLUE}#{BOLD}+1#{CLEAR})/i"
result_with_lvars = "a /(b +#{BLUE}#{BOLD}1#{CLEAR})/i"

assert_equal_with_term(result_without_lvars, code)
assert_equal_with_term(result_with_lvar, code, local_variables: ['a'])
assert_equal_with_term(result_with_lvars, code, local_variables: ['a', 'b'])
end

def test_colorize_code_complete_true
unless complete_option_supported?
pend '`complete: true` is the same as `complete: false` in Ruby 2.6-'
Expand Down
10 changes: 10 additions & 0 deletions test/irb/test_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ def test_assignment_expression
end
end

def test_assignment_expression_with_local_variable
input = TestInputMethod.new
irb = IRB::Irb.new(IRB::WorkSpace.new(Object.new), input)
code = "a /1;x=1#/"
refute(irb.assignment_expression?(code), "#{code}: should not be an assignment expression")
irb.context.workspace.binding.eval('a = 1')
assert(irb.assignment_expression?(code), "#{code}: should be an assignment expression")
refute(irb.assignment_expression?(""), "empty code should not be an assignment expression")
end

def test_echo_on_assignment
input = TestInputMethod.new([
"a = 1\n",
Expand Down
31 changes: 27 additions & 4 deletions test/irb/test_ruby_lex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,27 @@ def assert_indenting(lines, correct_space_count, add_new_line)
ruby_lex.set_auto_indent(context)
end

def assert_nesting_level(lines, expected)
def assert_nesting_level(lines, expected, local_variables: [])
ruby_lex = ruby_lex_for_lines(lines, local_variables: local_variables)
error_message = "Calculated the wrong number of nesting level for:\n #{lines.join("\n")}"
assert_equal(expected, ruby_lex.instance_variable_get(:@indent), error_message)
end

def assert_code_block_open(lines, expected, local_variables: [])
ruby_lex = ruby_lex_for_lines(lines, local_variables: local_variables)
error_message = "Wrong result of code_block_open for:\n #{lines.join("\n")}"
assert_equal(expected, ruby_lex.instance_variable_get(:@code_block_open), error_message)
end

def ruby_lex_for_lines(lines, local_variables: [])
ruby_lex = RubyLex.new()
io = proc{ lines.join("\n") }
ruby_lex.set_input(io, io)
ruby_lex.lex
error_message = "Calculated the wrong number of nesting level for:\n #{lines.join("\n")}"
assert_equal(expected, ruby_lex.instance_variable_get(:@indent), error_message)
unless local_variables.empty?
context = OpenStruct.new(local_variables: local_variables)
end
ruby_lex.lex(context)
ruby_lex
end

def test_auto_indent
Expand Down Expand Up @@ -480,6 +494,15 @@ def test_do_corresponding_to_loop
end
end

def test_local_variables_dependent_code
pend if RUBY_ENGINE == 'truffleruby'
lines = ["a /1#/ do", "2"]
assert_nesting_level(lines, 1)
assert_code_block_open(lines, true)
assert_nesting_level(lines, 0, local_variables: ['a'])
assert_code_block_open(lines, false, local_variables: ['a'])
end

def test_heredoc_with_indent
input_with_correct_indents = [
Row.new(%q(<<~Q), 0, 0, 0),
Expand Down
19 changes: 19 additions & 0 deletions test/irb/yamatanooroti/test_rendering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,25 @@ def test_autocomplete_with_showdoc_in_gaps_on_narrow_screen_left
EOC
end

def test_assignment_expression_truncate
write_irbrc <<~'LINES'
puts 'start IRB'
LINES
start_terminal(40, 80, %W{ruby -I#{@pwd}/lib #{@pwd}/exe/irb}, startup_message: 'start IRB')
# Assignment expression code that turns into non-assignment expression after evaluation
code = "a /'/i if false; a=1; x=1000.times.to_a#'.size"
write(code + "\n")
close
assert_screen(<<~EOC)
start IRB
irb(main):001:0> #{code}
=>
[0,
...
irb(main):002:0>
EOC
end

private def write_irbrc(content)
File.open(@irbrc_file, 'w') do |f|
f.write content
Expand Down