Initial commit

This commit is contained in:
Sijawusz Pur Rahnama 2020-12-27 17:12:47 +01:00
commit ece4858a29
18 changed files with 604 additions and 0 deletions

9
.editorconfig Normal file
View file

@ -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

9
.gitignore vendored Normal file
View file

@ -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

20
.travis.yml Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

39
README.md Normal file
View file

@ -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 (<https://github.com/Sija/backtracer.cr/fork>)
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

14
shard.yml Normal file
View file

@ -0,0 +1,14 @@
name: backtracer
version: 0.1.0
authors:
- Sijawusz Pur Rahnama <sija@sija.pl>
development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 0.13.0
crystal: ">= 0.35.0"
license: MIT

View file

@ -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

View file

@ -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(/#<Backtrace: .*>$/)
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

View file

@ -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

4
spec/backtracer_spec.cr Normal file
View file

@ -0,0 +1,4 @@
require "./spec_helper"
describe Backtracer do
end

2
spec/spec_helper.cr Normal file
View file

@ -0,0 +1,2 @@
require "spec"
require "../src/backtracer"

9
src/backtracer.cr Normal file
View file

@ -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/**"

View file

@ -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 << "#<Backtrace: "
@lines.join(io, ", ", &.inspect(io))
io << '>'
end
end
end
require "./backtrace/*"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,80 @@
module Backtracer
class Configuration
private IGNORED_LINES_PATTERN =
/_sigtramp|__crystal_(sigfault_handler|raise)|CallStack|caller:|raise<(.+?)>:NoReturn/
private ADDR_FORMAT =
/(?<addr>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<String>:Foo::Bam'`
# - `/usr/local/Cellar/crystal-lang/0.24.1/src/fiber.cr:114:3 in '*Fiber#run:(IO::FileDescriptor | Nil)'`
/^(?<file>[^:]+)(?:\:(?<line>\d+)(?:\:(?<col>\d+))?)? in '\*?(?<method>.*?)'(?: 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`
/^(?<method>~[^@]+)@(?<file>[^:]+)(?:\:(?<line>\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<Int32>: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}\] \*?(?<method>.*?) \+\d+(?: \((?<times>\d+) times\))?$/,
# Crystal method (--no-debug)
#
# Examples:
#
# - `HTTP::Server#handle_client<IO+>:Nil`
# - `HTTP::Server::RequestProcessor#process<IO+, IO+, IO::FileDescriptor>:Nil`
# - `Kemal::WebSocketHandler@HTTP::Handler#call_next<HTTP::Server::Context>:(Bool | HTTP::Server::Context | IO+ | Int32 | Nil)`
# - `__crystal_main`
/^(?<method>.+?)$/,
}
# 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\/(?<name>[^\/]+)\/(?:.+)/
# 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

View file

@ -0,0 +1,3 @@
module Backtracer
VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }}
end