diff --git a/.ameba.yml b/.ameba.yml new file mode 100644 index 0000000..399b108 --- /dev/null +++ b/.ameba.yml @@ -0,0 +1,3 @@ +Lint/NotNil: + Excluded: + - spec/backtracer/backtrace/frame/parser_spec.cr diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ac9ac2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + pull_request: + schedule: + - cron: "0 3 * * 1" # Every monday at 3 AM + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + crystal: [latest, nightly] + runs-on: ${{ matrix.os }} + + steps: + - name: Install Crystal + uses: oprypin/install-crystal@v1 + with: + crystal: ${{ matrix.crystal }} + + - name: Download source + uses: actions/checkout@v2 + + - name: Install dependencies + run: shards install + env: + SHARDS_OPTS: --ignore-crystal-version + + - name: Run specs + run: | + crystal spec + crystal spec --no-debug + + - name: Run specs (release) + run: | + crystal spec --release + crystal spec --release --no-debug + + - name: Check formatting + run: crystal tool format --check + + - name: Run ameba linter + run: bin/ameba diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7521934..0000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -language: crystal - -crystal: - - latest - - nightly - -jobs: - allow_failures: - - crystal: nightly - -install: - - shards install - -script: - - crystal spec - - crystal spec --no-debug - - crystal spec --release - - crystal spec --release --no-debug - - crystal tool format --check - - bin/ameba diff --git a/README.md b/README.md index 03ca6ae..8d17ade 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# backtracer.cr [![Build Status](https://travis-ci.com/Sija/backtracer.cr.svg?branch=master)](https://travis-ci.com/Sija/backtracer.cr) [![Releases](https://img.shields.io/github/release/Sija/backtracer.cr.svg)](https://github.com/Sija/backtracer.cr/releases) [![License](https://img.shields.io/github/license/Sija/backtracer.cr.svg)](https://github.com/Sija/backtracer.cr/blob/master/LICENSE) +# backtracer.cr [![CI](https://github.com/Sija/backtracer.cr/actions/workflows/ci.yml/badge.svg)](https://github.com/Sija/backtracer.cr/actions/workflows/ci.yml) [![Releases](https://img.shields.io/github/release/Sija/backtracer.cr.svg)](https://github.com/Sija/backtracer.cr/releases) [![License](https://img.shields.io/github/license/Sija/backtracer.cr.svg)](https://github.com/Sija/backtracer.cr/blob/master/LICENSE) Crystal shard aiming to assist with parsing backtraces into a structured form. diff --git a/shard.yml b/shard.yml index 20cf883..96ff072 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: backtracer -version: 1.0.0 +version: 1.2.2 authors: - Sijawusz Pur Rahnama @@ -7,7 +7,7 @@ authors: development_dependencies: ameba: github: crystal-ameba/ameba - version: ~> 0.13.0 + version: ~> 1.5.0 crystal: ">= 0.35.0" diff --git a/spec/backtracer/backtrace/frame/context_spec.cr b/spec/backtracer/backtrace/frame/context_spec.cr new file mode 100644 index 0000000..b6fc3aa --- /dev/null +++ b/spec/backtracer/backtrace/frame/context_spec.cr @@ -0,0 +1,60 @@ +require "../../../spec_helper" + +def with_foo_context(&) + yield Backtracer::Backtrace::Frame::Context.new( + lineno: 10, + pre: %w[foo bar baz], + line: "violent offender!", + post: %w[boo far faz], + ) +end + +describe Backtracer::Backtrace::Frame::Context do + describe ".to_a" do + it "works with empty #pre and #post" do + context = Backtracer::Backtrace::Frame::Context.new( + lineno: 1, + pre: %w[], + line: "violent offender!", + post: %w[], + ) + context.to_a.should eq(["violent offender!"]) + end + + it "returns array with #pre, #line and #post strings" do + with_foo_context do |context| + context.to_a.should eq([ + "foo", "bar", "baz", + "violent offender!", + "boo", "far", "faz", + ]) + end + end + end + + describe ".to_h" do + it "works with empty #pre and #post" do + context = Backtracer::Backtrace::Frame::Context.new( + lineno: 1, + pre: %w[], + line: "violent offender!", + post: %w[], + ) + context.to_h.should eq({1 => "violent offender!"}) + end + + it "returns hash with #pre, #line and #post strings" do + with_foo_context do |context| + context.to_h.should eq({ + 7 => "foo", + 8 => "bar", + 9 => "baz", + 10 => "violent offender!", + 11 => "boo", + 12 => "far", + 13 => "faz", + }) + end + end + end +end diff --git a/spec/backtracer/backtrace/frame/parser_spec.cr b/spec/backtracer/backtrace/frame/parser_spec.cr new file mode 100644 index 0000000..7df2f9e --- /dev/null +++ b/spec/backtracer/backtrace/frame/parser_spec.cr @@ -0,0 +1,202 @@ +require "../../../spec_helper" + +describe Backtracer::Backtrace::Frame::Parser do + describe ".parse" do + it "fails to parse an empty string" do + expect_raises(ArgumentError) { with_frame("", &.itself) } + end + + context "when --no-debug flag is set" do + it "parses frame with any value as method" do + backtrace_line = "__crystal_main" + + with_frame(backtrace_line) do |frame| + frame.lineno.should be_nil + frame.column.should be_nil + frame.method.should eq(backtrace_line) + frame.path.should be_nil + frame.relative_path.should be_nil + frame.under_src_path?.should be_false + frame.shard_name.should be_nil + frame.in_app?.should be_false + end + end + end + + context "with ~proc signature" do + it "parses absolute path outside of src/ dir" do + path = "/usr/local/Cellar/crystal/0.27.2/src/fiber.cr" + backtrace_line = "~proc2Proc(Fiber, (IO::FileDescriptor | Nil))@#{path}:72" + + with_frame(backtrace_line) do |frame| + frame.lineno.should eq(72) + frame.column.should be_nil + frame.method.should eq("~proc2Proc(Fiber, (IO::FileDescriptor | Nil))") + frame.path.should eq(path) + frame.absolute_path.should eq(frame.path) + frame.relative_path.should be_nil + frame.under_src_path?.should be_false + frame.shard_name.should be_nil + frame.in_app?.should be_false + end + end + + it "parses relative path inside of lib/ dir" do + with_configuration do |configuration| + path = "lib/kemal/src/kemal/route.cr" + backtrace_line = "~procProc(HTTP::Server::Context, String)@#{path}:11" + + with_frame(backtrace_line) do |frame| + frame.lineno.should eq(11) + frame.column.should be_nil + frame.method.should eq("~procProc(HTTP::Server::Context, String)") + frame.path.should eq(path) + frame.absolute_path.should eq( + File.join(configuration.src_path.not_nil!, path) + ) + frame.relative_path.should eq(frame.path) + frame.under_src_path?.should be_false + frame.shard_name.should eq("kemal") + frame.in_app?.should be_false + end + end + end + end + + it "parses absolute path outside of configuration.src_path" do + path = "/some/absolute/path/to/foo.cr" + + with_foo_frame(path: path) do |frame| + frame.lineno.should eq(1) + frame.column.should eq(7) + frame.method.should eq("foo_bar?") + frame.path.should eq(path) + frame.absolute_path.should eq(frame.path) + frame.relative_path.should be_nil + frame.under_src_path?.should be_false + frame.shard_name.should be_nil + frame.in_app?.should be_false + end + end + + context "with in_app? = false" do + it "parses absolute path outside of src/ dir" do + with_foo_frame(path: "#{__DIR__}/foo.cr") do |frame| + frame.lineno.should eq(1) + frame.column.should eq(7) + frame.method.should eq("foo_bar?") + frame.path.should eq("#{__DIR__}/foo.cr") + frame.absolute_path.should eq(frame.path) + frame.relative_path.should eq("spec/backtracer/backtrace/frame/foo.cr") + frame.under_src_path?.should be_true + frame.shard_name.should be_nil + frame.in_app?.should be_false + end + end + + it "parses relative path outside of src/ dir" do + with_configuration do |configuration| + path = "some/relative/path/to/foo.cr" + + with_foo_frame(path: path) do |frame| + frame.lineno.should eq(1) + frame.column.should eq(7) + frame.method.should eq("foo_bar?") + frame.path.should eq(path) + frame.absolute_path.should eq( + File.join(configuration.src_path.not_nil!, path) + ) + frame.relative_path.should eq(frame.path) + frame.under_src_path?.should be_false + frame.shard_name.should be_nil + frame.in_app?.should be_false + end + end + end + end + + context "with in_app? = true" do + it "parses absolute path inside of src/ dir" do + src_path = File.expand_path("../../../../src", __DIR__) + path = "#{src_path}/foo.cr" + + with_foo_frame(path: path) do |frame| + frame.lineno.should eq(1) + frame.column.should eq(7) + frame.method.should eq("foo_bar?") + frame.path.should eq(path) + frame.absolute_path.should eq(frame.path) + frame.relative_path.should eq("src/foo.cr") + frame.under_src_path?.should be_true + frame.shard_name.should be_nil + frame.in_app?.should be_true + end + end + + it "parses relative path inside of src/ dir" do + with_configuration do |configuration| + path = "src/foo.cr" + + with_foo_frame(path: path) do |frame| + frame.lineno.should eq(1) + frame.column.should eq(7) + frame.method.should eq("foo_bar?") + frame.path.should eq(path) + frame.absolute_path.should eq( + File.join(configuration.src_path.not_nil!, path) + ) + frame.relative_path.should eq(path) + frame.under_src_path?.should be_false + frame.shard_name.should be_nil + frame.in_app?.should be_true + end + end + end + end + + context "with shard path" do + it "parses absolute path inside of lib/ dir" do + lib_path = File.expand_path("../../../../lib/bar", __DIR__) + path = "#{lib_path}/src/bar.cr" + + with_foo_frame(path: path) do |frame| + frame.lineno.should eq(1) + frame.column.should eq(7) + frame.method.should eq("foo_bar?") + frame.path.should eq(path) + frame.absolute_path.should eq(frame.path) + frame.relative_path.should eq("lib/bar/src/bar.cr") + frame.under_src_path?.should be_true + frame.shard_name.should eq "bar" + frame.in_app?.should be_false + end + end + + it "parses relative path inside of lib/ dir" do + with_configuration do |configuration| + path = "lib/bar/src/bar.cr" + + with_foo_frame(path: path) do |frame| + frame.lineno.should eq(1) + frame.column.should eq(7) + frame.method.should eq("foo_bar?") + frame.path.should eq(path) + frame.absolute_path.should eq( + File.join(configuration.src_path.not_nil!, path) + ) + frame.relative_path.should eq(path) + frame.under_src_path?.should be_false + frame.shard_name.should eq "bar" + frame.in_app?.should be_false + end + end + end + + it "uses only folders for shard names" do + with_foo_frame(path: "lib/bar.cr") do |frame| + frame.shard_name.should be_nil + end + end + end + end +end diff --git a/spec/backtracer/backtrace/frame_spec.cr b/spec/backtracer/backtrace/frame_spec.cr index 5f6032c..53c3766 100644 --- a/spec/backtracer/backtrace/frame_spec.cr +++ b/spec/backtracer/backtrace/frame_spec.cr @@ -1,207 +1,6 @@ require "../../spec_helper" -private def parse_frame(line) - Backtracer::Backtrace::Frame::Parser.parse(line) -end - -private def with_frame(method, path = nil, lineno = nil, column = nil) - line = String.build do |io| - if path - io << path - io << ':' << lineno if lineno - io << ':' << column if column - io << " in '" << method << '\'' - else - io << method - end - end - yield parse_frame(line) -end - -private def with_foo_frame( - method = "foo_bar?", - path = "#{__DIR__}/foo.cr", - lineno = 1, - column = 7 -) - with_frame(method, path, lineno, column) do |frame| - yield frame - end -end - describe Backtracer::Backtrace::Frame do - describe ".parse" do - it "fails to parse an empty string" do - expect_raises(ArgumentError) { parse_frame("") } - end - - context "when --no-debug flag is set" do - it "parses frame with any value as method" do - backtrace_line = "__crystal_main" - - with_frame(backtrace_line) do |frame| - frame.lineno.should be_nil - frame.column.should be_nil - frame.method.should eq(backtrace_line) - frame.path.should be_nil - frame.relative_path.should be_nil - frame.under_src_path?.should be_false - frame.shard_name.should be_nil - frame.in_app?.should be_false - end - end - end - - context "with ~proc signature" do - it "parses absolute path outside of src/ dir" do - backtrace_line = "~proc2Proc(Fiber, (IO::FileDescriptor | Nil))@/usr/local/Cellar/crystal/0.27.2/src/fiber.cr:72" - - with_frame(backtrace_line) do |frame| - frame.lineno.should eq(72) - frame.column.should be_nil - frame.method.should eq("~proc2Proc(Fiber, (IO::FileDescriptor | Nil))") - frame.path.should eq("/usr/local/Cellar/crystal/0.27.2/src/fiber.cr") - frame.relative_path.should be_nil - frame.under_src_path?.should be_false - frame.shard_name.should be_nil - frame.in_app?.should be_false - end - end - - it "parses relative path inside of lib/ dir" do - backtrace_line = "~procProc(HTTP::Server::Context, String)@lib/kemal/src/kemal/route.cr:11" - - with_frame(backtrace_line) do |frame| - frame.lineno.should eq(11) - frame.column.should be_nil - frame.method.should eq("~procProc(HTTP::Server::Context, String)") - frame.path.should eq("lib/kemal/src/kemal/route.cr") - frame.relative_path.should eq("lib/kemal/src/kemal/route.cr") - frame.under_src_path?.should be_false - frame.shard_name.should eq("kemal") - frame.in_app?.should be_false - end - end - end - - it "parses absolute path outside of configuration.src_path" do - path = "/some/absolute/path/to/foo.cr" - - with_foo_frame(path: path) do |frame| - frame.lineno.should eq(1) - frame.column.should eq(7) - frame.method.should eq("foo_bar?") - frame.path.should eq(path) - frame.relative_path.should be_nil - frame.under_src_path?.should be_false - frame.shard_name.should be_nil - frame.in_app?.should be_false - end - end - - context "with in_app? = false" do - it "parses absolute path outside of src/ dir" do - with_foo_frame do |frame| - frame.lineno.should eq(1) - frame.column.should eq(7) - frame.method.should eq("foo_bar?") - frame.path.should eq("#{__DIR__}/foo.cr") - frame.relative_path.should eq("spec/backtracer/backtrace/foo.cr") - frame.under_src_path?.should be_true - frame.shard_name.should be_nil - frame.in_app?.should be_false - end - end - - it "parses relative path outside of src/ dir" do - path = "some/relative/path/to/foo.cr" - - with_foo_frame(path: path) do |frame| - frame.lineno.should eq(1) - frame.column.should eq(7) - frame.method.should eq("foo_bar?") - frame.path.should eq(path) - frame.relative_path.should eq(path) - frame.under_src_path?.should be_false - frame.shard_name.should be_nil - frame.in_app?.should be_false - end - end - end - - context "with in_app? = true" do - it "parses absolute path inside of src/ dir" do - src_path = File.expand_path("../../../src", __DIR__) - path = "#{src_path}/foo.cr" - - with_foo_frame(path: path) do |frame| - frame.lineno.should eq(1) - frame.column.should eq(7) - frame.method.should eq("foo_bar?") - frame.path.should eq(path) - frame.relative_path.should eq("src/foo.cr") - frame.under_src_path?.should be_true - frame.shard_name.should be_nil - frame.in_app?.should be_true - end - end - - it "parses relative path inside of src/ dir" do - path = "src/foo.cr" - - with_foo_frame(path: path) do |frame| - frame.lineno.should eq(1) - frame.column.should eq(7) - frame.method.should eq("foo_bar?") - frame.path.should eq(path) - frame.relative_path.should eq(path) - frame.under_src_path?.should be_false - frame.shard_name.should be_nil - frame.in_app?.should be_true - end - end - end - - context "with shard path" do - it "parses absolute path inside of lib/ dir" do - lib_path = File.expand_path("../../../lib/bar", __DIR__) - path = "#{lib_path}/src/bar.cr" - - with_foo_frame(path: path) do |frame| - frame.lineno.should eq(1) - frame.column.should eq(7) - frame.method.should eq("foo_bar?") - frame.path.should eq(path) - frame.relative_path.should eq("lib/bar/src/bar.cr") - frame.under_src_path?.should be_true - frame.shard_name.should eq "bar" - frame.in_app?.should be_false - end - end - - it "parses relative path inside of lib/ dir" do - path = "lib/bar/src/bar.cr" - - with_foo_frame(path: path) do |frame| - frame.lineno.should eq(1) - frame.column.should eq(7) - frame.method.should eq("foo_bar?") - frame.path.should eq(path) - frame.relative_path.should eq(path) - frame.under_src_path?.should be_false - frame.shard_name.should eq "bar" - frame.in_app?.should be_false - end - end - - it "uses only folders for shard names" do - with_foo_frame(path: "lib/bar.cr") do |frame| - frame.shard_name.should be_nil - end - end - end - end - it "#inspect" do with_foo_frame do |frame| frame.inspect.should match(/Backtrace::Frame(.*)$/) @@ -209,7 +8,7 @@ describe Backtracer::Backtrace::Frame do end it "#to_s" do - with_foo_frame do |frame| + with_foo_frame(path: "#{__DIR__}/foo.cr") do |frame| frame.to_s.should eq "`foo_bar?` at #{__DIR__}/foo.cr:1:7" end end @@ -219,9 +18,44 @@ describe Backtracer::Backtrace::Frame do with_foo_frame do |frame2| frame.should eq(frame2) end - with_foo_frame(method: "other_method") do |frame2| - frame.should_not eq(frame2) + with_foo_frame(method: "other_method") do |frame3| + frame.should_not eq(frame3) end end end + + {% unless flag?(:release) || !flag?(:debug) %} + describe "#context" do + it "returns proper lines" do + with_configuration do |configuration| + with_backtrace(caller) do |backtrace| + backtrace.frames.first.tap do |first_frame| + context_lines = configuration.context_lines.should_not be_nil + context = first_frame.context.should_not be_nil + + lines = File.read_lines(__FILE__) + lineidx = context.lineno - 1 + + context.pre + .should eq(lines[Math.max(0, lineidx - context_lines), context_lines]?) + context.line + .should eq(lines[lineidx]?) + context.post + .should eq(lines[Math.min(lines.size, lineidx + 1), context_lines]?) + end + end + end + end + + it "returns given amount of lines" do + with_backtrace(caller) do |backtrace| + backtrace.frames.first.tap do |first_frame| + context = first_frame.context(3).should_not be_nil + context.pre.size.should eq(3) + context.post.size.should eq(3) + end + end + end + end + {% end %} end diff --git a/spec/backtracer/backtrace/parser_spec.cr b/spec/backtracer/backtrace/parser_spec.cr new file mode 100644 index 0000000..7713983 --- /dev/null +++ b/spec/backtracer/backtrace/parser_spec.cr @@ -0,0 +1,21 @@ +require "../../spec_helper" + +describe Backtracer::Backtrace::Parser do + describe ".parse" do + it "handles `caller` as an input" do + with_backtrace(caller) do |backtrace| + backtrace.frames.should_not be_empty + end + end + + it "handles `Exception#backtrace` as an input" do + begin + raise "Oh, no!" + rescue ex + with_backtrace(ex.backtrace) do |backtrace| + backtrace.frames.should_not be_empty + end + end + end + end +end diff --git a/spec/backtracer/backtrace_spec.cr b/spec/backtracer/backtrace_spec.cr index 36bc340..c8d58f2 100644 --- a/spec/backtracer/backtrace_spec.cr +++ b/spec/backtracer/backtrace_spec.cr @@ -1,24 +1,31 @@ require "../spec_helper" describe Backtracer::Backtrace do - backtrace = Backtracer.parse(caller) - it "#frames" do - backtrace.frames.should be_a(Array(Backtracer::Backtrace::Frame)) + with_backtrace(caller) do |backtrace| + backtrace.frames.should be_a(Array(Backtracer::Backtrace::Frame)) + backtrace.frames.should_not be_empty + end end it "#inspect" do - backtrace.inspect.should match(/#$/) + with_backtrace(caller) do |backtrace| + backtrace.inspect.should match(/#$/) + end end {% unless flag?(:release) || !flag?(:debug) %} it "#to_s" do - backtrace.to_s.should match(/backtrace_spec.cr:4/) + with_backtrace(caller) do |backtrace| + backtrace.to_s.should match(/backtrace_spec.cr/) + end end {% end %} it "#==" do - backtrace2 = Backtracer::Backtrace.new(backtrace.frames) - backtrace2.should eq(backtrace) + with_backtrace(caller) do |backtrace| + backtrace2 = Backtracer::Backtrace.new(backtrace.frames) + backtrace2.should eq(backtrace) + end end end diff --git a/spec/backtracer/configuration_spec.cr b/spec/backtracer/configuration_spec.cr index 76284c6..c192860 100644 --- a/spec/backtracer/configuration_spec.cr +++ b/spec/backtracer/configuration_spec.cr @@ -1,9 +1,5 @@ require "../spec_helper" -private def with_configuration - yield Backtracer::Configuration.new -end - describe Backtracer::Configuration do it "should set #src_path to current dir from default" do with_configuration do |configuration| diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index e0c7f38..ed152bb 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,37 @@ require "spec" require "../src/backtracer" + +def with_configuration(shared = true, &) + yield shared ? Backtracer.configuration : Backtracer::Configuration.new +end + +def with_backtrace(backtrace, **options, &) + yield Backtracer::Backtrace::Parser.parse(backtrace, **options) +end + +def with_frame(method, path = nil, lineno = nil, column = nil, **options, &) + line = String.build do |io| + if path + io << path + io << ':' << lineno if lineno + io << ':' << column if column + io << " in '" << method << '\'' + else + io << method + end + end + yield Backtracer::Backtrace::Frame::Parser.parse(line, **options) +end + +def with_foo_frame( + method = "foo_bar?", + path = "#{__DIR__}/foo.cr", + lineno = 1, + column = 7, + **options, + & +) + with_frame(method, path, lineno, column, **options) do |frame| + yield frame + end +end diff --git a/src/backtracer.cr b/src/backtracer.cr index e11a616..c507a2d 100644 --- a/src/backtracer.cr +++ b/src/backtracer.cr @@ -1,6 +1,10 @@ module Backtracer class_getter(configuration) { Configuration.new } + def self.configure(&) : Nil + yield configuration + end + def self.parse(backtrace : Array(String) | String, **options) : Backtrace Backtrace::Parser.parse(backtrace, **options) end diff --git a/src/backtracer/backtrace/frame.cr b/src/backtracer/backtrace/frame.cr index c7fd0fd..5826422 100644 --- a/src/backtracer/backtrace/frame.cr +++ b/src/backtracer/backtrace/frame.cr @@ -1,6 +1,8 @@ module Backtracer # An object representation of a stack frame. struct Backtrace::Frame + @context_cache = {} of Int32 => Context + # The method of this frame (such as `User.find`). getter method : String @@ -21,7 +23,7 @@ module Backtracer def_equals_and_hash @method, @path, @lineno, @column - # Reconstructs the frame in a readable fashion + # Reconstructs the frame in a readable fashion. def to_s(io : IO) : Nil io << '`' << @method << '`' if @path @@ -37,11 +39,24 @@ module Backtracer io << ')' end + # Returns `true` if `path` of this frame is within + # the `configuration.src_path`, `false` otherwise. + # + # See `Configuration#src_path` def under_src_path? : Bool return false unless src_path = configuration.src_path !!path.try(&.starts_with?(src_path)) end + # Returns: + # + # - `path` as is, unless it's absolute - i.e. starts with `/` + # - `path` relative to `configuration.src_path` when `under_src_path?` is `true` + # - `nil` otherwise + # + # NOTE: returned path is not required to be `under_src_path?` - see point no. 1 + # + # See `Configuration#src_path` def relative_path : String? return unless path = @path return path unless path.starts_with?('/') @@ -51,6 +66,13 @@ module Backtracer end end + # Returns: + # + # - `path` as is, if it's absolute - i.e. starts with `/` + # - `path` appended to `configuration.src_path` + # - `nil` otherwise + # + # See `Configuration#src_path` def absolute_path : String? return unless path = @path return path if path.starts_with?('/') @@ -59,51 +81,67 @@ module Backtracer end end + # Returns name of the shard from which this frame originated. + # + # See `Configuration#modules_path_pattern` def shard_name : String? relative_path .try(&.match(configuration.modules_path_pattern)) .try(&.["name"]) end + # Returns `true` if this frame originated from the app source code, + # `false` otherwise. + # + # See `Configuration#app_dirs_pattern` def in_app? : Bool - !!(path.try(&.matches?(configuration.in_app_pattern))) + !!(relative_path.try(&.matches?(configuration.app_dirs_pattern))) end - def context(context_lines : Int32? = nil) : {Array(String), String, Array(String)}? + # Returns `Context` record consisting of 3 elements - an array of context lines + # before the `lineno`, line at `lineno`, and an array of context lines + # after the `lineno`. In case of failure it returns `nil`. + # + # Amount of returned context lines is taken from the *context_lines* + # argument if given, or `configuration.context_lines` otherwise. + # + # NOTE: amount of returned context lines might be lower than given + # in cases where `lineno` is near the start or the end of the file. + # + # See `Configuration#context_lines` + def context(context_lines : Int32? = nil) : Context? context_lines ||= configuration.context_lines - return unless context_lines && (context_lines > 0) + + cached = @context_cache[context_lines]? + return cached if cached + return unless (lineno = @lineno) && (lineno > 0) return unless (path = @path) && File.readable?(path) - lines = File.read_lines(path) - lineidx = lineno - 1 + context_line = nil + pre_context, post_context = %w[], %w[] - if context_line = lines[lineidx]? - pre_context = lines[Math.max(0, lineidx - context_lines), context_lines] - post_context = lines[Math.min(lines.size, lineidx + 1), context_lines] - {pre_context, context_line, post_context} + i = 0 + File.each_line(path) do |line| + case i += 1 + when lineno - context_lines...lineno + pre_context << line + when lineno + context_line = line + when lineno + 1..lineno + context_lines + post_context << line + end end - end - def context_hash(context_lines : Int32? = nil) : Hash(Int32, String)? - return unless context = self.context(context_lines) - return unless lineno = @lineno - - pre_context, context_line, post_context = context - - ({} of Int32 => String).tap do |hash| - pre_context.each_with_index do |code, index| - line = (lineno - pre_context.size) + index - hash[line] = code - end - - hash[lineno] = context_line - - post_context.each_with_index do |code, index| - line = lineno + (index + 1) - hash[line] = code - end + if context_line + @context_cache[context_lines] = + Context.new( + lineno: lineno, + pre: pre_context, + line: context_line, + post: post_context, + ) end end end diff --git a/src/backtracer/backtrace/frame/context.cr b/src/backtracer/backtrace/frame/context.cr new file mode 100644 index 0000000..d5d22aa --- /dev/null +++ b/src/backtracer/backtrace/frame/context.cr @@ -0,0 +1,46 @@ +module Backtracer + struct Backtrace::Frame::Context + # The line number this `Context` refers to. + getter lineno : Int32 + + # An array of lines before `lineno`. + getter pre : Array(String) + + # The line at `lineno`. + getter line : String + + # An array of lines after `lineno`. + getter post : Array(String) + + def initialize(@lineno, @pre, @line, @post) + end + + # Returns an array composed of context lines from `pre`, + # `line` and `post`. + def to_a : Array(String) + ([] of String).tap do |ary| + ary.concat(pre) + ary << line + ary.concat(post) + end + end + + # Returns hash with context lines, where line numbers are + # the keys and the lines itself are the values. + def to_h : Hash(Int32, String) + ({} of Int32 => String).tap do |hash| + base_index = lineno - pre.size + pre.each_with_index do |code, index| + hash[base_index + index] = code + end + + hash[lineno] = line + + base_index = lineno + 1 + post.each_with_index do |code, index| + hash[base_index + index] = code + end + end + end + end +end diff --git a/src/backtracer/backtrace/frame/parser.cr b/src/backtracer/backtrace/frame/parser.cr index f771639..37f77ff 100644 --- a/src/backtracer/backtrace/frame/parser.cr +++ b/src/backtracer/backtrace/frame/parser.cr @@ -2,10 +2,13 @@ module Backtracer module Backtrace::Frame::Parser extend self - # Parses a single line of a given backtrace, where *unparsed_line* is + # Parses a single line of a given backtrace, where *line* is # the raw line from `caller` or some backtrace. # - # Returns the parsed backtrace frame on success or `nil` otherwise. + # Accepts options: + # - `configuration`: `Configuration` object - uses `Backtracer.configuration` if `nil` + # + # Returns parsed `Backtrace::Frame` on success or `nil` otherwise. def parse?(line : String, **options) : Backtrace::Frame? return unless Configuration::LINE_PATTERNS.any? &.match(line) @@ -20,6 +23,7 @@ module Backtracer configuration: options[:configuration]? end + # Same as `parse?` but raises `ArgumentError` on error. def parse(line : String, **options) : Backtrace::Frame parse?(line, **options) || raise ArgumentError.new("Error parsing line: #{line.inspect}") diff --git a/src/backtracer/backtrace/parser.cr b/src/backtracer/backtrace/parser.cr index ade656e..30d6744 100644 --- a/src/backtracer/backtrace/parser.cr +++ b/src/backtracer/backtrace/parser.cr @@ -2,6 +2,14 @@ module Backtracer module Backtrace::Parser extend self + # Parses *backtrace* (possibly obtained as a return value + # from `caller` or `Exception#backtrace` methods). + # + # Accepts options: + # - `configuration`: `Configuration` object - uses `Backtracer.configuration` if `nil` + # - `filters`: additional line filters - see `Configuration#line_filters` + # + # Returns parsed `Backtrace` object or raises `ArgumentError` otherwise. def parse(backtrace : Array(String), **options) : Backtrace configuration = options[:configuration]? || Backtracer.configuration diff --git a/src/backtracer/configuration.cr b/src/backtracer/configuration.cr index c944188..1b91465 100644 --- a/src/backtracer/configuration.cr +++ b/src/backtracer/configuration.cr @@ -53,24 +53,32 @@ module Backtracer /^(?.+?)$/, } - # Used in `#in_app_pattern`. + # Path considered as "root" of your project. + # + # See `Frame#under_src_path?` property src_path : String? = {{ Process::INITIAL_PWD }} # Directories to be recognized as part of your app. e.g. if you # have an `engines` dir at the root of your project, you may want - # to set this to something like `/(src|engines)/` - property app_dirs_pattern = /src/ - - # `Regex` pattern matched against `Backtrace::Frame#file`. - property in_app_pattern : Regex { /^(#{src_path}\/)?(#{app_dirs_pattern})/ } + # to set this to something like `/^(src|engines)\//` + # + # See `Frame#in_app?` + property app_dirs_pattern = /^src\// # Path pattern matching directories to be recognized as your app modules. # Defaults to standard Shards setup (`lib/shard-name/...`). + # + # See `Frame#shard_name` property modules_path_pattern = /^lib\/(?[^\/]+)\/(?:.+)/ - # Number of lines of code context to capture, or `nil` for none. + # Number of lines of code context to return by default, or `nil` for none. + # + # See `Frame#context` property context_lines : Int32? = 5 + # Array of procs used for filtering backtrace lines before parsing. + # Each filter is expected to return a string, which is then passed + # onto the next filter, or ignored althoghether if `nil` is returned. getter(line_filters) { [ ->(line : String) { line unless line.matches?(IGNORED_LINES_PATTERN) },