Skip to content

Instantly share code, notes, and snippets.

@fecundf
Created June 1, 2016 17:03
Show Gist options
  • Save fecundf/be3e37c905e3c309f22603e55fce6ed9 to your computer and use it in GitHub Desktop.
Save fecundf/be3e37c905e3c309f22603e55fce6ed9 to your computer and use it in GitHub Desktop.
Unix + windows version of Nigel Hamilton's jmp browser
#!/usr/bin/env perl6
#--------------------------------------------------------------------------------
# Nigel Hamilton (2016) - Artistic Licence 2.0
#
# jmp - Perl6 powered tools for quickly jmping to the next thing in your workflow
#
# - please add subcommands for your own workflow. For example, you could jmp to:
#
# - log files
# - directory listings
# - command-line history
# - test output
# - todo lists
# - error messages
# - documentation
# - web pages
#
# - To get started:
#
# shell> jmp help # display help
# shell> jmp find something in your codebase # search your codebase
# shell> jmp edit Filename.pm 12 # open your editor
# shell> jmp config # edit your ~/.jmp config file
#
#--------------------------------------------------------------------------------
#| simple Template engine
class JMP::Template {
# parameters are passed by reference unless "is copy" makes a local copy
# you can optionally add a type to a parameter Str
method render (Str $string is copy, %params) {
# globally substitute tokens found in the string for values from the params hash
$string ~~ s:g/'[-' (<[a..z-]>+) '-]'/{ %params{$0} }/;
return $string;
}
}
#| Operating system abstraction, a singleton
class JMP::System {
my $only;
method instance { $only }
method new { !!! }
# Default implementation
method width { qx[tput cols] }
method height { qx[tput lines] }
method clear { shell 'clear' }
method home { %*ENV{'HOME'} }
method get-key {
ENTER shell "stty raw -echo";
LEAVE shell "stty sane";
return $*IN.getc;
}
# Private subclass for alternate implementation
class Win {
method width { qx[powershell.exe -noprofile -command $host.ui.rawui.WindowSize.Width] }
method height { qx[powershell.exe -noprofile -command $host.ui.rawui.WindowSize.Height] }
method clear { shell 'cls' }
method home { %*ENV{'HOMEPATH'} }
method get-key {
qx[powershell.exe -noprofile -command $host.UI.RawUI.ReadKey('NoEcho,IncludeKeyUp').Character].substr(0,1)
}
}
$only = ($*SPEC ~~ IO::Spec::Win32 ??
JMP::System::Win !! JMP::System).bless;
}
#| simple config file handling
class JMP::Config {
has Str $.filename;
has %!fields;
# get a simple value for a config field
multi method get ($key) {
die "Key $key does not exist in config file. Please add a value for $key to $.filename"
unless %!fields{$key}:exists;
return %!fields{$key};
}
# get a config template value and render params into it
multi method get ($key, %params) {
my $value = $.get($key);
return JMP::Template.new.render($value, %params);
}
# extra key value pairs from the config
# a helper sub called at object BUILD time
sub parse-config($config-file) {
my %fields;
for $config-file.IO.lines {
# skip comments
next if .starts-with('#');
# config_keys.CAN.look-like.this
# ^ $ - mark the start an end of line
next unless /^ (.*?) '=' (.*?) $/;
# save the key and value in config
# $0 and $1 refer to the match objects in the regex
# ~$0 stringifies the match object
# trim removes leading and trailing whitespace
%fields{~$0.trim} = ~$1.trim;
}
return %fields;
}
# set up the user with a default config
# this is a helper sub called at object BUILD time
sub populate-default-config ($config-file) {
# populate the default config file
# HEREdocs can be indented! The CONFIG end marker provides the
# indentation level
# .IO provides simple methods for slurping/spurting files to disk
$config-file.IO.spurt(q:to"CONFIG");
# uncomment your favourite editor
# editor.command.template = nano +[-line-number-]
editor.command.template = C:\Users\yhluc00\emacs\bin\emacsclient.exe +[-line-number-] "[-filename-]"
# atom has Perl 6 syntax highlighting and other plugins for Perl 6
# editor.command.template = atom :[-line-number-] &
# editor.command.template = subl :[-line-number-] &
# editor.command.template = emacs +[-line-number-]
# editor.command.template = vim +[-line-number-]
# uncomment your preferred code searching tool
# classic recursive grep
find.command.template = grep -rHn '[-search-terms-]'
# ag - the silver searcher for fast generic file jmping
# find.command.template = ag --nogroup '[-search-terms-]'
# git grep - for jmping around git repos
# find.command.template = git grep --full-name --untracked --text --line-number -e '[-search-terms-]'
# App::Ack - Perl-powered improvement to grep
# find.command.template = ack --nogroup '[-search-terms-]'
CONFIG
}
# called at object construction time
# Mu - is the top level class and calls to new() start there
# Objects are constructed by calling BUILD from least derived to most derived
# Mu blesses the class into its type
# the BUILD submethod sets the attributes for the instance
submethod BUILD (:$filename) {
# variables-in-perl6-can-be-kebab-case you include apostrophes too, cool-isn't-it ?
# kebab-case makes variables easy to type and read
# conditional assigment uses ?? and !! instead or ? and :
my $config-file = $filename
?? $filename
!! JMP::System.instance.home ~ '/.jmp';
# binding assignment is denoted with :=
# this sets the attribute value for $!filename
$!filename := $config-file;
# .IO is handy for IO operations
# populate the config if the file does not exist
populate-default-config($config-file)
unless $config-file.IO.e;
# parse the config file
# bind the results :=
# to a private hash variable %!
%!fields := parse-config($config-file);
}
}
# declare a role - the implementation and interface is shared with classes that "do" the role
role JMP::Screen::Action {
# all actions will need a key and takes up a height in lines on the screen
has $.key = '';
has $.line-height = 1;
# assign a key depending on where it appears on the screen
method assign-key($key) {
$!key = $key;
}
method does-action($key) returns Bool {
return $key eq $.key;
}
method do-action { ... }
method render { ... }
}
# this class does the JMP::Screen::Action role - the methods and attributes of the role above are merged into it
# the class provides its own implementation of do-action and render
# the implementation of assign-key comes from the role above
class JMP::Screen::Action::EditFileLine does JMP::Screen::Action {
has $.file-path;
has $.context;
has $.line-number;
has $.template = q:to"ACTION";
[[-key-]] ([-line-number-]) [-context-]
ACTION
method render {
my %params = (:$.key, :$.line-number, :$.context);
return JMP::Template.new.render($.template, %params);
}
method do-action {
MAIN('edit', $*CWD ~ '/' ~ $.file-path, +$.line-number);
}
}
class JMP::Screen::Action::EditFile does JMP::Screen::Action {
has $.file-path;
has $.template = q:to"ACTION";
[[-key-]] [-file-path-]
ACTION
method render {
my %params = (:$.file-path, :$!key);
return JMP::Template.new.render($.template, %params);
}
method do-action {
# open file with an absolute path - start at line 1
MAIN('edit', $*CWD ~ '/' ~ $.file-path, 1);
}
}
class JMP::Screen::Page {
has Int $.page-number = 1;
has @.actions;
method get-matching-action($key-pressed) {
# search through all the actions - does one match the key pressed?
# | flatten the element found in the list
return |@!actions.grep({ $_.does-action($key-pressed) });
}
method render($available-lines) {
# iterate through all the actions >> call render on them
# and [~] concatentate them together
my $rendered-actions = [~](@!actions>>.render);
# iterate through all the actions >> call line-height on them
# and [+] sum the line heights
my $lines-used = [+](@!actions>>.line-height);
# if there are more available lines - add padding
my $vertical-padding = "\n" x $available-lines - $lines-used;
$rendered-actions ~= $vertical-padding;
return $rendered-actions;
}
submethod BUILD (:$page-number, :@actions) {
# create a list of all the available keys
# stop at 'W' so we have 'X' to eXit the program
my @keys = 'a' ... 'z', 'A' ... 'W';
for @actions -> $action {
$action.assign-key(@keys.shift);
}
# bind into the attributes of the instance
$!page-number := $page-number;
@!actions := @actions;
}
}
class JMP::Screen {
has $.title = 'jmp';
# qqx - is similar to backticks - execute this shell command
has $!rule = '_' x JMP::System.instance.width;
has $!content-line-height;
has $!total-pages;
has %!pages;
# HEREdocs can now be indented!
has $!template = q:to"SCREEN";
[-title-] [-page-number-] of [-total-pages-]
[-rule-]
[-contents-]
[-rule-]
SCREEN
# handle going out of bounds with multi methods and type constraints
multi method display-page ($page-number where * < 1) { self.display-page(1); }
multi method display-page ($page-number where * > $!total-pages) { self.display-page($!total-pages); }
multi method display-page ($page-number) {
# grab the actions on this page
my $page = %!pages{$page-number};
my $contents = $page.render($!content-line-height);
JMP::System.instance.clear;
say JMP::Template.new.render($!template, { :$!title, :$page-number, :$!total-pages, :$!rule, :$contents });
$.prompt($page);
}
# display the prompt and respond
method prompt ($page) {
print '[<] Previous e[X]it [>] Next';
loop {
my $key-pressed = JMP::System.instance.get-key();
given $key-pressed {
when /<[a..zA..W]>/ {
my $action = $page.get-matching-action($key-pressed);
$action.do-action;
}
when '<' { $.display-page($page.page-number - 1) }
when '>' { $.display-page($page.page-number + 1) }
when 'X' { say ''; exit; }
}
}
}
sub paginate-actions($content-line-height, @actions) {
my @action-keys = 'a' ... 'z', 'A' ... 'W'; # reserve X for exit
my $actions-per-page = ($content-line-height > @action-keys.elems)
?? @action-keys.elems
!! $content-line-height;
my $page-number = 1;
my %pages;
while @actions {
# splice off a page's worth of actions
my @page-actions = @actions.splice(0, $actions-per-page);
%pages{$page-number} = JMP::Screen::Page.new(:$page-number, actions => @page-actions, :@action-keys);
$page-number++;
}
return %pages;
}
submethod BUILD (:$!title, :@actions) {
# the number of available lines - 6 for the header and footer
my $content-line-height = JMP::System.instance.height - (4 + 4);
$!content-line-height = $content-line-height;
# place the action on their pages
%!pages := paginate-actions($content-line-height, @actions);
$!total-pages := [max] keys %!pages;
}
}
my $config = JMP::Config.new;
#| open the .jmp config file in your editor
multi sub MAIN ('config') {
MAIN('edit', $config.filename, 0);
}
# Perl 6 is optionally typed - providing types is useful in multi method dispatch
#| edit a text file at a given line number
multi sub MAIN ('edit', $filename, Int $line-number = 0) {
# render the editor command from the config
my $editor-command = $config.get('editor.command.template', { :$filename, :$line-number });
# launch the editor via the shell
shell($editor-command);
}
#| edit a text file starting on a matching line
multi sub MAIN ('edit', $filename, *@search-terms) {
my $matching-line-number = 0;
# find the first line in the file that matches the pattern
for $filename.IO.lines {
++$matching-line-number;
if / @search-terms / {
MAIN('edit', $filename, $matching-line-number);
exit;
}
}
# nothing match, open at the first line
MAIN('edit', $filename, 0);
}
#| find search terms in the codebase
multi sub MAIN ('find', *@search-terms) {
my $search-terms = @search-terms.join(' ');
# render the find command from the config
my $find-command = $config.get('find.command.template', { :$search-terms });
my $search-results = qqx{$find-command};
# finish if nothing found
return unless $search-results;
my @actions;
my $previous-file-path;
for $search-results.lines -> $line {
my ($file-path, $line-number, $context) = $line.split(':', 3);
if ($previous-file-path ne $file-path) {
@actions.push(JMP::Screen::Action::EditFile.new(:$file-path, line-number => 0));
$previous-file-path = $file-path;
}
@actions.push(JMP::Screen::Action::EditFileLine.new(:$file-path, :$line-number, :$context));
}
my $screen = JMP::Screen.new(title => 'jmp find ' ~ $search-terms, :@actions);
$screen.display-page(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment