Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

[GGE] TreeSpider refactor

There's a way to compensate for Perl 6's lack of continuations, by
building closures out of regex parts.  However, I'm not yet ready to
take such a route.  Instead, this refactor causes the crawling through
the tree to be handled explicitly.

The crawling consists of two things.  First, there's the usual tree
traversal.  Commonly, this is carried out using the call stack: calling
things to descend and returning to ascend back up.  Since the result of
parent nodes depends on the result of child nodes, the match results
can even be passed back up via the return mechanism.

Unfortunately, due to the need for backtracking, straightforward tree
traversal isn't enough.  For certain 'backtracking-enabled' nodes which
leave savepoints upon matching, the same nodes must be prepared to
re-activate in the -same- state as they were when the savepoint was
registered.  With 'state' is meant the smallest possible set of values
involved in the regex match such that when the savepoint is activated,
it is -as- -if- the whole history after the registering of the
savepoint hadn't happened.  (This parallels the way saving/restoring
works in most computer games.)

When possible, savepoints are implemented using continuations.  These
store away the lexical pad of the currently executing routine, the
stack of routines waiting to resume along with their lexical pads,
along with things possibly stored outside the tree-traversal routines,
such as the current matching position in the target string.  Invoking
the continuation restores all those things automatically, making
continuations a desirable tool for implementing backtracking.

STD.pm's gimme5 uses closures to emulate the continuation-like
behavior.  Closures have the same ability to capture state as
continuations, and can be made to implement backtracking.  But not
while also doing tree traversal using the conventional call stack.
(Closures don't close over the call stack.)  Which means one has to
do the traversal in some other way, for exaple by using more
closures.  Replacing the call stack with closures is called
'continuation-passing style'.  Instead of popping the stack when
returning, one invokes a continuation pointing back to the calling
routine.

The refactor does not take this route.  Rather, it handles all tree
traversal and registering/invoking of savepoints explicitly.  In a way,
this implementation is as unsugared as it gets, since the control flow
is all explicit, abstracted away neither by continuations nor by
closures.

The tree spider keeps track of the currently executing regex node, the
target string and the current match position, a lexical pad for the
current node as well as a stack of pads for the path of nodes up to the
root, and all currently active savepoints, registered at regex nodes in
the tree, each containing its own stack of lexical pads from which
execution at a given node can be restored.

Under this model, regex nodes do their thing and return the control to
the tree spider as soon as they can.  In doing this, it returns a
partial match result, one of four possible values to guide the tree
spider on:

DESCEND    Node needs result from its children; call downwards
MATCH      Node has completed successfully; return upwards
FAIL       Node has completed unsuccessfully; return downwards

In addition to these three return valus, there's also a fourth value
which is never returned by a node, but generated by the tree spider
when it activates a registered savepoint:

BACKTRACK  Savepoint activated; restore state and continue

Corresponding to these four states are four methods for each regex node
class: .start, .succeeded, .failed and .backtracked, respectively.
Since these methods are called on different nodes than the ones that
send the return values, the tree spider can be seen as a mediator of
signals between nodes, and the regex match as a whole can be seen as an
intricate negotiation between many nodes, with the tree spider making
sure that the negotiation messages are delivered.

A savepoint is registered each time a regex node doing the Backtracking
role returns MATCH.  The tree spider takes the savepoint and places it
not on the node itself, but on the closest ancestor that also does
Backtracking.  This is required to make savepoints trigger when they
should; when failures propagate up the tree, backtracking savepoints
need to be activated as late as possible, to allow for all nodes along
the way to turn a FAIL into a MATCH (as, for example, greedy
quantifiers may do).  The requirement of such an ancestor doing
Backtracking creates the need for a special-cased regex node, called
Regex, which functions as a kind of last-resort savepoint vessel for
those Backtracking nodes which would otherwise not have an ancestor
doing Backtracking.

There's an easy set of low-hanging fruit to be picked in making the
crawling through the tree less explicit.  If a node deep in the tree
suceeds (or fails), it doesn't have to send success (or failure)
signals up several levels to the first node that might do something
interesting with this information.  Instead, the computation of
the first node which will do something interesting with a MATCH
(or FAIL) signal can be computed before the traversal begins.
Similarly, the registering, activation and de-registering of
backtracking savepoints can all be replaced by once-only generated
static information in the form of pointers in the tree.  This all
corresponds to making the tree spider prescient, so that it won't
have to check all the boring intermediate nodes to know where to
go.  Again, this type of optimization has not yet been attempted.

There is a water-tight division between Exp objects and the tree
spider: regex nodes *can't*, and shouldn't, cause backtracking to be
initiated.  Should you find that a certain class of regex node
really can't be implemented without doing explicit backtracking, my
best advice to you is to take a deep breath.  That helped for me.
commit d71f92d543fdc72aa77bba338fefa73f499a8326 1 parent 51cab0f
@masak authored
View
1  .gitignore
@@ -1 +1,2 @@
*.pir
+Makefile
View
19 Makefile
@@ -1,19 +0,0 @@
-PERL6=/Users/masak/work/hobbies/parrot/languages/rakudo/perl6
-RAKUDO_DIR=/Users/masak/work/hobbies/parrot/languages/rakudo
-PERL6LIB='/Users/masak/gwork/gge/lib:/Users/masak/gwork/gge/lib:$(RAKUDO_DIR)'
-
-SOURCES=lib/GGE/Match.pm lib/GGE/Exp.pm lib/GGE/Traversal.pm \
- lib/GGE/Cursor.pm lib/GGE/OPTable.pm lib/GGE/Perl6Regex.pm lib/GGE.pm
-
-PIRS=$(SOURCES:.pm=.pir)
-
-all: $(PIRS)
-
-%.pir: %.pm
- env PERL6LIB=$(PERL6LIB) $(PERL6) --target=pir --output=$@ $<
-
-clean:
- rm -f $(PIRS)
-
-test: all
- env PERL6LIB=$(PERL6LIB) prove -e '$(PERL6)' -r --nocolor t/
View
4 Makefile.in
@@ -2,8 +2,8 @@ PERL6=<PERL6>
RAKUDO_DIR=<RAKUDO_DIR>
PERL6LIB='<PERL6LIB>:$(RAKUDO_DIR)'
-SOURCES=lib/GGE/Match.pm lib/GGE/Exp.pm lib/GGE/Traversal.pm \
- lib/GGE/Cursor.pm lib/GGE/OPTable.pm lib/GGE/Perl6Regex.pm lib/GGE.pm
+SOURCES=lib/GGE/Match.pm lib/GGE/Exp.pm lib/GGE/TreeSpider.pm \
+ lib/GGE/OPTable.pm lib/GGE/Perl6Regex.pm lib/GGE.pm
PIRS=$(SOURCES:.pm=.pir)
View
109 lib/GGE/Cursor.pm
@@ -1,109 +0,0 @@
-use v6;
-
-use GGE::Exp;
-use GGE::Traversal;
-
-class GGE::Cursor {
-
- has GGE::Traversal $!traversal;
- has Str $!target;
- has Int $.pos;
-
- submethod BUILD(GGE::Exp :$exp, Str :$!target, Int :$!pos) {
- $!traversal = GGE::Traversal.new(:$exp);
- }
-
- method matches(:$debug) {
- my &DEBUG = $debug ?? -> *@_ { say @_ } !! -> *@ {};
- DEBUG "Starting match at pos $!pos";
-
- my @savepoints;
- my $backtracking = False;
- my $current = $!traversal.next.<START>;
- while $current ne 'END' {
- if $backtracking {
- if $current.backtrack().() {
- $backtracking = False;
- }
- else {
- pop @savepoints;
- return False unless @savepoints; # XXX acabcabbcac
- $current = @savepoints[*-1];
- redo;
- }
- }
- else {
- my $old-pos = $!pos;
- if $current.matches($!target, $!pos) {
- DEBUG "MATCH: '{$current.ast}' at pos $old-pos";
- }
- else {
- DEBUG "MISMATCH: '{$current.ast}' at pos $old-pos";
- return False unless @savepoints; # XXX acabcabbcac
- DEBUG 'Backtracking...';
- $current = @savepoints[*-1];
- $backtracking = True;
- redo;
- }
- if $current ~~ GGE::Exp::Quant | GGE::Exp::Alt {
- push @savepoints, $current;
- }
- }
- $current = $!traversal.next.{$current.WHICH};
- }
- return True;
- }
-}
-
-class GGE::Exp::Quant is also {
- has &.backtrack = { False };
-
- method matches($string, $pos is rw) {
- for ^self.hash-access('min') {
- return False if !self[0].matches($string, $pos);
- }
- my $n = self.hash-access('min');
- if self.hash-access('backtrack') == EAGER {
- &!backtrack = {
- $n++ < self.hash-access('max') && self[0].matches($string, $pos)
- };
- }
- else {
- my @positions;
- while $n++ < self.hash-access('max') {
- push @positions, $pos;
- last if !self[0].matches($string, $pos);
- }
- if self.hash-access('backtrack') == GREEDY {
- &!backtrack = {
- @positions && $pos = pop @positions
- };
- }
- }
- return True;
- }
-}
-
-class GGE::Exp::Alt is also {
- has &.backtrack = { False };
-
- method matches($string, $pos is rw) {
- &!backtrack = {
- &!backtrack = { False };
- my GGE::Cursor $cursor .= new(:exp(self.llist[1]),
- :target($string), :$pos);
- if $cursor.matches() {
- $pos = $cursor.pos;
- return True;
- }
- return False;
- };
- my GGE::Cursor $cursor .= new(:exp(self.llist[0]),
- :target($string), :$pos);
- if $cursor.matches() {
- $pos = $cursor.pos;
- return True;
- }
- &!backtrack();
- }
-}
View
215 lib/GGE/Exp.pm
@@ -1,19 +1,26 @@
use v6;
use GGE::Match;
-enum GGE_BACKTRACK <
- GREEDY
- EAGER
- NONE
->;
-
role ShowContents {
method contents() {
self.ast;
}
}
+# RAKUDO: Could name this one GGE::Exp::Actions or something, if enums
+# with '::' in them worked, which they don't. [perl #71460]
+enum Action <
+ DESCEND
+ MATCH
+ FAIL
+ BACKTRACK
+>;
+
class GGE::Exp is GGE::Match {
+ method start($, $, %) { MATCH }
+ method succeeded($, %) { MATCH }
+ method failed($, %) { FAIL }
+
method structure($indent = 0) {
my $contents
= join ' ',
@@ -27,61 +34,130 @@ class GGE::Exp is GGE::Match {
}
class GGE::Exp::Literal is GGE::Exp does ShowContents {
- method matches($string, $pos is rw) {
- if $pos >= $string.chars {
- return False;
- }
- my $value = ~self.ast;
- if $string.substr($pos, $value.chars) eq $value {
+ method start($string, $pos is rw, %pad) {
+ if $pos < $string.chars
+ && $string.substr($pos, (my $value = ~self.ast).chars) eq $value {
$pos += $value.chars;
- return True;
+ MATCH
+ }
+ else {
+ FAIL
}
}
}
-class GGE::Exp::Quant is GGE::Exp {
+enum GGE_BACKTRACK <
+ GREEDY
+ EAGER
+ NONE
+>;
+
+role Backtracking {}
+
+class GGE::Exp::Quant is GGE::Exp does Backtracking {
+ method contents() {
+ my ($min, $max, $bt) = map { self.hash-access($_) },
+ <min max backtrack>;
+ $bt //= GREEDY;
+ "{$bt.name.lc} $min..$max"
+ }
+
+ method start($_: $, $, %pad is rw) {
+ %pad<reps> = 0;
+ my $bt = .hash-access('backtrack') // GREEDY;
+ if .hash-access('min') > 0 {
+ DESCEND
+ }
+ elsif .hash-access('max') > 0 && $bt != EAGER {
+ if %pad<reps> >= .hash-access('min') {
+ (%pad<mempos> //= []).push(%pad<pos>);
+ }
+ DESCEND
+ }
+ else {
+ MATCH
+ }
+ }
+
+ method succeeded($_: $, %pad is rw) {
+ ++%pad<reps>;
+ if (.hash-access('backtrack') // GREEDY) != EAGER
+ && %pad<reps> < .hash-access('max') {
+ if %pad<reps> > .hash-access('min') {
+ (%pad<mempos> //= []).push(%pad<pos>);
+ }
+ DESCEND
+ }
+ else {
+ MATCH
+ }
+ }
+
+ method failed($_: $pos, %pad is rw) {
+ if %pad<reps> >= .hash-access('min') {
+ MATCH
+ }
+ else {
+ FAIL
+ }
+ }
+
+ method backtracked($_: $pos is rw, %pad) {
+ my $bt = .hash-access('backtrack') // GREEDY;
+ if $bt == EAGER
+ && %pad<reps> < .hash-access('max') {
+ DESCEND
+ }
+ elsif $bt == GREEDY && +%pad<mempos> {
+ $pos = pop %pad<mempos>;
+ MATCH
+ }
+ else {
+ FAIL
+ }
+ }
}
class GGE::Exp::CCShortcut is GGE::Exp does ShowContents {
- method matches($string, $pos is rw) {
+ method start($string, $pos is rw, %pad) {
if $pos >= $string.chars {
- return False;
+ FAIL
}
- if self.ast eq '.'
+ elsif self.ast eq '.'
|| self.ast eq '\\s' && $string.substr($pos, 1) eq ' '
|| self.ast eq '\\S' && $string.substr($pos, 1) ne ' '
|| self.ast eq '\\N' && !($string.substr($pos, 1) eq "\n"|"\r") {
++$pos;
- return True;
+ MATCH
}
else {
- return False;
+ FAIL
}
}
}
class GGE::Exp::Newline is GGE::Exp does ShowContents {
- method matches($string, $pos is rw) {
+ method start($string, $pos is rw, %pad) {
if $pos >= $string.chars {
- return False;
+ FAIL
}
- if $string.substr($pos, 2) eq "\r\n" {
+ elsif $string.substr($pos, 2) eq "\r\n" {
$pos += 2;
- return True;
+ MATCH
}
- if $string.substr($pos, 1) eq "\n"|"\r" {
+ elsif $string.substr($pos, 1) eq "\n"|"\r" {
++$pos;
- return True;
+ MATCH
}
else {
- return False;
+ FAIL
}
}
}
class GGE::Exp::Anchor is GGE::Exp does ShowContents {
- method matches($string, $pos is rw) {
- return self.ast eq '^' && $pos == 0
+ method start($string, $pos is rw, %pad) {
+ my $matches = self.ast eq '^' && $pos == 0
|| self.ast eq '$' && $pos == $string.chars
|| self.ast eq '<<' && $string.substr($pos, 1) ~~ /\w/
&& ($pos == 0 || $string.substr($pos - 1, 1) !~~ /\w/)
@@ -93,41 +169,104 @@ class GGE::Exp::Anchor is GGE::Exp does ShowContents {
|| self.ast eq '$$' && ($string.substr($pos, 1) eq "\n"
|| $pos == $string.chars
&& ($pos < 1 || $string.substr($pos - 1, 1) ne "\n"));
+ $matches ?? MATCH !! FAIL;
}
}
-class GGE::Exp::Concat is GGE::Exp {
+role MultiChild {}
+
+class GGE::Exp::Concat is GGE::Exp does MultiChild {
+ method start($, $, %pad is rw) {
+ %pad<child> = 0;
+ DESCEND
+ }
+
+ method succeeded($, %pad is rw) {
+ if ++%pad<child> == self.elems {
+ MATCH
+ }
+ else {
+ DESCEND
+ }
+ }
}
class GGE::Exp::Modifier is GGE::Exp does ShowContents {
+ method start($, $, %) { DESCEND }
}
class GGE::Exp::EnumCharList is GGE::Exp does ShowContents {
- method matches($string, $pos is rw) {
- if $pos >= $string.chars {
- return False;
+ method start($string, $pos is rw, %pad) {
+ if $pos >= $string.chars && !self.hash-access('iszerowidth') {
+ FAIL
}
- if defined(self.ast.index($string.substr($pos, 1)))
+ elsif defined(self.ast.index($string.substr($pos, 1)))
xor self.hash-access('isnegated') {
unless self.hash-access('iszerowidth') {
++$pos;
}
- return True;
+ MATCH
}
else {
- return False;
+ FAIL
}
}
}
-class GGE::Exp::Alt is GGE::Exp {
+class GGE::Exp::Alt is GGE::Exp does MultiChild does Backtracking {
+ method start($, $pos, %pad) {
+ %pad<child> = 0;
+ %pad<orig-pos> = $pos;
+ DESCEND
+ }
+
+ method failed($pos is rw, %pad is rw) {
+ self.backtracked($pos, %pad);
+ }
+
+ method backtracked($pos is rw, %pad is rw) {
+ if %pad<child> {
+ FAIL
+ }
+ else {
+ $pos = %pad<orig-pos>;
+ %pad<child> = 1;
+ DESCEND
+ }
+ }
+}
+
+class GGE::Exp::Conj is GGE::Exp does MultiChild {
+ method start($, $pos, %pad) {
+ %pad<child> = 0;
+ %pad<orig-pos> = $pos;
+ DESCEND
+ }
+
+ method succeeded($pos is rw, %pad) {
+ if %pad<child> {
+ if $pos == %pad<firstmatch-pos> {
+ MATCH
+ }
+ else {
+ FAIL
+ }
+ }
+ else {
+ %pad<firstmatch-pos> = $pos;
+ $pos = %pad<orig-pos>;
+ %pad<child> = 1;
+ DESCEND
+ }
+ }
}
class GGE::Exp::WS is GGE::Exp {
- method matches($string, $pos is rw) {
- return True;
+ method start($string, $pos is rw, %pos) {
+ MATCH
}
}
class GGE::Exp::Group is GGE::Exp {
+ method start($, $, %) { DESCEND }
}
View
21 lib/GGE/Perl6Regex.pm
@@ -2,7 +2,7 @@ use v6;
use GGE::Match;
use GGE::Exp;
use GGE::OPTable;
-use GGE::Cursor;
+use GGE::TreeSpider;
class GGE::Perl6Regex {
has $!regex;
@@ -57,7 +57,9 @@ class GGE::Perl6Regex {
:parsed(&GGE::Perl6Regex::parse_quant));
$optable.newtok('infix:', :looser<postfix:*>, :assoc<list>,
:nows, :match(GGE::Exp::Concat));
- $optable.newtok('infix:|', :looser<infix:>,
+ $optable.newtok('infix:&', :looser<infix:>,
+ :nows, :match(GGE::Exp::Conj));
+ $optable.newtok('infix:|', :looser<infix:&>,
:nows, :match(GGE::Exp::Alt));
$optable.newtok('prefix:|', :equiv<infix:|>,
:nows, :match(GGE::Exp::Alt));
@@ -74,14 +76,7 @@ class GGE::Perl6Regex {
if $debug {
say $!regex.structure;
}
- for ^$target.chars -> $from {
- my GGE::Cursor $cursor .= new(:exp($!regex), :$target,
- :pos($from), :$debug);
- if $cursor.matches(:$debug) {
- return GGE::Match.new(:$target, :$from, :to($cursor.pos));
- }
- }
- return GGE::Match.new(:$target, :from(0), :to(-2));
+ GGE::TreeSpider.new(:$!regex, :$target, :pos(*)).crawl(:$debug);
}
sub parse_term($mob) {
@@ -295,4 +290,10 @@ class GGE::Perl6Regex {
$exp[1] = perl6exp($exp[1], %pad);
return $exp;
}
+
+ multi sub perl6exp(GGE::Exp::Conj $exp is rw, %pad) {
+ $exp[0] = perl6exp($exp[0], %pad);
+ $exp[1] = perl6exp($exp[1], %pad);
+ return $exp;
+ }
}
View
38 lib/GGE/Traversal.pm
@@ -1,38 +0,0 @@
-use v6;
-
-use GGE::Exp;
-
-class GGE::Traversal;
-
-has %.next;
-has $!current;
-
-submethod step($exp) {
- my $curstr = $!current ~~ GGE::Exp ?? $!current.WHICH !! $!current;
- %!next{$curstr} = $exp;
- $!current = $exp;
-}
-
-submethod BUILD(GGE::Exp :$exp!) {
- $!current = 'START';
- self.weave($exp);
- self.step('END');
-}
-
-multi method weave(GGE::Exp $exp) {
- self.step($exp);
-}
-
-multi method weave(GGE::Exp::Modifier $exp) {
- self.weave($exp.llist[0]);
-}
-
-multi method weave(GGE::Exp::Group $exp) {
- self.weave($exp.llist[0]);
-}
-
-multi method weave(GGE::Exp::Concat $exp) {
- for $exp.llist -> $child {
- self.weave($child);
- }
-}
View
117 lib/GGE/TreeSpider.pm
@@ -0,0 +1,117 @@
+use v6;
+use GGE::Exp;
+
+class GGE::Exp::Regex is GGE::Exp does Backtracking {
+ method start($, $, %) { DESCEND }
+}
+
+class GGE::TreeSpider {
+ has GGE::Exp $!top;
+ has Str $!target;
+ has Int $!from;
+ has Int $!pos;
+ has Bool $!iterate-positions;
+
+ has GGE::Exp $!current;
+ has Int $!pos;
+ has Action $!last;
+ has GGE::Exp @!nodestack;
+ has @!padstack;
+ has %!savepoints;
+
+ submethod BUILD(GGE::Exp :$regex!, Str :$!target!, :$pos!) {
+ $!top = GGE::Exp::Regex.new();
+ $!top[0] = $regex;
+ # RAKUDO: Smartmatch on type yields an Int, must convert to Bool
+ # manually. [perl #71462]
+ if $!iterate-positions = ?($pos ~~ Whatever) {
+ $!from = 0;
+ }
+ else {
+ $!from = $pos;
+ }
+ }
+
+ method crawl(:$debug) {
+ my &debug = $debug ?? -> *@_ { $*ERR.say(|@_) } !! -> *@_ { ; };
+ my @start-positions = $!iterate-positions ?? ^$!target.chars !! $!from;
+ for @start-positions -> $start-position {
+ debug 'Starting at position ', $start-position;
+ $!pos = $start-position;
+ $!current = $!top;
+ $!last = DESCEND;
+ loop {
+ my %pad = $!last == DESCEND ?? (:pos($!pos)) !! pop @!padstack;
+ my $nodename = $!current.WHAT.perl.subst(/.* '::'/, '');
+ if $!current.?contents {
+ $nodename ~= '(' ~ $!current.contents ~ ')';
+ }
+ my $fragment = ($!target ~ '«END»').substr($!pos, 5);
+ if $!last == FAIL {
+ if %!savepoints.exists($!current.WHICH)
+ && +%!savepoints{$!current.WHICH} {
+ my @sp = %!savepoints{$!current.WHICH}.pop.list;
+ @!nodestack = @sp[0].list;
+ @!padstack = @sp[1].list;
+ $!current = @!nodestack[*-1];
+ $!last = BACKTRACK;
+ next;
+ }
+ }
+ if $!last == BACKTRACK {
+ $!pos = %pad<pos>;
+ }
+ my $action = do given $!last {
+ when DESCEND { $!current.start($!target, $!pos, %pad) }
+ when MATCH { $!current.succeeded($!pos, %pad) }
+ when FAIL { $!current.failed($!pos, %pad) }
+ when BACKTRACK { $!current.backtracked($!pos, %pad) }
+ };
+ if $action == DESCEND && %!savepoints.exists($!current.WHICH) {
+ %!savepoints.delete($!current.WHICH);
+ }
+ if $action != DESCEND {
+ my $participle
+ = $!last == BACKTRACK ?? 'backtracking' !! 'matching';
+ debug sprintf '%-20s %12s "%-5s": %s',
+ $nodename,
+ $participle,
+ $fragment,
+ $action.name;
+ }
+ %pad<pos> = $!pos;
+ push @!padstack, \%pad;
+ if $!last == DESCEND {
+ push @!nodestack, $!current;
+ }
+ if $!current ~~ Backtracking && $action == MATCH {
+ my $index = @!nodestack.end - 1;
+ $index--
+ until @!nodestack[$index] ~~ Backtracking;
+ my $ancestor = @!nodestack[$index];
+ (%!savepoints{$ancestor.WHICH} //= []).push(
+ [[@!nodestack.list], [@!padstack.list]]
+ );
+ }
+ if $action == DESCEND {
+ $!current = $!current[ $!current ~~ MultiChild
+ ?? %pad<child> !! 0 ];
+ }
+ else {
+ pop @!nodestack;
+ last unless @!nodestack;
+ $!current = @!nodestack[*-1];
+ pop @!padstack;
+ }
+ $!last = $action;
+ }
+ if $!last == MATCH {
+ return GGE::Match.new(:target($!target),
+ :from($start-position),
+ :to($!pos));
+ }
+ }
+
+ GGE::Match.new(:target($!target), :from(0), :to(-2));
+ }
+}
Please sign in to comment.
Something went wrong with that request. Please try again.