commit ece4858a2971b16b90ef263a83d85293d9fd4e40 Author: Sijawusz Pur Rahnama Date: Sun Dec 27 17:12:47 2020 +0100 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbd4a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf + +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7521934 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +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/LICENSE b/LICENSE new file mode 100644 index 0000000..4507a0c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Sijawusz Pur Rahnama + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fc1586 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# backtracer.cr + +TODO: Write a description here + +## Installation + +1. Add the dependency to your `shard.yml`: + + ```yaml + dependencies: + backtracer: + github: Sija/backtracer.cr + ``` + +2. Run `shards install` + +## Usage + +```crystal +require "backtracer" +``` + +TODO: Write usage instructions here + +## Development + +TODO: Write development instructions here + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [@Sija](https://github.com/Sija) Sijawusz Pur Rahnama - creator and maintainer diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..3abe78a --- /dev/null +++ b/shard.yml @@ -0,0 +1,14 @@ +name: backtracer +version: 0.1.0 + +authors: + - Sijawusz Pur Rahnama + +development_dependencies: + ameba: + github: crystal-ameba/ameba + version: ~> 0.13.0 + +crystal: ">= 0.35.0" + +license: MIT diff --git a/spec/backtracer/backtrace/line_spec.cr b/spec/backtracer/backtrace/line_spec.cr new file mode 100644 index 0000000..d8e0363 --- /dev/null +++ b/spec/backtracer/backtrace/line_spec.cr @@ -0,0 +1,199 @@ +require "../../spec_helper" + +private def with_line( + path = "#{__DIR__}/foo.cr", + method = "foo_bar?", + line = 1, + column = 7 +) + line = "#{path}#{line && ":#{line}"}#{column && ":#{column}"} in '#{method}'" + yield Backtracer::Backtrace::Line::Parser.parse(line) +end + +describe Backtracer::Backtrace::Line do + describe ".parse" do + it "fails to parse an empty string" do + expect_raises(ArgumentError) { Backtracer::Backtrace::Line::Parser.parse("") } + end + + context "when --no-debug flag is set" do + it "parses line with any value as method" do + backtrace_line = "__crystal_main" + line = Backtracer::Backtrace::Line::Parser.parse(backtrace_line) + + line.number.should be_nil + line.column.should be_nil + line.method.should eq(backtrace_line) + line.file.should be_nil + line.relative_path.should be_nil + line.under_src_path?.should be_false + line.shard_name.should be_nil + line.in_app?.should be_false + 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" + line = Backtracer::Backtrace::Line::Parser.parse(backtrace_line) + + line.number.should eq(72) + line.column.should be_nil + line.method.should eq("~proc2Proc(Fiber, (IO::FileDescriptor | Nil))") + line.file.should eq("/usr/local/Cellar/crystal/0.27.2/src/fiber.cr") + line.relative_path.should be_nil + line.under_src_path?.should be_false + line.shard_name.should be_nil + line.in_app?.should be_false + end + + it "parses relative path inside of lib/ dir" do + backtrace_line = "~procProc(HTTP::Server::Context, String)@lib/kemal/src/kemal/route.cr:11" + line = Backtracer::Backtrace::Line::Parser.parse(backtrace_line) + + line.number.should eq(11) + line.column.should be_nil + line.method.should eq("~procProc(HTTP::Server::Context, String)") + line.file.should eq("lib/kemal/src/kemal/route.cr") + line.relative_path.should eq("lib/kemal/src/kemal/route.cr") + line.under_src_path?.should be_false + line.shard_name.should eq("kemal") + line.in_app?.should be_false + end + end + + it "parses absolute path outside of configuration.src_path" do + path = "/some/absolute/path/to/foo.cr" + with_line(path: path) do |line| + line.number.should eq(1) + line.column.should eq(7) + line.method.should eq("foo_bar?") + line.file.should eq(path) + line.relative_path.should be_nil + line.under_src_path?.should be_false + line.shard_name.should be_nil + line.in_app?.should be_false + end + end + + context "with in_app? = false" do + it "parses absolute path outside of src/ dir" do + with_line do |line| + line.number.should eq(1) + line.column.should eq(7) + line.method.should eq("foo_bar?") + line.file.should eq("#{__DIR__}/foo.cr") + line.relative_path.should eq("spec/backtracer/backtrace/foo.cr") + line.under_src_path?.should be_true + line.shard_name.should be_nil + line.in_app?.should be_false + end + end + + it "parses relative path outside of src/ dir" do + path = "some/relative/path/to/foo.cr" + with_line(path: path) do |line| + line.number.should eq(1) + line.column.should eq(7) + line.method.should eq("foo_bar?") + line.file.should eq(path) + line.relative_path.should eq(path) + line.under_src_path?.should be_false + line.shard_name.should be_nil + line.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_line(path: path) do |line| + line.number.should eq(1) + line.column.should eq(7) + line.method.should eq("foo_bar?") + line.file.should eq(path) + line.relative_path.should eq("src/foo.cr") + line.under_src_path?.should be_true + line.shard_name.should be_nil + line.in_app?.should be_true + end + end + + it "parses relative path inside of src/ dir" do + path = "src/foo.cr" + with_line(path: path) do |line| + line.number.should eq(1) + line.column.should eq(7) + line.method.should eq("foo_bar?") + line.file.should eq(path) + line.relative_path.should eq(path) + line.under_src_path?.should be_false + line.shard_name.should be_nil + line.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_line(path: path) do |line| + line.number.should eq(1) + line.column.should eq(7) + line.method.should eq("foo_bar?") + line.file.should eq(path) + line.relative_path.should eq("lib/bar/src/bar.cr") + line.under_src_path?.should be_true + line.shard_name.should eq "bar" + line.in_app?.should be_false + end + end + + it "parses relative path inside of lib/ dir" do + path = "lib/bar/src/bar.cr" + with_line(path: path) do |line| + line.number.should eq(1) + line.column.should eq(7) + line.method.should eq("foo_bar?") + line.file.should eq(path) + line.relative_path.should eq(path) + line.under_src_path?.should be_false + line.shard_name.should eq "bar" + line.in_app?.should be_false + end + end + + it "uses only folders for shard names" do + with_line(path: "lib/bar.cr") do |line| + line.shard_name.should be_nil + end + end + end + end + + it "#inspect" do + with_line do |line| + line.inspect.should match(/Backtrace::Line(.*)$/) + end + end + + it "#to_s" do + with_line do |line| + line.to_s.should eq "`foo_bar?` at #{__DIR__}/foo.cr:1:7" + end + end + + it "#==" do + with_line do |line| + with_line do |line2| + line.should eq(line2) + end + with_line(method: "other_method") do |line2| + line.should_not eq(line2) + end + end + end +end diff --git a/spec/backtracer/backtrace_spec.cr b/spec/backtracer/backtrace_spec.cr new file mode 100644 index 0000000..eb2a1fe --- /dev/null +++ b/spec/backtracer/backtrace_spec.cr @@ -0,0 +1,24 @@ +require "../spec_helper" + +describe Backtracer::Backtrace do + backtrace = Backtracer.parse(caller) + + it "#lines" do + backtrace.lines.should be_a(Array(Backtracer::Backtrace::Line)) + end + + it "#inspect" do + backtrace.inspect.should match(/#$/) + end + + {% unless flag?(:release) || !flag?(:debug) %} + it "#to_s" do + backtrace.to_s.should match(/backtrace_spec.cr:4/) + end + {% end %} + + it "#==" do + backtrace2 = Backtracer::Backtrace.new(backtrace.lines) + backtrace2.should eq(backtrace) + end +end diff --git a/spec/backtracer/configuration_spec.cr b/spec/backtracer/configuration_spec.cr new file mode 100644 index 0000000..76284c6 --- /dev/null +++ b/spec/backtracer/configuration_spec.cr @@ -0,0 +1,13 @@ +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| + configuration.src_path.should eq(Dir.current) + end + end +end diff --git a/spec/backtracer_spec.cr b/spec/backtracer_spec.cr new file mode 100644 index 0000000..80bfd27 --- /dev/null +++ b/spec/backtracer_spec.cr @@ -0,0 +1,4 @@ +require "./spec_helper" + +describe Backtracer do +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..e0c7f38 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/backtracer" diff --git a/src/backtracer.cr b/src/backtracer.cr new file mode 100644 index 0000000..e11a616 --- /dev/null +++ b/src/backtracer.cr @@ -0,0 +1,9 @@ +module Backtracer + class_getter(configuration) { Configuration.new } + + def self.parse(backtrace : Array(String) | String, **options) : Backtrace + Backtrace::Parser.parse(backtrace, **options) + end +end + +require "./backtracer/**" diff --git a/src/backtracer/backtrace.cr b/src/backtracer/backtrace.cr new file mode 100644 index 0000000..b9c5008 --- /dev/null +++ b/src/backtracer/backtrace.cr @@ -0,0 +1,22 @@ +module Backtracer + class Backtrace + getter lines : Array(Line) + + def initialize(@lines = [] of Line) + end + + def_equals_and_hash @lines + + def to_s(io : IO) : Nil + @lines.join(io, '\n') + end + + def inspect(io : IO) : Nil + io << "#' + end + end +end + +require "./backtrace/*" diff --git a/src/backtracer/backtrace/line.cr b/src/backtracer/backtrace/line.cr new file mode 100644 index 0000000..27f058f --- /dev/null +++ b/src/backtracer/backtrace/line.cr @@ -0,0 +1,81 @@ +module Backtracer + # Handles backtrace parsing line by line + struct Backtrace::Line + # The method of the line (such as `User.find`). + getter method : String + + # The file portion of the line (such as `app/models/user.cr`). + getter file : String? + + # The line number portion of the line. + getter number : Int32? + + # The column number portion of the line. + getter column : Int32? + + protected getter(configuration) { Backtracer.configuration } + + def initialize(@method, @file = nil, @number = nil, @column = nil, *, + @configuration = nil) + end + + def_equals_and_hash @method, @file, @number, @column + + # Reconstructs the line in a readable fashion + def to_s(io : IO) : Nil + io << '`' << @method << '`' + if @file + io << " at " << @file + io << ':' << @number if @number + io << ':' << @column if @column + end + end + + def inspect(io : IO) : Nil + io << "Backtrace::Line(" + to_s(io) + io << ')' + end + + def under_src_path? : Bool + return false unless src_path = configuration.src_path + !!file.try(&.starts_with?(src_path)) + end + + def relative_path : String? + return unless path = file + return path unless path.starts_with?('/') + return unless under_src_path? + if prefix = configuration.src_path + path[prefix.chomp(File::SEPARATOR).size + 1..] + end + end + + def shard_name : String? + relative_path + .try(&.match(configuration.modules_path_pattern)) + .try(&.["name"]) + end + + def in_app? : Bool + !!(file.try(&.matches?(configuration.in_app_pattern))) + end + + def context(context_lines : Int32? = nil) : {Array(String), String, Array(String)}? + context_lines ||= configuration.context_lines + + return unless context_lines && (context_lines > 0) + return unless (lineno = @number) && (lineno > 0) + return unless (filename = @file) && File.readable?(filename) + + lines = File.read_lines(filename) + lineidx = lineno - 1 + + 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} + end + end + end +end diff --git a/src/backtracer/backtrace/line/parser.cr b/src/backtracer/backtrace/line/parser.cr new file mode 100644 index 0000000..c58ee62 --- /dev/null +++ b/src/backtracer/backtrace/line/parser.cr @@ -0,0 +1,28 @@ +module Backtracer + module Backtrace::Line::Parser + extend self + + # Parses a single line of a given backtrace, where *unparsed_line* is + # the raw line from `caller` or some backtrace. + # + # Returns the parsed backtrace line on success or `nil` otherwise. + def parse?(line : String, **options) : Backtrace::Line? + return unless Configuration::LINE_PATTERNS.any? &.match(line) + + method = $~["method"]?.presence + file = $~["file"]?.presence + number = $~["line"]?.try(&.to_i?) + column = $~["col"]?.try(&.to_i?) + + return unless method + + Backtrace::Line.new method, file, number, column, + configuration: options[:configuration]? + end + + def parse(line : String, **options) : Backtrace::Line + parse?(line, **options) || + raise ArgumentError.new("Error parsing line: #{line.inspect}") + end + end +end diff --git a/src/backtracer/backtrace/parser.cr b/src/backtracer/backtrace/parser.cr new file mode 100644 index 0000000..67de911 --- /dev/null +++ b/src/backtracer/backtrace/parser.cr @@ -0,0 +1,27 @@ +module Backtracer + module Backtrace::Parser + extend self + + def parse(backtrace : Array(String), **options) : Backtrace + configuration = options[:configuration]? || Backtracer.configuration + + filters = configuration.line_filters + if extra_filters = options[:filters]? + filters += extra_filters + end + + lines = backtrace.compact_map do |line| + line = filters.reduce(line) do |nested_line, filter| + filter.call(nested_line) || break + end + Line::Parser.parse(line, configuration: configuration) if line + end + + Backtrace.new(lines) + end + + def parse(backtrace : String, **options) : Backtrace + parse(backtrace.lines, **options) + end + end +end diff --git a/src/backtracer/configuration.cr b/src/backtracer/configuration.cr new file mode 100644 index 0000000..37f2bf6 --- /dev/null +++ b/src/backtracer/configuration.cr @@ -0,0 +1,80 @@ +module Backtracer + class Configuration + private IGNORED_LINES_PATTERN = + /_sigtramp|__crystal_(sigfault_handler|raise)|CallStack|caller:|raise<(.+?)>:NoReturn/ + + private ADDR_FORMAT = + /(?0x[a-f0-9]+)/i + + LINE_PATTERNS = { + # Crystal method + # + # Examples: + # + # - `lib/foo/src/foo/bar.cr:50:7 in '*Foo::Bar#_baz:Foo::Bam'` + # - `lib/foo/src/foo/bar.cr:29:9 in '*Foo::Bar::bar_by_id:Foo::Bam'` + # - `/usr/local/Cellar/crystal-lang/0.24.1/src/fiber.cr:114:3 in '*Fiber#run:(IO::FileDescriptor | Nil)'` + /^(?[^:]+)(?:\:(?\d+)(?:\:(?\d+))?)? in '\*?(?.*?)'(?: at #{ADDR_FORMAT})?$/, + + # Crystal proc + # + # Examples: + # + # - `~procProc(Nil)@/usr/local/Cellar/crystal-lang/0.24.1/src/http/server.cr:148 at 0x102cee376` + # - `~procProc(HTTP::Server::Context, String)@lib/kemal/src/kemal/route.cr:11 at 0x102ce57db` + # - `~procProc(HTTP::Server::Context, (File::PReader | HTTP::ChunkedContent | HTTP::Server::Response | HTTP::Server::Response::Output | HTTP::UnknownLengthContent | HTTP::WebSocket::Protocol::StreamIO | IO::ARGF | IO::Delimited | IO::FileDescriptor | IO::Hexdump | IO::Memory | IO::MultiWriter | IO::Sized | Int32 | OpenSSL::SSL::Socket | String::Builder | Zip::ChecksumReader | Zip::ChecksumWriter | Zlib::Deflate | Zlib::Inflate | Nil))@src/foo/bar/baz.cr:420` + /^(?~[^@]+)@(?[^:]+)(?:\:(?\d+))(?: at #{ADDR_FORMAT})?$/, + + # Crystal crash + # + # Examples: + # + # - `[0x1057a9fab] *CallStack::print_backtrace:Int32 +107` + # - `[0x105798aac] __crystal_sigfault_handler +60` + # - `[0x7fff9ca0652a] _sigtramp +26` + # - `[0x105cb35a1] GC_realloc +50` + # - `[0x1057870bb] __crystal_realloc +11` + # - `[0x1057d3ecc] *Pointer(UInt8)@Pointer(T)#realloc:Pointer(UInt8) +28` + # - `[0x105965e03] *Foo::Bar#bar!:Nil +195` + # - `[0x10579f5c1] *naughty_bar:Nil +17` + # - `[0x10579f5a9] *naughty_foo:Nil +9` + # - `[0x10578706c] __crystal_main +2940` + # - `[0x105798128] main +40` + /^\[#{ADDR_FORMAT}\] \*?(?.*?) \+\d+(?: \((?\d+) times\))?$/, + + # Crystal method (--no-debug) + # + # Examples: + # + # - `HTTP::Server#handle_client:Nil` + # - `HTTP::Server::RequestProcessor#process:Nil` + # - `Kemal::WebSocketHandler@HTTP::Handler#call_next:(Bool | HTTP::Server::Context | IO+ | Int32 | Nil)` + # - `__crystal_main` + /^(?.+?)$/, + } + + # Used in `#in_app_pattern`. + 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::Line#file`. + property in_app_pattern : Regex { /^(#{src_path}\/)?(#{app_dirs_pattern})/ } + + # Path pattern matching directories to be recognized as your app modules. + # Defaults to standard Shards setup (`lib/shard-name/...`). + property modules_path_pattern = /^lib\/(?[^\/]+)\/(?:.+)/ + + # Number of lines of code context to capture, or `nil` for none. + property context_lines : Int32? = 5 + + getter(line_filters) { + [ + ->(line : String) { line unless line.matches?(IGNORED_LINES_PATTERN) }, + ] of String -> String? + } + end +end diff --git a/src/backtracer/version.cr b/src/backtracer/version.cr new file mode 100644 index 0000000..9888009 --- /dev/null +++ b/src/backtracer/version.cr @@ -0,0 +1,3 @@ +module Backtracer + VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }} +end