<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P004-contacts/ | updated: 2026-09-28 -->

# P004 Contacts

Add, find, edit and delete contacts, kept in name order by binary search; find by part of a name or part of a number, with every phone number checked - from a text menu.

EML-P project `projects/contacts` in the EML language repo: 3 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/contacts` runs it and `eml project verify projects/contacts` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: contact-book (https://efficientnewlanguage.org/cases/017-contact-book/), binary-search (https://efficientnewlanguage.org/cases/050-binary-search/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
9
3

1

1
This Name Is Far Too Long For The Book
1
Ann Lee
0912-abc
1
Ann Lee
12-34
1
Ann Lee
0912+345678
1
Ann Lee
1234567890123456
1
Ann Lee
0912 345 678
1
ANN LEE
3
zz
4
x
4
7
4
1

123
5
0
2
6
```

Screen:

```text

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 9
Pick a number from 1 to 6.

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 3
part of a name or number> 
Type part of a name or a number.

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> 
A contact needs a name.

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> This Name Is Far Too Long For The Book
A name has at most 30 characters.

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Ann Lee
phone> 0912-abc
A phone number has only digits, spaces and dashes (and a + first).

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Ann Lee
phone> 12-34
A phone number has 7 to 15 digits; this one has 4.

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Ann Lee
phone> 0912+345678
A + can only come first.

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Ann Lee
phone> 1234567890123456
A phone number has 7 to 15 digits; this one has 16.

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Ann Lee
phone> 0912 345 678
Added #1: Ann Lee.

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> ANN LEE
There is already a contact named Ann Lee.

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 3
part of a name or number> zz

-- found: 0 --
  (nothing here)

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 4
id> x
No contact with that id.

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 4
id> 7
No contact with that id.

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 4
id> 1
name (Enter keeps Ann Lee)> 
phone (Enter keeps 0912 345 678)> 123
A phone number has 7 to 15 digits; this one has 3.

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 5
id> 0
No contact with that id.

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 2

-- A to Z: 1 --
  #1   Ann Lee               0912 345 678

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 6
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
Wen Chen
0912-345-678
1
ann lee
+886 2 2345 6789
1
Bob Marsh
02-8765-4321
1
Carla Diaz
0933 222 111
2
3
an
3
0912 345
3
23
4
1
Anna Wen

4
3

02-8765-0000
5
4
2
6
```

Screen:

```text

== Contacts (0) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Wen Chen
phone> 0912-345-678
Added #1: Wen Chen.

== Contacts (1) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> ann lee
phone> +886 2 2345 6789
Added #2: ann lee.

== Contacts (2) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Bob Marsh
phone> 02-8765-4321
Added #3: Bob Marsh.

== Contacts (3) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 1
name> Carla Diaz
phone> 0933 222 111
Added #4: Carla Diaz.

== Contacts (4) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 2

-- A to Z: 4 --
  #2   ann lee               +886 2 2345 6789
  #3   Bob Marsh             02-8765-4321
  #4   Carla Diaz            0933 222 111
  #1   Wen Chen              0912-345-678

== Contacts (4) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 3
part of a name or number> an

-- found: 1 --
  #2   ann lee               +886 2 2345 6789

== Contacts (4) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 3
part of a name or number> 0912 345

-- found: 1 --
  #1   Wen Chen              0912-345-678

== Contacts (4) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 3
part of a name or number> 23

-- found: 2 --
  #2   ann lee               +886 2 2345 6789
  #1   Wen Chen              0912-345-678

== Contacts (4) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 4
id> 1
name (Enter keeps Wen Chen)> Anna Wen
phone (Enter keeps 0912-345-678)> 
Updated #1.

== Contacts (4) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 4
id> 3
name (Enter keeps Bob Marsh)> 
phone (Enter keeps 02-8765-4321)> 02-8765-0000
Updated #3.

== Contacts (4) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 5
id> 4
Deleted #4: Carla Diaz.

== Contacts (3) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 2

-- A to Z: 3 --
  #2   ann lee               +886 2 2345 6789
  #1   Anna Wen              0912-345-678
  #3   Bob Marsh             02-8765-0000

== Contacts (3) ==
1) add  2) list  3) find  4) edit  5) delete  6) quit
choice> 6
Bye.
```

## Modules

### main.eml

```eml
# P004 contacts: add contacts, find them by part of a name or part of a
# number, edit and delete them; the book is always shown in name order. The
# contacts live while the program runs; the first twenty projects do not
# save to files.
import book
import text

def show(title, contacts):
    "" ^0
    ("-- " + title + ": " + str(len(contacts)) + " --") ^0
    if len(contacts) == 0:
        "  (nothing here)" ^0
    for c in contacts:
        ("  " + ("%-5s" % ("#" + str(c[0]))) + ("%-22s" % c[1]) + c[2]) ^0

def ask_id(contacts):
    # The contact whose id is typed, or None once the problem has been shown.
    text.trim(input("id> ")) => typed
    None => c
    if typed != "" and text.digits_of(typed) == typed:
        book.find(contacts, int(typed)) => c
    if c == None:
        "No contact with that id." ^0
    return c

def name_problem(contacts, name, own_id):
    # What is wrong with name for a contact, or "" if nothing is. own_id is
    # the contact being edited (0 for a new one): it may keep its own name.
    if name == "":
        return "A contact needs a name."
    if len(name) > 30:
        return "A name has at most 30 characters."
    book.named(contacts, name) => other
    if other != None and other[0] != own_id:
        return "There is already a contact named " + other[1] + "."
    return ""

[] => contacts
1 => next_id
True => running
while running:
    "" ^0
    ("== Contacts (" + str(len(contacts)) + ") ==") ^0
    "1) add  2) list  3) find  4) edit  5) delete  6) quit" ^0
    text.trim(input("choice> ")) => choice
    if choice == "1":
        text.trim(input("name> ")) => name
        name_problem(contacts, name, 0) => problem
        if problem != "":
            problem ^0
        else:
            text.trim(input("phone> ")) => phone
            text.phone_problem(phone) => problem
            if problem != "":
                problem ^0
            else:
                book.insert(contacts, [next_id, name, phone]) => contacts
                ("Added #" + str(next_id) + ": " + name + ".") ^0
                next_id + 1 => next_id
    elif choice == "2":
        show("A to Z", contacts)
    elif choice == "3":
        text.trim(input("part of a name or number> ")) => fragment
        if fragment == "":
            "Type part of a name or a number." ^0
        else:
            show("found", book.search(contacts, fragment))
    elif choice == "4":
        ask_id(contacts) => c
        if c != None:
            text.trim(input("name (Enter keeps " + c[1] + ")> ")) => name
            if name == "":
                c[1] => name
            name_problem(contacts, name, c[0]) => problem
            if problem != "":
                problem ^0
            else:
                text.trim(input("phone (Enter keeps " + c[2] + ")> ")) => phone
                if phone == "":
                    c[2] => phone
                text.phone_problem(phone) => problem
                if problem != "":
                    problem ^0
                else:
                    # Out and back in, so a new name lands in its place.
                    book.insert(book.remove(contacts, c[0]), [c[0], name, phone]) => contacts
                    ("Updated #" + str(c[0]) + ".") ^0
    elif choice == "5":
        ask_id(contacts) => c
        if c != None:
            book.remove(contacts, c[0]) => contacts
            ("Deleted #" + str(c[0]) + ": " + c[1] + ".") ^0
    elif choice == "6":
        False => running
    else:
        "Pick a number from 1 to 6." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import book
import text

def show(title, contacts):
    print("")
    print("-- " + title + ": " + str(len(contacts)) + " --")
    if len(contacts) == 0:
        print("  (nothing here)")
    for c in contacts:
        print("  " + "%-5s" % ("#" + str(c[0])) + "%-22s" % c[1] + c[2])

def ask_id(contacts):
    typed = text.trim(input("id> "))
    c = None
    if typed != "" and text.digits_of(typed) == typed:
        c = book.find(contacts, int(typed))
    if c == None:
        print("No contact with that id.")
    return c

def name_problem(contacts, name, own_id):
    if name == "":
        return "A contact needs a name."
    if len(name) > 30:
        return "A name has at most 30 characters."
    other = book.named(contacts, name)
    if other != None and other[0] != own_id:
        return "There is already a contact named " + other[1] + "."
    return ""

contacts = []
next_id = 1
running = True
while running:
    print("")
    print("== Contacts (" + str(len(contacts)) + ") ==")
    print("1) add  2) list  3) find  4) edit  5) delete  6) quit")
    choice = text.trim(input("choice> "))
    if choice == "1":
        name = text.trim(input("name> "))
        problem = name_problem(contacts, name, 0)
        if problem != "":
            print(problem)
        else:
            phone = text.trim(input("phone> "))
            problem = text.phone_problem(phone)
            if problem != "":
                print(problem)
            else:
                contacts = book.insert(contacts, [next_id, name, phone])
                print("Added #" + str(next_id) + ": " + name + ".")
                next_id = next_id + 1
    elif choice == "2":
        show("A to Z", contacts)
    elif choice == "3":
        fragment = text.trim(input("part of a name or number> "))
        if fragment == "":
            print("Type part of a name or a number.")
        else:
            show("found", book.search(contacts, fragment))
    elif choice == "4":
        c = ask_id(contacts)
        if c != None:
            name = text.trim(input("name (Enter keeps " + c[1] + ")> "))
            if name == "":
                name = c[1]
            problem = name_problem(contacts, name, c[0])
            if problem != "":
                print(problem)
            else:
                phone = text.trim(input("phone (Enter keeps " + c[2] + ")> "))
                if phone == "":
                    phone = c[2]
                problem = text.phone_problem(phone)
                if problem != "":
                    print(problem)
                else:
                    contacts = book.insert(book.remove(contacts, c[0]), [c[0], name, phone])
                    print("Updated #" + str(c[0]) + ".")
    elif choice == "5":
        c = ask_id(contacts)
        if c != None:
            contacts = book.remove(contacts, c[0])
            print("Deleted #" + str(c[0]) + ": " + c[1] + ".")
    elif choice == "6":
        running = False
    else:
        print("Pick a number from 1 to 6.")
print("Bye.")
```

### book.eml

```eml
# P004 contacts - the contact book. A contact is a list [id, name, phone].
# The book is kept in name order, ignoring case, and a new or renamed contact
# goes in at the place a binary search finds, so the list never needs sorting.
# Two contacts cannot share a name that differs only in case. Ids are never
# reused.
import text

def position(book, name):
    # Where a contact with this name belongs: the index of the first contact
    # whose name, ignoring case, is not before it. Binary search: each step
    # halves the part of the list the answer can still be in.
    text.lower(name) => key
    0 => lo
    len(book) => hi
    while lo < hi:
        int((lo + hi) / 2) => mid
        if text.lower(book[mid][1]) < key:
            mid + 1 => lo
        else:
            mid => hi
    return lo

def named(book, name):
    # The contact with this name, ignoring case, or None.
    position(book, name) => p
    if p < len(book) and text.lower(book[p][1]) == text.lower(name):
        return book[p]
    return None

def insert(book, contact):
    # A new book with the contact in its place.
    position(book, contact[1]) => p
    return book[0:p] + [contact] + book[p:len(book)]

def find(book, contact_id):
    for c in book:
        if c[0] == contact_id:
            return c
    return None

def remove(book, contact_id):
    return [c for c in book if c[0] != contact_id]

def search(book, fragment):
    # Contacts matching part of a name or part of a number. A fragment with a
    # digit in it is looked for in the numbers, digits only - "0912 345"
    # finds 0912-345-678 - and any other fragment in the names, ignoring case.
    text.digits_of(fragment) => digits
    [] => found
    for c in book:
        if digits != "":
            if digits in text.digits_of(c[2]):
                found + [c] => found
        elif text.lower(fragment) in text.lower(c[1]):
            found + [c] => found
    return found
```

Python projection of book.eml:

```python
import text

def position(book, name):
    key = text.lower(name)
    lo = 0
    hi = len(book)
    while lo < hi:
        mid = int((lo + hi) / 2)
        if text.lower(book[mid][1]) < key:
            lo = mid + 1
        else:
            hi = mid
    return lo

def named(book, name):
    p = position(book, name)
    if p < len(book) and text.lower(book[p][1]) == text.lower(name):
        return book[p]
    return None

def insert(book, contact):
    p = position(book, contact[1])
    return book[0:p] + [contact] + book[p:len(book)]

def find(book, contact_id):
    for c in book:
        if c[0] == contact_id:
            return c
    return None

def remove(book, contact_id):
    return [c for c in book if c[0] != contact_id]

def search(book, fragment):
    digits = text.digits_of(fragment)
    found = []
    for c in book:
        if digits != "":
            if digits in text.digits_of(c[2]):
                found = found + [c]
        elif text.lower(fragment) in text.lower(c[1]):
            found = found + [c]
    return found
```

### text.eml

```eml
# P004 contacts - text helpers. The interpreter that checks every session
# does not run string methods yet, so these are written out.

def trim(s):
    # s without the spaces at either end.
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def lower(s):
    # s with A-Z turned into a-z; everything else as it was.
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" => upper_letters
    "abcdefghijklmnopqrstuvwxyz" => lower_letters
    "" => out
    for c in s:
        c => d
        for i in [0:25]:
            if upper_letters[i] == c:
                lower_letters[i] => d
        out + d => out
    return out

def digits_of(s):
    # Only the digits of s, in order.
    "" => out
    for c in s:
        if c in "0123456789":
            out + c => out
    return out

def phone_problem(phone):
    # What is wrong with phone as a phone number, or "" if nothing is: digits,
    # spaces and dashes, a + only at the very start, and 7 to 15 digits.
    if phone == "":
        return "Type a phone number."
    0 => i
    for c in phone:
        if c == "+":
            if i != 0:
                return "A + can only come first."
        elif not (c in "0123456789 -"):
            return "A phone number has only digits, spaces and dashes (and a + first)."
        i + 1 => i
    len(digits_of(phone)) => n
    if n < 7 or n > 15:
        return "A phone number has 7 to 15 digits; this one has " + str(n) + "."
    return ""
```

Python projection of text.eml:

```python
def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def lower(s):
    upper_letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    lower_letters = "abcdefghijklmnopqrstuvwxyz"
    out = ""
    for c in s:
        d = c
        for i in range(0, 26):
            if upper_letters[i] == c:
                d = lower_letters[i]
        out = out + d
    return out

def digits_of(s):
    out = ""
    for c in s:
        if c in "0123456789":
            out = out + c
    return out

def phone_problem(phone):
    if phone == "":
        return "Type a phone number."
    i = 0
    for c in phone:
        if c == "+":
            if i != 0:
                return "A + can only come first."
        elif not c in "0123456789 -":
            return "A phone number has only digits, spaces and dashes (and a + first)."
        i = i + 1
    n = len(digits_of(phone))
    if n < 7 or n > 15:
        return "A phone number has 7 to 15 digits; this one has " + str(n) + "."
    return ""
```

## README

# P004 - Contacts

Adds contacts with a name and a phone number, finds them by part of a name or
part of a number, edits and deletes them. The book is always in name order,
ignoring case. A text menu; the contacts live while the program runs.

- `main.eml` - the menu loop and the checks on what is typed
- `book.eml` - the contact book: a binary search finds where a contact
  belongs, so a new or renamed contact goes straight into its place and the
  list never needs sorting; the same search finds a name that is already there
- `text.eml` - trimming, lower case, the digits of a number, and the phone check

Finding: a fragment with a digit in it is looked for in the numbers, by
digits only, so `0912 345` finds 0912-345-678; any other fragment is looked
for in the names, ignoring case.

What is checked: a name is not empty, has at most 30 characters and is not
already in the book in any case; a phone number has only digits, spaces and
dashes, a + only at the start, and 7 to 15 digits. When editing, Enter keeps
the current name or number, and an edit that fails a check changes nothing.

Sessions: `sessions/basic.in` adds four contacts out of order, lists them,
finds by a name fragment, a spaced number fragment and a fragment two numbers
share, renames one (it moves to its new place), changes a number and deletes
one; `sessions/bad-input.in` types an unknown choice, an empty search, an
empty and a too-long name, phone numbers with letters, too few and too many
digits and a + in the middle, a name that differs only in case, ids that are
not there, and an edit with a bad number.

Built on the verified corpus cases `contact-book` (contacts kept and looked
up by name) and `binary-search` (halving the part of a sorted list the answer
can still be in).
