diff --git a/NEWS.md b/NEWS.md index e3e2665e67f..ff0bf012992 100644 --- a/NEWS.md +++ b/NEWS.md @@ -113,6 +113,11 @@ Note: We're only listing outstanding class updates. * `Symbol#to_s` now returns a frozen string. [[Feature #22137]] +* Thread::Backtrace::Location + + * `Thread::Backtrace::Location#source_range` is added. It returns a + `Ruby::SourceRange` for the Ruby expression associated with the frame. + ## Stdlib updates * Psych diff --git a/ast.c b/ast.c index 9a1c08dad79..ed2bcdb2b94 100644 --- a/ast.c +++ b/ast.c @@ -179,15 +179,24 @@ rb_ast_parse_array(VALUE array, VALUE keep_script_lines, VALUE error_tolerant, V static VALUE node_children(VALUE, const NODE*); -static VALUE -node_find(VALUE self, const int node_id) +struct node_find_result { + VALUE node; + VALUE parent; +}; + +static bool +node_find_with_parent(VALUE self, VALUE parent, const int node_id, struct node_find_result *result) { VALUE ary; long i; struct ASTNodeData *data; TypedData_Get_Struct(self, struct ASTNodeData, &rb_node_type, data); - if (nd_node_id(data->node) == node_id) return self; + if (nd_node_id(data->node) == node_id) { + result->node = self; + result->parent = parent; + return true; + } ary = node_children(data->ast_value, data->node); @@ -195,12 +204,73 @@ node_find(VALUE self, const int node_id) VALUE child = RARRAY_AREF(ary, i); if (CLASS_OF(child) == rb_cNode) { - VALUE result = node_find(child, node_id); - if (RTEST(result)) return result; + if (node_find_with_parent(child, self, node_id, result)) return true; } } - return Qnil; + return false; +} + +static VALUE +node_find(VALUE self, const int node_id) +{ + struct node_find_result result = { Qnil, Qnil }; + node_find_with_parent(self, Qnil, node_id, &result); + return result.node; +} + +bool +rb_ast_node_source_location(VALUE source, VALUE path, int first_lineno, + int node_id, bool block_iseq, int iseq_node_id, + rb_code_location_t *location) +{ + VALUE ast; + + if (NIL_P(source)) { + ast = rb_ast_parse_file(path, Qfalse, Qfalse, Qfalse); + } + else { + VALUE ast_value; + VALUE vparser = setup_vparser(Qfalse, Qfalse, Qfalse); + + if (RB_TYPE_P(source, T_ARRAY)) { + ast_value = rb_parser_compile_array(vparser, path, source, first_lineno); + } + else { + StringValue(source); + ast_value = rb_parser_compile_string_path(vparser, path, source, first_lineno); + } + ast = ast_parse_done(ast_value); + } + + struct node_find_result result = { Qnil, Qnil }; + if (!node_find_with_parent(ast, Qnil, node_id, &result)) return false; + + struct ASTNodeData *data; + TypedData_Get_Struct(result.node, struct ASTNodeData, &rb_node_type, data); + const NODE *node = data->node; + + if (!NIL_P(result.parent)) { + struct ASTNodeData *parent_data; + TypedData_Get_Struct(result.parent, struct ASTNodeData, &rb_node_type, parent_data); + const NODE *parent = parent_data->node; + + /* Prism's call node includes its literal block. */ + if (nd_type(parent) == NODE_ITER && RNODE_ITER(parent)->nd_iter == node) { + node = parent; + } + } + + /* Prism's block node excludes the call that produced the block. */ + if (block_iseq && node_id == iseq_node_id && nd_type(node) == NODE_ITER) { + const NODE *scope = RNODE_ITER(node)->nd_body; + if (scope && nd_type(scope) == NODE_SCOPE) { + node = scope; + } + } + + *location = *nd_code_loc(node); + return true; } extern VALUE rb_e_script; diff --git a/internal/proc.h b/internal/proc.h index 24a077ca6d8..4528926471c 100644 --- a/internal/proc.h +++ b/internal/proc.h @@ -11,6 +11,7 @@ #include "ruby/ruby.h" /* for rb_block_call_func_t */ #include "ruby/st.h" /* for st_index_t */ struct rb_block; /* in vm_core.h */ +struct rb_code_location_struct; /* in rubyparser.h */ struct rb_iseq_struct; /* in vm_core.h */ /* proc.c */ @@ -21,6 +22,8 @@ int rb_block_arity(void); int rb_block_min_max_arity(int *max); VALUE rb_block_to_s(VALUE self, const struct rb_block *block, const char *additional_info); VALUE rb_callable_receiver(VALUE); +VALUE rb_source_range_new(VALUE path, VALUE absolute_path, + const struct rb_code_location_struct *location); VALUE rb_func_proc_dup(VALUE src_obj); VALUE rb_func_lambda_new(rb_block_call_func_t func, VALUE val, int min_argc, int max_argc); diff --git a/internal/ruby_parser.h b/internal/ruby_parser.h index 8e306d18dec..097ad3d291f 100644 --- a/internal/ruby_parser.h +++ b/internal/ruby_parser.h @@ -56,6 +56,9 @@ VALUE rb_parser_compile_string(VALUE, const char*, VALUE, int); VALUE rb_parser_compile_file_path(VALUE vparser, VALUE fname, VALUE input, int line); VALUE rb_parser_compile_generic(VALUE vparser, rb_parser_lex_gets_func *lex_gets, VALUE fname, VALUE input, int line); VALUE rb_parser_compile_array(VALUE vparser, VALUE fname, VALUE array, int start); +bool rb_ast_node_source_location(VALUE source, VALUE path, int first_lineno, + int node_id, bool block_iseq, int iseq_node_id, + rb_code_location_t *location); enum lex_state_bits { EXPR_BEG_bit, /* ignore newline, +/- is a sign. */ diff --git a/prism_compile.c b/prism_compile.c index 29f266dfecf..50dadbb80bf 100644 --- a/prism_compile.c +++ b/prism_compile.c @@ -11051,6 +11051,63 @@ pm_parse_string(pm_parse_result_t *result, VALUE source, VALUE filepath, VALUE * return error; } +typedef struct { + uint32_t node_id; + const pm_node_t *node; +} pm_node_find_context_t; + +static bool +pm_node_find(const pm_node_t *node, void *data) +{ + pm_node_find_context_t *context = data; + + if (context->node == NULL && node->node_id == context->node_id) { + context->node = node; + return false; + } + + return context->node == NULL; +} + +bool +pm_node_source_location(VALUE source, VALUE filepath, int start_line, + int node_id, rb_code_location_t *location) +{ + pm_parse_result_t result; + pm_parse_result_init(&result); + + VALUE error; + if (NIL_P(source)) { + error = pm_load_parse_file(&result, filepath, NULL); + } + else { + pm_options_line_set(result.options, start_line); + error = pm_parse_string(&result, source, filepath, NULL); + } + + if (!NIL_P(error)) { + pm_parse_result_free(&result); + rb_exc_raise(error); + } + + pm_node_find_context_t context = { + .node_id = (uint32_t) node_id, + .node = NULL + }; + pm_visit_node(result.node.ast_node, pm_node_find, &context); + + bool found = context.node != NULL; + if (found) { + *location = pm_code_location(&result.node, context.node); + } + + RB_GC_GUARD(source); + RB_GC_GUARD(filepath); + + pm_parse_result_free(&result); + return found; +} + VALUE rb_io_gets_limit_internal(VALUE io, long limit); /** diff --git a/prism_compile.h b/prism_compile.h index 44857939025..783a167d798 100644 --- a/prism_compile.h +++ b/prism_compile.h @@ -15,6 +15,7 @@ typedef struct pm_local_index_struct { // A declaration for the struct that lives in compile.c. struct iseq_link_anchor; +struct rb_code_location_struct; /** * A direct-indexed lookup table mapping constant IDs to local variable indices. @@ -187,6 +188,8 @@ VALUE pm_parse_string(pm_parse_result_t *result, VALUE source, VALUE filepath, V VALUE pm_parse_stdin(pm_parse_result_t *result); void pm_options_version_for_current_ruby_set(pm_options_t *options); void pm_parse_result_free(pm_parse_result_t *result); +bool pm_node_source_location(VALUE source, VALUE filepath, int start_line, + int node_id, struct rb_code_location_struct *location); rb_iseq_t *pm_iseq_new(pm_scope_node_t *node, VALUE name, VALUE path, VALUE realpath, const rb_iseq_t *parent, enum rb_iseq_type, int *error_state); rb_iseq_t *pm_iseq_new_top(pm_scope_node_t *node, VALUE name, VALUE path, VALUE realpath, const rb_iseq_t *parent, int *error_state); diff --git a/proc.c b/proc.c index 0f501699acd..599b68ca7f2 100644 --- a/proc.c +++ b/proc.c @@ -82,6 +82,22 @@ static const rb_data_type_t source_range_data_type = { 0, 0, RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_DECL_MARKING }; +VALUE +rb_source_range_new(VALUE path, VALUE absolute_path, const rb_code_location_t *location) +{ + struct source_range_data *data; + VALUE obj = TypedData_Make_Struct( + rb_cSourceRange, struct source_range_data, &source_range_data_type, data); + RB_OBJ_WRITE(obj, &data->path, path); + RB_OBJ_WRITE(obj, &data->absolute_path, absolute_path); + data->start_line = location->beg_pos.lineno; + data->start_column = location->beg_pos.column; + data->end_line = location->end_pos.lineno; + data->end_column = location->end_pos.column; + + return obj; +} + static VALUE source_range_new(const rb_iseq_t *iseq) { @@ -96,19 +112,7 @@ source_range_new(const rb_iseq_t *iseq) return Qnil; } - int start_line, start_column, end_line, end_column; - rb_iseq_code_location(iseq, &start_line, &start_column, &end_line, &end_column); - - struct source_range_data *data; - VALUE obj = TypedData_Make_Struct(rb_cSourceRange, struct source_range_data, &source_range_data_type, data); - RB_OBJ_WRITE(obj, &data->path, path); - RB_OBJ_WRITE(obj, &data->absolute_path, absolute_path); - data->start_line = start_line; - data->start_column = start_column; - data->end_line = end_line; - data->end_column = end_column; - - return obj; + return rb_source_range_new(path, absolute_path, &ISEQ_BODY(iseq)->location.code_location); } static struct source_range_data * @@ -4909,11 +4913,12 @@ proc_ruby2_keywords(VALUE procval) /* * Document-class: Ruby::SourceRange * - * An object representing the source-code range for a Ruby callable. + * An object representing a range of Ruby source code. * * Source ranges are returned by Proc#source_range, Method#source_range, and - * UnboundMethod#source_range. They include the source path, absolute path when - * available, start line, start byte column, end line, and end byte column. + * UnboundMethod#source_range, as well as Thread::Backtrace::Location#source_range. + * They include the source path, absolute path when available, + * start line, start byte column, end line, and end byte column. * * The primary purpose of this class is to implement `Prism.find` precisely and cleanly on all Ruby implementations, * in a way which does not depend on implementation details like `node_id`. diff --git a/spec/ruby/core/method/shared/source_range.rb b/spec/ruby/core/method/shared/source_range.rb index 70884484e09..a85f7120ecb 100644 --- a/spec/ruby/core/method/shared/source_range.rb +++ b/spec/ruby/core/method/shared/source_range.rb @@ -1,4 +1,4 @@ -require_relative '../../proc/fixtures/source_range_helpers' +require_relative '../../../fixtures/source_range_helpers' describe :method_source_range, shared: true do it "sets absolute_path to the real path of the source file" do diff --git a/spec/ruby/core/proc/fixtures/source_range_helpers.rb b/spec/ruby/core/proc/fixtures/source_range_helpers.rb deleted file mode 100644 index d5a12d28ee7..00000000000 --- a/spec/ruby/core/proc/fixtures/source_range_helpers.rb +++ /dev/null @@ -1,31 +0,0 @@ -def source_range_values(range) - [range.start_line, range.start_column, range.end_line, range.end_column] -end - -# Use <<-RUBY and not <<~RUBY to keep some spaces in front to make it more representative of a Proc in some file -def check_source_range(source) - raise "Expected 2 '$' to mark start and end of source_range" unless source.count('$') == 2 - from = source.byteindex('$') - to = source.byteindex('$', from+1) - lines = source.lines - from_line = 1 + source.byteslice(0, from).count("\n") - from_column = lines[from_line-1].byteindex('$') - to_line = 1 + source.byteslice(0, to).count("\n") - if from_line == to_line - to_column = lines[to_line-1].byteindex('$', from_column + 1) - 1 - else - to_column = lines[to_line-1].byteindex('$') - end - - eval_source = source.gsub('$', '') - result = eval(eval_source) - source_range = result.source_range - source_range.should.instance_of?(Ruby::SourceRange) - source_range.start_line.should == from_line - source_range.start_column.should == from_column - source_range.end_line.should == to_line - source_range.end_column.should == to_column - - # Check consistency with source_location start line - result.source_location[1].should == from_line -end diff --git a/spec/ruby/core/proc/source_range_spec.rb b/spec/ruby/core/proc/source_range_spec.rb index 81c803cc6bf..c0cf699a56e 100644 --- a/spec/ruby/core/proc/source_range_spec.rb +++ b/spec/ruby/core/proc/source_range_spec.rb @@ -1,5 +1,5 @@ require_relative '../../spec_helper' -require_relative 'fixtures/source_range_helpers' +require_relative '../../fixtures/source_range_helpers' ruby_version_is "4.1" do describe "Proc#source_range" do diff --git a/spec/ruby/core/thread/backtrace/location/source_range_spec.rb b/spec/ruby/core/thread/backtrace/location/source_range_spec.rb new file mode 100644 index 00000000000..7246eec99ec --- /dev/null +++ b/spec/ruby/core/thread/backtrace/location/source_range_spec.rb @@ -0,0 +1,393 @@ +require_relative '../../../../spec_helper' +require_relative '../../../../fixtures/source_range_helpers' + +ruby_version_is "4.1" do + describe "Thread::Backtrace::Location#source_range" do + it "returns a Ruby::SourceRange with the location paths" do + location, range, path, absolute_path = capture_backtrace_location_source_range(<<-RUBY) + $nil.foo$ + RUBY + + range.should.instance_of?(Ruby::SourceRange) + range.path.should == path + range.absolute_path.should == absolute_path + location.path.should == path + location.absolute_path.should == absolute_path + end + + { + "receiver calls with arguments" => <<-RUBY, + $nil.foo(42)$ + RUBY + + "receiver calls split across lines" => <<-RUBY, + $nil + .foo( + 42 + )$ + RUBY + + "safe navigation calls" => <<-RUBY, + $1&.foo(42)$ + RUBY + + ".() call syntax" => <<-RUBY, + $nil.(42)$ + RUBY + + "calls to send" => <<-RUBY, + $nil.send(:foo, 42)$ + RUBY + + "index reads" => <<-RUBY, + $nil[0]$ + RUBY + + "index writes" => <<-RUBY, + $nil[0] = 42$ + RUBY + + "explicit index write calls" => <<-RUBY, + $nil.[]=$ + RUBY + + "attribute writes" => <<-RUBY, + $nil.foo = 42$ + RUBY + + "binary operator calls split by a comment" => <<-RUBY, + $nil + # comment + 42$ + RUBY + + "unary operator calls" => <<-RUBY, + $+nil$ + RUBY + + "function calls" => <<-RUBY, + "str".instance_eval { $gsub("foo", :sym)$ } + RUBY + + "function calls without ()" => <<-RUBY, + "str".instance_eval { $gsub "foo", :sym$ } + RUBY + + "variable calls" => <<-RUBY, + nil.instance_eval { $foo$ } + RUBY + + "local variable operator assignments" => <<-RUBY, + value = nil + $value += 42$ + RUBY + + "index operator assignments failing while reading" => <<-RUBY, + value = nil + $value[0] += 42$ + RUBY + + "index operator assignments failing in the operator" => <<-RUBY, + value = Object.new + def value.[](index) = nil + $value[0] += 42$ + RUBY + + "index operator assignments failing while writing" => <<-RUBY, + value = Object.new + def value.[](index) = 1 + $value[0] += 42$ + RUBY + + "index operator assignments failing on an argument" => <<-RUBY, + value = [] + $value[nil] += 42$ + RUBY + + "attribute operator assignments failing while reading" => <<-RUBY, + value = nil + $value.foo += 42$ + RUBY + + "attribute operator assignments failing in the operator" => <<-RUBY, + value = Object.new + def value.foo = nil + $value.foo += 42$ + RUBY + + "attribute operator assignments failing while writing" => <<-RUBY, + value = Object.new + def value.foo = 1 + $value.foo += 42$ + RUBY + + "attribute operator assignments failing on the value" => <<-RUBY, + value = Object.new + def value.foo = 1 + def value.foo=(new_value) + new_value + end + $value.foo += nil$ + RUBY + + "bare constants" => <<-RUBY, + $SourceRangeNotDefined$ + RUBY + + "qualified constants" => <<-RUBY, + $Object::SourceRangeNotDefined$ + RUBY + + "qualified constants split across lines" => <<-RUBY, + $Object:: + SourceRangeNotDefined$ + RUBY + + "top-level constants" => <<-RUBY, + $::SourceRangeNotDefined$ + RUBY + + "constant operator assignments" => <<-RUBY, + namespace = Module.new + namespace.const_set(:Nil, nil) + $namespace::Nil += 1$ + RUBY + + "constant operator assignments failing while reading" => <<-RUBY, + namespace = Module.new + $namespace::NotDefined += 1$ + RUBY + + "top-level constant operator assignments" => <<-RUBY, + $::SourceRangeNotDefined += 1$ + RUBY + + "explicit raises" => <<-RUBY, + $raise NameError$ + RUBY + + "calls failing while converting arguments" => <<-RUBY, + $1.+(nil)$ + RUBY + + "calls with brace blocks" => <<-RUBY, + $nil.foo(1) { 2 }$ + RUBY + + "calls with do-end blocks" => <<-RUBY, + $nil.foo(1) do + 2 + end$ + RUBY + + "calls with heredoc arguments" => <<-RUBY, + $nil.foo(<<~TEXT)$ + heredoc + TEXT + RUBY + + "multibyte identifiers with byte columns" => <<-RUBY, + value = "été" + $value.あいうえお$ + RUBY + + "hard tabs" => "\t \t$1.time {}$\n", + + "a missing final newline" => "$1.time {}$", + + "very long source lines" => ("1" * 100) + " + $1.time {}$\n", + }.each do |description, source| + it "returns the precise range for #{description}" do + capture_backtrace_location_source_range(source) + end + end + + it "returns the method definition for a method arity error" do + capture_backtrace_location_source_range(<<-RUBY) + target = Class.new do + $def source_range_target(first, second) + first + second + end$ + end.new + target.source_range_target(1) + RUBY + end + + it "returns the call for the caller frame of a method arity error" do + capture_backtrace_location_source_range(<<-RUBY, frame: 1) + target = Class.new do + def source_range_target(first, second) + first + second + end + end.new + $target.source_range_target(1)$ + RUBY + end + + it "returns a multiline method definition for a method arity error" do + capture_backtrace_location_source_range(<<-RUBY) + target = Class.new do + $def source_range_target( + first, + second + ) + first + second + end$ + end.new + target.source_range_target(1) + RUBY + end + + it "returns a singleton method definition with spacing for a keyword arity error" do + capture_backtrace_location_source_range(<<-RUBY) + target = Object.new + $def target . source_range_target(value:) + value + end$ + target.source_range_target + RUBY + end + + it "returns a stabby lambda for an arity error" do + capture_backtrace_location_source_range(<<-RUBY) + value = $->(argument) {}$ + value.call + RUBY + end + + it "returns only the block for an arity error in a Kernel#lambda" do + capture_backtrace_location_source_range(<<-RUBY) + value = lambda ${ |argument| }$ + value.call + RUBY + end + + it "returns only the block for an arity error in a define_method" do + capture_backtrace_location_source_range(<<-RUBY) + target = Class.new do + define_method(:source_range_target) $do |first, second| + first + second + end$ + end.new + target.source_range_target(1) + RUBY + end + + it "propagates an error when the absolute source file no longer exists" do + keep_source(false) do + location, path = capture_backtrace_location_from_source("nil.foo\n") + rm_r path + + -> { + location.source_range + }.should.raise(Errno::ENOENT) + ensure + rm_r path if path + end + end + + it "propagates a syntax error from changed source" do + keep_source(false) do + location, path = capture_backtrace_location_from_source("nil.foo\n") + File.binwrite(path, "(\n") + + -> { + location.source_range + }.should.raise(SyntaxError) + ensure + rm_r path if path + end + end + + it "raises when changed source no longer contains the node ID" do + keep_source(false) do + location, path = capture_backtrace_location_from_source("first = 1\nsecond = 2\nnil.foo\n") + File.binwrite(path, "nil\n") + + -> { + location.source_range + }.should.raise(RuntimeError, /cannot find node ID \d+ in parsed source/) + ensure + rm_r path if path + end + end + + it "uses retained eval source and preserves its starting line" do + keep_source do + path = File.realpath(__FILE__) + + location, range = capture_eval_backtrace_location_source_range( + "$nil.foo$", + path, + 100 + ) + + range.path.should == path + range.absolute_path.should == nil + range.start_line.should == 100 + location.lineno.should == 100 + end + end + + it "preserves the starting line for blocks in retained eval source" do + keep_source do + location, range = capture_eval_backtrace_location_source_range(<<-RUBY, "source_range_eval.rb", 100) + value = lambda ${ |argument| }$ + value.call + RUBY + + range.start_line.should == 100 + location.lineno.should == 100 + end + end + + it "does not open an eval path even when it names an existing absolute file" do + keep_source(false) do + path = File.realpath(__FILE__) + + exception = nil + begin + eval("nil.foo", binding, path) + rescue Exception => error + exception = error + end + + -> { + exception.backtrace_locations.first.source_range + }.should.raise(ArgumentError, "cannot get source range for location in eval") + end + end + + it "does not treat an eval path named -e as command-line source" do + keep_source(false) do + exception = nil + begin + eval("nil.foo", binding, "-e") + rescue Exception => error + exception = error + end + + -> { + exception.backtrace_locations.first.source_range + }.should.raise(ArgumentError, "cannot get source range for location in eval") + end + end + + it "does not treat a method from eval named -e as command-line source" do + code = "eval(%q{def spoofed_source_range_target; nil.foo; end}, binding, %q{-e}); " \ + "begin; spoofed_source_range_target; rescue => e; " \ + "begin; e.backtrace_locations.first.source_range; rescue => source_error; " \ + "p source_error; end; end" + ruby_exe(code, escape: false).should == "#\n" + end + + it "works for -e source" do + code = "def source_range_target; nil.foo; end; " \ + "begin; source_range_target; rescue => e; " \ + "r = e.backtrace_locations.first.source_range; " \ + "p [r.path, r.absolute_path, r.start_line, r.start_column, r.end_line, r.end_column]; end" + start_column = code.byteindex("nil.foo") + expected = ["-e", nil, 1, start_column, 1, start_column + "nil.foo".bytesize] + ruby_exe(code, escape: false).should == "#{expected.inspect}\n" + end + end +end diff --git a/spec/ruby/fixtures/source_range_helpers.rb b/spec/ruby/fixtures/source_range_helpers.rb new file mode 100644 index 00000000000..aeed5cc8944 --- /dev/null +++ b/spec/ruby/fixtures/source_range_helpers.rb @@ -0,0 +1,111 @@ +def source_range_values(range) + [range.start_line, range.start_column, range.end_line, range.end_column] +end + +def keep_source(value = true) + return yield unless defined?(RubyVM.keep_script_lines) + + previous = RubyVM.keep_script_lines + begin + RubyVM.keep_script_lines = value + yield + ensure + RubyVM.keep_script_lines = previous + end +end + +def source_range_source(source) + raise "Expected 2 '$' to mark start and end of source_range" unless source.count('$') == 2 + from = source.byteindex('$') + to = source.byteindex('$', from + 1) + lines = source.lines + from_line = 1 + source.byteslice(0, from).count("\n") + from_column = lines[from_line-1].byteindex('$') + to_line = 1 + source.byteslice(0, to).count("\n") + if from_line == to_line + to_column = lines[to_line-1].byteindex('$', from_column + 1) - 1 + else + to_column = lines[to_line-1].byteindex('$') + end + + eval_source = source.gsub('$', '') + [eval_source, [from_line, from_column, to_line, to_column]] +end + +# Use <<-RUBY and not <<~RUBY to keep some spaces in front to make it more representative of a Proc in some file +def check_source_range(marked_source) + source, expected = source_range_source(marked_source) + result = eval(source) + range = result.source_range + range.should.instance_of?(Ruby::SourceRange) + source_range_values(range).should == expected + + # Check consistency with source_location start line + result.source_location[1].should == expected[0] +end + +def capture_backtrace_location_source_range(marked_source, frame: 0) + source, expected = source_range_source(marked_source) + path = tmp("backtrace_location_source_range.rb") + File.binwrite(path, source) + absolute_path = File.realpath(path) + + exception = nil + begin + load path + rescue Exception => error + exception = error + end + + raise "Expected source to raise an exception" unless exception + + location = exception.backtrace_locations.fetch(frame) + range = location.source_range + range.should.instance_of?(Ruby::SourceRange) + source_range_values(range).should == expected + + [location, range, path, absolute_path] +ensure + rm_r path if path +end + +def capture_backtrace_location_from_source(source, frame: 0) + path = tmp("backtrace_location_source_range.rb") + File.binwrite(path, source) + + exception = nil + begin + load path + rescue Exception => error + exception = error + end + + raise "Expected source to raise an exception" unless exception + + [exception.backtrace_locations.fetch(frame), path] +end + +def capture_eval_backtrace_location_source_range(marked_source, path, first_lineno) + source, expected = source_range_source(marked_source) + exception = nil + + begin + eval(source, binding, path, first_lineno) + rescue Exception => error + exception = error + end + + raise "Expected source to raise an exception" unless exception + + location = exception.backtrace_locations.first + range = location.source_range + range.should.instance_of?(Ruby::SourceRange) + source_range_values(range).should == [ + expected[0] + first_lineno - 1, + expected[1], + expected[2] + first_lineno - 1, + expected[3] + ] + + [location, range] +end diff --git a/vm_backtrace.c b/vm_backtrace.c index 5af6cc341a8..c987b44f1f0 100644 --- a/vm_backtrace.c +++ b/vm_backtrace.c @@ -14,6 +14,8 @@ #include "internal/class.h" #include "internal/error.h" #include "internal/object.h" +#include "internal/proc.h" +#include "internal/ruby_parser.h" #include "internal/vm.h" #include "iseq.h" #include "ruby/debug.h" @@ -407,8 +409,160 @@ location_node_id(rb_backtrace_location_t *loc) } return -1; } + +extern VALUE rb_e_script; + +static bool +location_code_location_equal(const rb_code_location_t *left, const rb_code_location_t *right) +{ + return left->beg_pos.lineno == right->beg_pos.lineno && + left->beg_pos.column == right->beg_pos.column && + left->end_pos.lineno == right->end_pos.lineno && + left->end_pos.column == right->end_pos.column; +} + +static bool +iseq_from_e_script_p(const rb_iseq_t *iseq, VALUE path) +{ + if (!RB_TYPE_P(path, T_STRING) || + RSTRING_LEN(path) != 2 || + memcmp(RSTRING_PTR(path), "-e", 2) != 0 || + !RTEST(rb_e_script)) { + return false; + } + + const rb_iseq_t *source_iseq = iseq; + for (; source_iseq; source_iseq = ISEQ_BODY(source_iseq)->parent_iseq) { + if (ISEQ_BODY(source_iseq)->type == ISEQ_TYPE_EVAL) return false; + if (ISEQ_BODY(source_iseq)->type == ISEQ_TYPE_MAIN) return true; + } + + int node_id = ISEQ_BODY(iseq)->location.node_id; + if (node_id == -1) return false; + + rb_code_location_t source_location; + bool found; + if (ISEQ_BODY(iseq)->prism) { + found = pm_node_source_location(rb_e_script, path, 1, node_id, &source_location); + } + else { + found = rb_ast_node_source_location( + rb_e_script, + path, + 1, + node_id, + ISEQ_BODY(iseq)->type == ISEQ_TYPE_BLOCK, + node_id, + &source_location + ); + } + + return found && location_code_location_equal( + &source_location, &ISEQ_BODY(iseq)->location.code_location); +} + +static int +location_source_first_lineno(const rb_iseq_t *iseq, VALUE script_lines) +{ + const rb_iseq_t *source_iseq = iseq; + + while (ISEQ_BODY(source_iseq)->parent_iseq) { + const rb_iseq_t *parent = ISEQ_BODY(source_iseq)->parent_iseq; + if (ISEQ_BODY(parent)->variable.script_lines != script_lines) break; + source_iseq = parent; + } + + return ISEQ_BODY(source_iseq)->location.first_lineno; +} #endif +/* + * call-seq: + * location.source_range -> Ruby::SourceRange or nil + * + * Returns the Ruby::SourceRange for the Ruby expression associated with this + * backtrace location, or +nil+ when the location is not available + * (e.g., the source is not Ruby code). + * + * This method requires re-reading the source file from the filesystem + * (since this information is not kept in the bytecode to avoid memory overhead). + * Errno::ENOENT if the source file no longer exists. + * RuntimeError is raised if the file has been modified. + * + * On CRuby, `RubyVM.keep_script_lines = true` can be used to avoid to re-read + * source files from the filesystem, however this will increase memory usage, + * by keeping all source files in memory. + * + * Locations from eval'd code are only available with `RubyVM.keep_script_lines = true`. + */ +static VALUE +location_source_range_m(VALUE self) +{ +#ifdef USE_ISEQ_NODE_ID + rb_backtrace_location_t *backtrace_location = location_ptr(self); + const rb_iseq_t *iseq = location_iseq(backtrace_location); + if (!iseq) return Qnil; + + rb_iseq_check(iseq); + int node_id = location_node_id(backtrace_location); + if (node_id == -1) return Qnil; + + VALUE path = rb_iseq_path(iseq); + VALUE absolute_path = rb_iseq_realpath(iseq); + VALUE script_lines = ISEQ_BODY(iseq)->variable.script_lines; + VALUE source = script_lines; + VALUE parser_path = path; + int first_lineno = 1; + + if (!NIL_P(script_lines)) { + first_lineno = location_source_first_lineno(iseq, script_lines); + } + else if (iseq_from_e_script_p(iseq, path)) { + source = rb_e_script; + } + else if (!NIL_P(absolute_path)) { + source = Qnil; + parser_path = absolute_path; + } + else { + rb_raise(rb_eArgError, "cannot get source range for location in eval"); + } + + if (NIL_P(parser_path)) { + parser_path = rb_str_new_cstr("(eval)"); + } + + rb_code_location_t code_location; + bool found; + + if (ISEQ_BODY(iseq)->prism) { + if (RB_TYPE_P(source, T_ARRAY)) { + source = rb_ary_join(source, Qnil); + } + found = pm_node_source_location(source, parser_path, first_lineno, node_id, &code_location); + } + else { + found = rb_ast_node_source_location( + source, + parser_path, + first_lineno, + node_id, + ISEQ_BODY(iseq)->type == ISEQ_TYPE_BLOCK, + ISEQ_BODY(iseq)->location.node_id, + &code_location + ); + } + + if (!found) { + rb_raise(rb_eRuntimeError, "cannot find node ID %d in parsed source", node_id); + } + + return rb_source_range_new(path, absolute_path, &code_location); +#else + return Qnil; +#endif +} + int rb_get_node_id_from_frame_info(VALUE obj) { @@ -1530,6 +1684,7 @@ Init_vm_backtrace(void) rb_define_method(rb_cBacktraceLocation, "base_label", location_base_label_m, 0); rb_define_method(rb_cBacktraceLocation, "path", location_path_m, 0); rb_define_method(rb_cBacktraceLocation, "absolute_path", location_absolute_path_m, 0); + rb_define_method(rb_cBacktraceLocation, "source_range", location_source_range_m, 0); rb_define_method(rb_cBacktraceLocation, "to_s", location_to_str_m, 0); rb_define_method(rb_cBacktraceLocation, "inspect", location_inspect_m, 0);