4
\$\begingroup\$

What I've hastily named the Scissors API brings the cut-copy-paste paradigm made popular by word processors to the realm of list processing. The Scissors API is fluent, provides methods to manipulate a list, and maintains context through the use of a customizable clipboard.

An example: Fixing Yoda’s grammar

A good starting point is with a simple (partial) quote from wise Yoda: Powerful you have become. The objective is to move the word powerful to the end of the sentence, after the word welcome.

But here's the catch:

You don't know the positions of the words; this has to be discovered. You don’t know whether the word powerful is capitalized or not. Once the word powerful has been repositioned it needs to be made lower case.

A traditional solution

def yoda = 'Powerful you have become'

def words = yoda.split(' ')
def sourceWord = words.find { it.toLowerCase() == 'powerful' }
words = words - sourceWord as List
def targetIndex = words.findIndexOf { it == 'become' }
words.add(targetIndex + 1, sourceWord.toLowerCase())
words[0] = words[0].capitalize()
assert words.join(' ') == 'You have become powerful'

I'll wait while you process how that code works. And that’s precisely the problem. It's a solution, but one that requires figuring out how it works before you can understand what it does.

In contrast, with Scissors it's much easier to understand the intent of the code. Note, the following example purposely avoids some of the shortcuts available in the API in order to demonstrate more features.

A Scissors API solution

def s = new Scissors(yoda.split(' '))
    .select { it.toLowerCase() == 'powerful' }
    .cut()
    .withClipboard { toLowerCase() }
    .pasteAfter { it.toLowerCase() == 'become' }
    .select(/you/)
    .withSelection { capitalize() }

assert s.list.join(' ') == 'You have become powerful'

The Scissors API

The Scissors API is based on the copy-cut-paste paradigm we all use to touch up our Facebook posts. The idea is to make list manipulations which require maintaining a context as easy as text manipulation in a word processor.

  • select() - Defines a selection of list items. Other methods act on the selection.
  • copy() - Copies the selection from the list into the clipboard.
  • cut() - Removes the selection from the list and inserts it into the clipboard.
  • insert() - Puts an object into the clipboard (not into the list).
  • pasteBefore() - Pastes the clipboard’s contents into the list ahead of the selection.
  • pasteAfter() - Pastes the clipboard’s contents into the list after of the selection.
  • withClipboard() - Allows modifications to the clipboard’s contents. This depends on the clipboard implementation.
  • withSelection() - Allows in-place modifications to the selection.

Method variations

Some of the methods are overloaded to make life that much easier. For example, there are three variations of the cut() method:

  • cut() - Uses the selection, so a select() call has to be made beforehand.
  • cut(Closure) - Calls select(Closure) before calling cut().
  • cut(String) - Calls select(String) where String is a regular expression, before calling cut().

The clipboard

The clipboard is an object which must provide three methods:

  • leftShift() - Called to insert an object into the clipboard.
  • call() - Called to retrieve the contents of the clipboard.
  • with() - Called to allow manipulation of the clipboard through the withClipboard() method.

Consider this example:

s = new Scissors('in The I sense side you dark'.split(' '))
    .cut(/The/)
    .pasteBefore(/in/)
    .cut(/dark/)
    .pasteAfter(/The/)
    .cut(/side/)
    .pasteAfter(/dark/)
    .cut(/in/)
    .pasteBefore(/you/)

assert s.list.join(' ') == 'The dark side I sense in you'

Yoda had too much to drink and got his words all mixed up. Notice the alternating cut()/paste*() calls. That’s because the default clipboard can only hold one object. But with a custom clipboard the Scissors API can be utilized in a more sensible manner.

/*
 * I dump all my contents at once when asked to paste.
 */
class DumpingClipboard implements Clipboard {
    def values = []

    def leftShift(Object object) {
        values << object

        return this
    }

    def call() {
        return values
    }

    Clipboard with(Closure closure) {
        values = values.with closure

        return this
    }

    String toString() {
        "${super.toString()} with contents: ${value}"
    }
}

s = new Scissors('in The I sense side you dark'.split(' '), new DumpingClipboard())
    .cut(/The/)
    .cut(/dark/)
    .cut(/side/)
    .cut(/I/)
    .cut(/sense/)
    .cut(/in/)
    .pasteBefore(/you/)
    .select(0..-1)
    .withSelection { it.flatten() }

assert s.list.join(' ') == 'The dark side I sense in you'

The strange flattening toward the end is due to the fact that a clipboard can provide only a single value to the Scissors. (Probably an API defect). But, the pasting in the example above could be simplified by creating a macro:

s = new Scissors('in The I sense side you dark'.split(' '), new DumpingClipboard(), {
    pasteFlattened {args ->
        pasteBefore(args)
        select(0..-1)
        withSelection { it.flatten() }
    }})

    .cut(/The/)
    .cut(/dark/)
    .cut(/side/)
    .cut(/I/)
    .cut(/sense/)
    .cut(/in/)
    .pasteFlattened(/you/)

assert s.list.join(' ') == 'The dark side I sense in you'

Review

My focus for this review is to learn about what people would like from such an API. Try it out! Sure there are bugs (I wrote the whole thing today), but my interest is in the API's usage.

Scissors API Source Code

interface Clipboard {
    /*
     * Adds an object to the clipboard.
     * @param object to add.
     * @return the clipboard.
     */
    Clipboard leftShift(Object object)

    /*
     * Returns the contents of the clipboard.
     * @return the contents of the clipboard.
     */
    def call()

    /*
     * Provides the clipboard to the Closure for customized clipboard manipulation.
     * Note: The method calls made within the Closure are clipboard-implementation-specific.
     * @return the clipboard.
     */
    Clipboard with(Closure closure)
}

class BasicClipboard implements Clipboard {
    def value

    Clipboard leftShift(Object object) {
        value = object

        return this
    }

    def call() {
        return value
    }

    Clipboard with(Closure closure) {
        value = value.with closure

        return this
    }

    String toString() {
        "${super.toString()} with contents: ${value}"
    }
}

/* 
 * The workhorse of the Scissors API
 */
class Scissors {
    List list
    Clipboard clipboard
    def selection

    /*
     * Initializes the API with a default clipboard.
     * @param list to manipulate
     */
    Scissors(Object list) {
        this(list, new BasicClipboard())
    }

    /*
     * Initializes the API with a default clipboard.
     * @param list to manipulate
     * @param macros a Closure passed to the ExpandoMetaClass to add methods to the Scissors.
     */
    Scissors(Object list, Closure macros) {
        this(list, new BasicClipboard(), macros)
    }

    /*
     * Initializes the API.
     * @param list to manipulate
     * @param clipboard to use.
     */    
    Scissors(Object list, Clipboard clipboard) {
        this.list = list
        this.clipboard = clipboard
    }

    /*
     * Initializes the API.
     * @param list to manipulate
     * @param clipboard to use.
     * @param macros a Closure passed to the ExpandoMetaClass to add methods to the Scissors.
     */       
    Scissors(Object list, Clipboard clipboard, Closure macros) {
        this.list = list
        this.clipboard = clipboard
        this.metaClass macros
    }

    /* 
     * Makes the current selection the list element identified by the index.
     * @param index of element to select.
     * @return the Scissors
     */
    Scissors select(int index) {
        selection = index

        return this
    }

    /* 
     * Makes the current selection the list elements identified by the Range.
     * @param range of elements to select.
     * @return the Scissors
     */
    Scissors select(Range range) {
        selection = range

        return this
    }

    /* 
     * Makes the current selection the first element for which the Closure evaluates to true.
     * @param closure to select an element.
     * @return the Scissors
     */
    Scissors select(Closure closure) {
        def index = list.findIndexOf closure

        selection = index > -1 ? index : selection

        return this
    }

    /* 
     * Makes the current selection the first element for which the regular expression evaluates to true.
     * @param regex expression to select an element.
     * @return the Scissors
     */
    Scissors select(String regex) {
        return select { it ==~ regex }
    }

    /* Removes the selected element(s) and copies it/them into the clipboard.
     * @return the Scissors
     */
    Scissors cut() {
        copy()
        list.removeElement list[selection] // BUG: doesn't work with a Range.

        return this
    }

    /* Uses select(Closure) to select an element, removes it from the list, and then copies it into the clipboard.
     * @param closure to select an element.
     * @return the Scissors
     */    
    Scissors cut(Closure closure) {
        select(closure).cut()
    }

    /* Uses select(String) to select an element, removes it from the list, and then copies it into the clipboard.
     * @param regex to select an element.
     * @return the Scissors
     */     
    Scissors cut(String regex) {
        select(regex).cut()
    }

    /* Copies the selected element(s) into the clipboard.
     * @return the Scissors
     */
    Scissors copy() {
        clipboard << list[selection]

        return this
    }

    /* Uses select(Closure) to select an element and then copies it into the clipboard.
     * @param closure to select an element.
     * @return the Scissors
     */  
    Scissors copy(Closure closure) {
        select(closure).copy()
    }

    /* Uses select(String) to select an element and then copies it into the clipboard.
     * @param regex to select an element.
     * @return the Scissors
     */  
    Scissors copy(String regex) {
        select(regex).copy()
    }

    /*
     * Inserts an arbitrary object into the clipboard.
     * Useful for adding content not found in the list.
     * @return the Scissors
     */
    Scissors insert(Object object) {
        clipboard << object

        return this
    }

    /*
     * Inserts the contents of the clipbboard into the list before the current selection.
     * @return the Scissors
     */
    Scissors pasteBefore() { 
        list.add selection, clipboard()

        return this
    }

    /*
     * Uses select(Closure) to make a selection and then inserts the contents of the 
     * clipbboard into the list before the selection.
     * @param closure to select element
     * @return the Scissors
     */
    Scissors pasteBefore(Closure closure) { 
        select(closure).pasteBefore()
    }

    /*
     * Uses select(String) to make a selection and then inserts the contents of the 
     * clipbboard into the list before the selection.
     * @param regex to select element
     * @return the Scissors
     */
    Scissors pasteBefore(String regex) { 
        select(regex).pasteBefore()
    }

    /*
     * Inserts the contents of the clipbboard into the list after the current selection.
     * @return the Scissors
     */
    Scissors pasteAfter() { 
        list.add selection + 1, clipboard()

        return this
    }

    /*
     * Uses select(Closure) to make a selection and then inserts the contents of the 
     * clipbboard into the list after the selection.
     * @param closure to select element
     * @return the Scissors
     */
    Scissors pasteAfter(Closure closure) { 
        select(closure).pasteAfter()
    }

    /*
     * Uses select(String) to make a selection and then inserts the contents of the 
     * clipbboard into the list after the selection.
     * @param regex to select element
     * @return the Scissors
     */
    Scissors pasteAfter(String regex) { 
        select(regex).pasteAfter()
    }

    /*
     * Executes the Closure with the clipboard as the delegate.
     */
    Scissors withClipboard(Closure closure) {
        clipboard = clipboard.with closure

        return this
    }

    /*
     * Executes the Closure with a subset of the list as the delegate.
     */
    Scissors withSelection(Closure closure) {
        list[selection] = list[selection].with closure

        return this
    }

    String toString() {
        "${super.toString()} with list ${list}"
    }
}
\$\endgroup\$
1
\$\begingroup\$

If Scissors always requires a split string, why not make it part of the initialisation?

new Scissors('Not-so great solution'.split(' '))

See;

new Scissors('Much better.')

With initialisation like:

Scissors(Object list) {
    this(list, new BasicClipboard())
}
Scissors(String list) {
    this(list.split(' '), new BasicClipboard())
}

Then, you could just let the input be either, in case the user has a preference, or a need for custom .split() rules.


It might be better on styling to indent the pasting:

.cut(/The/)
.pasteBefore(/in/)
.cut(/dark/)
.pasteAfter(/The/)
.cut(/side/)
.pasteAfter(/dark/)
.cut(/in/)
.pasteBefore(/you/)

into something like:

.cut(/The/)
    .pasteBefore(/in/)
.cut(/dark/)
    .pasteAfter(/The/)
.cut(/side/)
    .pasteAfter(/dark/)
.cut(/in/)
    .pasteBefore(/you/)

Indentation is for children elements in a language, of which, .pasteBefore sort-of is a parent of .cut


Returning s.list.join(' ') as a result seems a little forced. If you had something along the lines of s.compile(), and then, you can keep the default value of .compile as a space (" "), and make it optional, in case you want something different.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.