Re: Factor

Factor: the language, the theory, and the practice.

Ask OK?

Tuesday, July 22, 2025

#language

To illustrate some varied aspects of programming with Factor, I wanted to show how you might write an example used in the Python documentation to demonstrate control flow and default values:

def ask_ok(prompt, retries=4, complaint='Yes or no, please!'):
    while True:
        ok = input(prompt)
        if ok in ('y', 'ye', 'yes'):
            return True
        if ok in ('n', 'no', 'nop', 'nope'):
            return False
        retries = retries - 1
        if retries < 0:
            raise IOError('invalid user response')
        print(complaint)

You can see how it works in Python:

>>> ask_ok("Continue? ")
Continue? y
True

>>> ask_ok("Continue? ")
Continue? no
False

>>> ask_ok("Continue? ")
Continue? r
Yes or no, please!
Continue? r
Yes or no, please!
Continue? r
Yes or no, please!
Continue? r
Yes or no, please!
Continue? r
Traceback (most recent call last):
  File "<python-input-1>", line 1, in <module>
    ask_ok("Continue? ")
    ~~~~~~^^^^^^^^^^^^^^
  File "<python-input-0>", line 10, in ask_ok
    raise IOError('invalid user response')
OSError: invalid user response

Direct Translation

Without focusing on the keyword arguments with default values for the moment, we could directly translate this to a similar looping implementation with all arguments provided on the stack:

:: ask-ok ( prompt retries! complaint -- ? )
    f [
        drop prompt write bl flush readln {
            { [ dup { "y" "ye" "yes" } member? ] [ drop t f ] }
            { [ dup { "n" "no" "nop" "nope" member? ] [ drop f f ] }
            [
                retries 1 - retries!
                retries 0 < [ "invalid user response" throw ] when
                complaint print t
            ]
        } cond
    ] loop ;

And then try it out for a pretty similar result:

IN: scratchpad "Continue?" 4 "Yes or no, please!" ask-ok
Continue? y

--- Data stack:
t

IN: scratchpad "Continue?" 4 "Yes or no, please!" ask-ok
Continue? no

--- Data stack:
f

IN: scratchpad "Continue?" 4 "Yes or no, please!" ask-ok
Continue? r
Yes or no, please!
Continue? r
Yes or no, please!
Continue? r
Yes or no, please!
Continue? r
Yes or no, please!
Continue? r
invalid user response

Type :help for debugging help.

Using Namespaces

One way to get default values would be to use initialized dynamic variables, and an inner word that implements the retry logic with tail calls, while also simplifying our prefix check for each supported input variation:

INITIALIZED-SYMBOL: retries [ 4 ]
INITIALIZED-SYMBOL: complaint [ "Yes or no, please!" ]

: (ask-ok) ( prompt n -- ? )
    [ "invalid user response" throw ] [
        1 - over write bl flush readln {
            { [ "yes" over head? ] [ 3drop t ] }
            { [ "nope" over head? ] [ 3drop f ] }
            [ drop complaint get print (ask-ok) ]
        } cond
    ] if-zero ;

: ask-ok ( prompt -- ? )
    retries get (ask-ok) ;

This has the benefit that those arguments can be changed easily using the namespaces vocabulary.

Option Arguments

Another way might be to provide an options tuple, with default values:

TUPLE: ask prompt retries complaint ;

: <ask> ( prompt -- ask )
    4 "Yes or no, please!" ask boa ;

:: ask-ok ( ask -- ? )
    f [
        drop ask prompt>> write bl flush readln {
            { [ "yes" over head? ] [ drop t f ] }
            { [ "nope" over head? ] [ drop f f ] }
            [
                ask [ 1 - dup ] change-retries drop
                0 < [ "invalid user response" throw ] when
                ask complaint>> print t
            ]
        } cond
    ] loop ;

And then use it:

IN: scratchpad "Continue?" <ask> ask-ok
Continue? yes

--- Data stack:
t

Combinators

A different approach might be to use exception handling instead and separate out the logic of the trying quotation from the erroring one, and build a retrying combinator that loops and throws after n attempts:

: with-retries ( try-quot error-quot n -- )
    -rot '[
        [ _ dip ]
        [ swap 1 - [ rethrow ] [ nip @ ] if-zero ] recover t
    ] loop drop ; inline

With that we can then make our simple ask-ok word with an error class to throw the invalid input:

ERROR: invalid-user-response input ;

:: ask-ok ( prompt -- ? )
    prompt write bl flush readln {
        { [ "yes" over head? ] [ drop t ] }
        { [ "nope" over head? ] [ drop f ] }
        [ invalid-user-response ]
    } cond ;

And then try it out with the specified retry logic:

IN: scratchpad [ "Continue?" ask-ok ]
               [ "Yes or no, please?" print ] 4 with-retries
Continue? r
Yes or no, please?
Continue? r
Yes or no, please?
Continue? r
Yes or no, please?
Continue? r
invalid-user-response
input "r"

Type :help for debugging help.

This is now generic and can be used in any other place that retrying is required.

Restarts

Restartable errors is a neat feature, and we can use this to throw a restart when invalid input is provided:

:: ask-ok ( prompt -- ? )
    prompt write bl flush readln {
        { [ "yes" over head? ] [ drop t ] }
        { [ "nope" over head? ] [ drop f ] }
        [
            drop "invalid user response"
            { { "Yes" t } { "Nope" f } } throw-restarts
        ]
    } cond ;

And see that we throw a restart, and then can select one of the options:

IN: scratchpad "Continue?" ask-ok
Continue? r
invalid user response

The following restarts are available:

:1      Yes
:2      Nope

Type :help for debugging help.

IN: scratchpad :1

--- Data stack:
t

Other improvements could include splitting out the yes and nope checks into their own words:

: yes? ( input -- ? ) "yes" swap head? ;

: nope? ( input -- ? ) "nope" swap head? ;

This would allow us to simplify the code – or make it more complex without impacting the calling context – and also to write unit tests for small pieces of the overall system.

How else might you write this?