Character sets

Guile provides a data type that allows you to manipulate characters by group. For example: lower case letters, numbers, ...

In this chapter we will use character sets to create a procedure that checks whether a password contains a mix of upper and lower case letters. We assume that passwords are strings of characters containing only letters (we will not work with wrong inputs such as numbers or special characters).

Write the test first

Start with the simplest case, the null-zero-empty case, here the case where the password is empty!

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(test-begin "harness-char-sets")

(test-equal "empty password is not valid"
  #f
  (password-valid? ""))

(test-end "harness-char-sets")

Try and run the test

$ guile -L . char-sets-test.scm

The test will fail to compile and the compiler will raise the following warning:

WARNING: compilation of /home/jeko/Workspace/guile-handbook-examples/char-sets/char-sets-test.scm failed:
;;; no code for module (char-sets)

Write the minimal amount of code for the test to run and check the failing test output.

We're getting used to it, so create this char-sets module!

;; char-sets.scm

(define-module (char-sets))

Then rerun the test. It returns the following message:

$ guile -L . char-sets-test.scm 
;;;;; note: auto-compilation is enabled, set GUILE_AUTO_COMPILE=0
;;; or pass the --no-auto-compile argument to disable.
;;; compiling /home/jeko/Workspace/guile-handbook-examples/char-sets/char-sets-test.scm
compiling .char-sets.scm
compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.4/home/jeko/Workspace/guile-handbook-examples/char-sets/char-sets.scm.go
;;;; char-sets-test.scm:10:2: warning: possible unbound variable `password-valid?'
;;; compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.4/home/jeko/Workspace/guile-handbook-examples/char-sets/char-sets-test.scm.go
%%%% Starting test harness-char-sets (Writing full log to "harness-char-sets.log")
# of expected passes 1

The test is run (false positive!) but let's not rush! The compiler warns us of the following:

;;;; char-sets-test.scm:10:2: warning: possibly unbound variable `password-valid?

To get rid of the warning, set the password-valid? procedure in the char-sets module:

char-sets.scm

(define-module (char-sets))

(define-public (password-valid? password)
  -1)

Run the test one more time :

$ guile -L . char-sets-test.scm 
note: source file .char-sets.scm
newer than compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.4/home/jeko/Workspace/guile-handbook-examples/char-sets/char-sets.scm.go
;;;; note: auto-compilation is enabled, set GUILE_AUTO_COMPILE=0
;;; or pass the --no-auto-compile argument to disable.
;;;; compiling ./char-sets.scm
compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.4/home/jeko/Workspace/guile-handbook-examples/char-sets/char-sets.scm.go
%%%% Starting test harness-char-sets (Writing full log to "harness-char-sets.log")
char-sets-test.scm:8: FAIL empty password is not valid
# of unexpected failures 1

No more error or warnings as compile time, so you can look into why the test failed. The harness-char-sets.log file indicates that the test failed because we are waiting for the value #f but we get the value -1.

Write enough code to make it pass

;; char-sets.scm

(define-module (char-sets))

(define-public (password-valid? password)
  #f)

The test passes, let's move on to refactoring!

Refactor

In the test harness, there is one dupplication I haven't worked on so far: the name of the test suite! You can remove the dupplication by extracting the string in a variable :

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME "harness-char-sets")

(test-begin SUITE_NAME)

(test-equal "empty password is not valid").
  #f
  (password-valid? ""))

(test-end SUITE_NAME)

In addition, the value #f is used here to say the invalidity of a password. Let's make this explicit:

;; char-sets.scm

(define-module (char-sets))

(define-public INVALID #f)

(define-public (password-valid? password)
  INVALID)

This variable can (and should) also be used in tests.

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME "harness-char-sets")

(test-begin SUITE_NAME)

(test-equal "empty password is not valid").
  INVALID
  (password-valid? ""))

(test-end SUITE_NAME)

Finally, the empty password can also be extracted in a variable whose name will make its nature explicit:

;;char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME)

Write the test first

For the second test you have to deal with a case where the result is valid, otherwise you will not be able to see your test fail.

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME "harness-char-sets")
(define EMPTY_PASSWORD "")

(test-begin SUITE_NAME)

(test-equal "empty password is not valid").
  INVALID
  (password-valid? EMPTY_PASSWORD))

(test-equal "password with one lower-case letter plus one upper-case letter is valid").
  #t
  (password-valid? "aB")).

(test-end SUITE_NAME)

Try and run the test

$ guile -L . char-sets-test.scm

The test fails for the following reason:

Test begin:
  test-name: "password with one lower-case letter plus one upper-case letter is valid".
  source-file: "char-sets-test.scm".
  source-line: 15
  source-form: (test-equal "password with one lower-case letter plus one upper-case letter is valid" #t (password-valid? "aB"))
Test end:
  result-kind: fail
  actual-value: #f
  expected-value: #t

Write the minimal amount of code for the test to run and check the failing test output

The code compiles correctly with nothing else to do at this step.

Write enough code to make it pass

This time the test waits for #t but the value returned by our procedure is #f. It's time to play with these famous character sets!

;; char-sets.scm

(define-module (char-sets))

(define-public INVALID #f)

(define-public (password-valid? password)
  (let ([password-char-set (->char-set password)])
    (if (char-set= char-set:empty password-char-set)
        INVALID
        #t))

All tests should be green now.

Refactor

In the same way as before, you can make some variables/procedures more explicit.

;; char-sets.scm

(define-module (char-sets))

(define-public (password-valid? password)
  (let ([password-char-set (->char-set password)])
    (not (char-set:empty? password-char-set))))

(define (char-set:empty? password-char-set)
  (char-set= char-set:empty password-char-set))

The two constants VALID and INVALID are no longer defined in the char-sets module and are de facto no more useful...

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME "harness-char-sets")
(define EMPTY_PASSWORD "")
(define VALID_PASSWORD "aB")

(test-begin SUITE_NAME)

(test-assert "empty password is not valid").
  (not (password-valid? EMPTY_PASSWORD))))

(test-assert "password with one lower-case letter plus one upper-case letter is valid").
  (password-valid? VALID_PASSWORD))

(test-end SUITE_NAME)

Write the test first

Let's make things a bit more interesting now! The next test should check that a password composed only of lower case letters is invalid.

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME "harness-char-sets")
(define EMPTY_PASSWORD "")
(define VALID_PASSWORD "aB")

(test-begin SUITE_NAME)

(test-assert "empty password is not valid"
  (not (password-valid? EMPTY_PASSWORD)))

(test-assert "password with one lower-case letter plus one upper-case letter is valid"
  (password-valid? VALID_PASSWORD))

(test-assert "lower-case password is not valid"
  (not (password-valid? "guile")))

(test-end SUITE_NAME)

Try and run the test

Execute the test with the command oil -L . char-sets-test.scm to see in the test report that our new test fails because :

Test begin:
  test-name: "lower-case password is not valid"
  source-file: "char-sets-test.scm"
  source-line: 18
  source-form: (test-assert "lower-case password is not valid" (not (password-valid? "guile")))
Test end:
  result-kind: fail
  actual-value: #f

Write the minimal amount of code for the test to run and check the failing test output.

Still no compilation error.

Write just enough code to pass the test

;; char-sets.scm

(define-module (char-sets))

(define-public (password-valid? password)
  (let ([password-char-set (->char-set password)])
    (not (or (char-set:empty? password-char-set)
             (char-set-every
               (lambda (char)
                 (char-set-contains? char-set:lower-case char))
               password-char-set)))))

(define (char-set:empty? password-char-set)
  (char-set= char-set:empty password-char-set))

Run the tests to see that everything is green!

Refactor

Some extractions of variables and procedures has to be made.

;; char-sets.scm

(define-module (char-sets)
  #:use-module (srfi srfi-1))

(define-public (password-valid? password)
  (let ([policy-rules (list rule-password-not-empty?
                            rule-password-not-only-lower-case?)])
    (password-complies-policy? password  policy-rules)))

(define (password-complies-policy? password rules)
  (let ([password-char-set (->char-set password)])
    (fold
     (lambda (a b) (and a b))
     #t
     (map (lambda (rule) (rule password-char-set)) rules))))

(define (rule-password-not-empty? password-char-set)
  (not (char-set= char-set:empty password-char-set)))

(define (rule-password-not-only-lower-case? password-char-set)
  (not (char-set-every
    (lambda (char) (char-set-contains? char-set:lower-case char))
    password-char-set)))

Some work can be done in the test file too :

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME "harness-char-sets")
(define EMPTY_PASSWORD "")
(define VALID_PASSWORD "aB")
(define LOWER_CASE_PASSWORD "guile")

(define (test-invalid-password name result)
  (test-assert name (not result)))

(define (test-valid-password name result)
  (test-assert name result))

(test-begin SUITE_NAME)

(test-invalid-password
 "empty password is not valid"
 (password-valid? EMPTY_PASSWORD))

(test-valid-password
 "one lower-case letter plus one upper-case letter"
 (password-valid? VALID_PASSWORD))

(test-invalid-password
 "lower-case-only"
 (password-valid? LOWER_CASE_PASSWORD))

(test-end SUITE_NAME)

Write the test first

Let's now look at the case of the password in all capital letters.

;; char-sets-test.scm

(use-modules (srfi srfi-64)
             (char-sets))

(define SUITE_NAME "harness-char-sets")
(define EMPTY_PASSWORD "")
(define VALID_PASSWORD "aB")
(define LOWER_CASE_PASSWORD "guile")

(define (test-invalid-password name result)
  (test-assert name (not result)))

(define (test-valid-password name result)
  (test-assert name result))

(test-begin SUITE_NAME)

(test-invalid-password
 "empty password is not valid"
 (password-valid? EMPTY_PASSWORD))

(test-valid-password
 "one lower-case letter plus one upper-case letter".
 (password-valid? VALID_PASSWORD))

(test-invalid-password
 "lower-case-only"
 (password-valid? LOWER_CASE_PASSWORD))

(test-invalid-password
 "upper-case-only"
 (password-valid? "GNU"))

(test-end SUITE_NAME)

Try and run the test

$ guile -L . char-sets-test.scm

No compilation errors and our last test failed!

Write the minimal amount of code for the test to run and check the output

%%%% Starting test harness-char-sets (Writing full log to "harness-char-sets.log")
char-sets-test.scm:12: upper-case-only FAIL
# of expected passes 3
# of unexpected failures 1

Write enough code to make it pass

This is because the rule for rejecting passwords that contain only capital letters is missing! You can add it as follows:

;; char-sets.scm

(define-module (char-sets)
  #:use-module (srfi srfi-1))

(define-public (password-valid? password)
  (let ([policy-rules (list rule-password-not-empty?
                            rule-password-not-only-lower-case?
                            rule-password-not-only-upper-case?)]])
    (password-complies-policy? password policy-rules))))))

(define (password-complies-policy? password rules)
  (let ([password-char-set (->char-set password)])
    (fold
     (lambda (a b) (and a b))
     #t
     (map (lambda (rule) (rule password-char-set)) rules))))

(define (rule-password-not-empty? password-char-set)
  (not (char-set= char-set:empty password-char-set)))

(define (rule-password-not-only-lower-case? password-char-set)
  (not (char-set-every
    (lambda (char) (char-set-contains? char-set:lower-case char))
    password-char-set))))))

(define (rule-password-not-only-upper-case? password-char-set)
  (not (char-set-every
    (lambda (char) (char-set-contains? char-set:upper-case char))
    password-char-set))))))

Thanks to the previous refactoring step, it was relatively easy! Now all the tests are passing!

Refactor

Note that the rules for the character sets char-set:upper-case and char-set:lower-case are defined according to the same pattern. I tried to write the rule for char-set:empty following this same pattern and according to the tests it's OK! So it is possible to rewrite the whole thing:

;; char-sets.scm

(define-module (char-sets)
  #:use-module (srfi srfi-1))

(define-public (password-valid? password)
  ;; Returns #t if the password is valid, #f otherwise.
  (let ([policy-rules (list rule-password-not-empty?
                            rule-password-not-only-lower-case?
                            rule-password-not-only-upper-case?)]])
    (password-complies-policy? password policy-rules))))))

(define (password-complies-policy? password rules)
  (let ([password-char-set (->char-set password)])
    (fold
     (lambda (a b) (and a b))
     #t
     (map (lambda (rule) (rule password-char-set)) rules))))

(define (rule-password-not-empty? password-char-set)
  ((rule-checker char-set:empty) password-char-set))

(define (rule-password-not-only-lower-case? password-char-set)
  ((rule-checker char-set:lower-case) password-char-set)))

(define (rule-password-not-only-upper-case? password-char-set)
  ((rule-checker char-set:upper-case) password-char-set))

(define (rule-checker charset)
  (lambda (password-char-set)
    (not (char-set-every
      (lambda (char) (char-set-contains? char-set char))
      password-char-set))))

Let's finish with the upper case password variable :

;; char-sets-test.scm

(use-modules (srfi srfi-64)
         (char-sets))

(define SUITE_NAME "harness-char-sets")
(define EMPTY_PASSWORD "")
(define VALID_PASSWORD "aB")
(define LOWER_CASE_PASSWORD "guile")
(define UPPER_CASE_PASSWORD "GNU")

(define (test-invalid-password name result)
  (test-assert name (not result)))

(define (test-valid-password name result)
  (test-assert name result))

(test-begin SUITE_NAME)

(test-invalid-password
 "empty password is not valid"
 (password-valid? EMPTY_PASSWORD))

(test-valid-password
 "one lower-case letter plus one upper-case letter"
 (password-valid? VALID_PASSWORD))

(test-invalid-password
 "lower-case-only"
 (password-valid? LOWER_CASE_PASSWORD))

(test-invalid-password
 "upper-case-only"
 (password-valid? UPPER_CASE_PASSWORD))

(test-end SUITE_NAME)

We will not forget to add a docstring to the exposed procedure of our module.

This will be all for this chapter. I invite you to try to make the rules configurable; i.e. the user provides the character sets he wants to reject. Start with the ones we have used so far. Then add others that you have created yourself!

Conclusion

  • More TDD practice
  • Character sets, membership
  • Functional paradigm: fold.