<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P126-outline-writer/ | updated: 2026-10-17 -->

# P126 Outline writer

An outline written as an indented triple-quoted string, read into a tree and written out as bullets, numbers and wrapped lines with a hanging indent; cut to a depth, a with block keeps the indentation where keeping it by hand drifts.

EML-P project `projects/outline-writer` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/outline-writer` runs it and `eml project verify projects/outline-writer` 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: indented-report-writer (https://efficientnewlanguage.org/cases/144-indented-report-writer/), multiline-help-text (https://efficientnewlanguage.org/cases/146-multiline-help-text/).

## Sessions

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

Input:

```text
0
x
4

4
19
4
81
4
x
5

5
4
5
-1
6

6
3
6
1.9
6
1..2
6
a
6
1.1.1.1
6
1

6
1
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
6
1.1.1
Sub-step
5
3
7
```

Screen:

```text
== Outline writer ==
An outline written as an indented string, read into a tree, and written out as bullets, as numbers, and wrapped to a width.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 0
Pick a number from 1 to 7.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> x
Pick a number from 1 to 7.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 4
width (20 to 80)> 
Cancelled.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 4
width (20 to 80)> 19
Type a whole number from 20 to 80.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 4
width (20 to 80)> 81
Type a whole number from 20 to 80.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 4
width (20 to 80)> x
Type a whole number from 20 to 80.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 5
depth (0 to 3)> 
Cancelled.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 5
depth (0 to 3)> 4
Type a whole number from 0 to 3.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 5
depth (0 to 3)> -1
Type a whole number from 0 to 3.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 
Cancelled.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 3
Type the number of an item, such as 1 or 1.2, or 0.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 1.9
Type the number of an item, such as 1 or 1.2, or 0.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 1..2
Type the number of an item, such as 1 or 1.2, or 0.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> a
Type the number of an item, such as 1 or 1.2, or 0.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 1.1.1.1
Type the number of an item, such as 1 or 1.2, or 0.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 1
text> 
Cancelled.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 1
text> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Up to 120 characters.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 1.1.1
text> Sub-step
Added under 1.1.1: Sub-step

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 5
depth (0 to 3)> 3
With a with block:
Release plan
  Build
    Compile the interpreter and the transpiler for every platform we ship
      Sub-step
    Run the whole test suite
  Check
    Read every golden line by line
    Cross-check each claim a second way, written separately
  Publish
    Build the site
    Deploy
Afterwards
  Write the journal
Keeping the depth by hand, an early return skipping the step back:
Release plan
  Build
    Compile the interpreter and the transpiler for every platform we ship
      Sub-step
    Run the whole test suite
  Check
    Read every golden line by line
    Cross-check each claim a second way, written separately
  Publish
    Build the site
    Deploy
Afterwards
  Write the journal
The two agree: nothing was cut, so no early return happened.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 7
Bye.
```

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

Input:

```text
1
2
3
4
40
4
20
5
1
5
3
6
1.3
Tell Splice
6
0
Rest
3
7
```

Screen:

```text
== Outline writer ==
An outline written as an indented string, read into a tree, and written out as bullets, as numbers, and wrapped to a width.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 1
The source string has 347 characters in 14 lines (| marks where each line starts):
   1 |
   2 |    Release plan
   3 |      Build
   4 |        Compile the interpreter and the transpiler for every platform we ship
   5 |        Run the whole test suite
   6 |      Check
   7 |        Read every golden line by line
   8 |        Cross-check each claim a second way, written separately
   9 |      Publish
  10 |        Build the site
  11 |        Deploy
  12 |    Afterwards
  13 |      Write the journal
  14 |
Reading it: 2 blank lines at the ends dropped (the empty line right after the opening quotes, and the empty one before the closing quotes), and the 4 spaces every line shares taken off.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 2
- Release plan
  * Build
    + Compile the interpreter and the transpiler for every platform we ship
    + Run the whole test suite
  * Check
    + Read every golden line by line
    + Cross-check each claim a second way, written separately
  * Publish
    + Build the site
    + Deploy
- Afterwards
  * Write the journal

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 3
1 Release plan
  1.1 Build
    1.1.1 Compile the interpreter and the transpiler for every platform we ship
    1.1.2 Run the whole test suite
  1.2 Check
    1.2.1 Read every golden line by line
    1.2.2 Cross-check each claim a second way, written separately
  1.3 Publish
    1.3.1 Build the site
    1.3.2 Deploy
2 Afterwards
  2.1 Write the journal

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 4
width (20 to 80)> 40
Wrapped at 40, each line after the first lined up under its text:
+----------------------------------------+
1 Release plan
  1.1 Build
    1.1.1 Compile the interpreter and
          the transpiler for every
          platform we ship
    1.1.2 Run the whole test suite
  1.2 Check
    1.2.1 Read every golden line by line
    1.2.2 Cross-check each claim a
          second way, written separately
  1.3 Publish
    1.3.1 Build the site
    1.3.2 Deploy
2 Afterwards
  2.1 Write the journal
+----------------------------------------+

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 4
width (20 to 80)> 20
Wrapped at 20, each line after the first lined up under its text:
+--------------------+
1 Release plan
  1.1 Build
    1.1.1 Compile
          the
          interpreter   <- past the width
          and the
          transpiler
          for every
          platform
          we ship
    1.1.2 Run the
          whole test
          suite
  1.2 Check
    1.2.1 Read every
          golden
          line by
          line
    1.2.2 Cross-check   <- past the width
          each claim
          a second
          way,
          written
          separately
  1.3 Publish
    1.3.1 Build the
          site
    1.3.2 Deploy
2 Afterwards
  2.1 Write the
      journal
+--------------------+
2 lines run past the width: words longer than the room left beside their numbers.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 5
depth (0 to 3)> 1
With a with block:
Release plan
  Build
    (2 more)
  Check
    (2 more)
  Publish
    (2 more)
Afterwards
  Write the journal
Keeping the depth by hand, an early return skipping the step back:
Release plan
  Build
    (2 more)
    Check
      (2 more)
      Publish
        (2 more)
      Afterwards
        Write the journal
6 of the 9 lines differ: each early return leaves every later line by hand one more step too far in.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 5
depth (0 to 3)> 3
With a with block:
Release plan
  Build
    Compile the interpreter and the transpiler for every platform we ship
    Run the whole test suite
  Check
    Read every golden line by line
    Cross-check each claim a second way, written separately
  Publish
    Build the site
    Deploy
Afterwards
  Write the journal
Keeping the depth by hand, an early return skipping the step back:
Release plan
  Build
    Compile the interpreter and the transpiler for every platform we ship
    Run the whole test suite
  Check
    Read every golden line by line
    Cross-check each claim a second way, written separately
  Publish
    Build the site
    Deploy
Afterwards
  Write the journal
The two agree: nothing was cut, so no early return happened.

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 1.3
text> Tell Splice
Added under 1.3: Tell Splice

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 6
under (an item's number such as 1.2, or 0 for the top)> 0
text> Rest
Added at the top: Rest

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 3
1 Release plan
  1.1 Build
    1.1.1 Compile the interpreter and the transpiler for every platform we ship
    1.1.2 Run the whole test suite
  1.2 Check
    1.2.1 Read every golden line by line
    1.2.2 Cross-check each claim a second way, written separately
  1.3 Publish
    1.3.1 Build the site
    1.3.2 Deploy
    1.3.3 Tell Splice
2 Afterwards
  2.1 Write the journal
3 Rest

1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit
choice> 7
Bye.
```

## Modules

### main.eml

```eml
# P126 outline writer: the outline's source string, bullets, numbers,
# wrapping to a width, cutting to a depth with and without a with block,
# and adding items.
import outline

def plain(s):
    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 lpad(s, n):
    s => out
    while len(out) < n:
        " " + out => out
    return out

def say(lines):
    for s in lines:
        s ^0

def show_source():
    outline.SOURCE => s
    outline.split_lines(s) => lines
    ("The source string has " + str(len(s)) + " characters in " + str(len(lines)) + " lines (| marks where each line starts):") ^0
    for k in [0:len(lines) - 1]:
        ("  " + lpad(str(k + 1), 2) + " |" + lines[k]) ^0
    outline.dedent(lines) => d
    ("Reading it: " + str(d[1]) + " blank lines at the ends dropped (the empty line right after the opening quotes, and the empty one before the closing quotes), and the " + str(d[2]) + " spaces every line shares taken off.") ^0

def tree_of():
    outline.dedent(outline.split_lines(outline.SOURCE))[0] => lines
    return outline.parse(lines)[0]

def ask_int(prompt, low, high):
    plain(input(prompt)) => t
    if t == "":
        "Cancelled." ^0
        return 0 - 1
    if len(t) > 2:
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
        return 0 - 1
    for c in t:
        if c < "0" or c > "9":
            ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
            return 0 - 1
    if int(t) < low or int(t) > high:
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
        return 0 - 1
    return int(t)

def wrapped(tree):
    ask_int("width (20 to 80)> ", 20, 80) => w
    if w < 0:
        return
    outline.numbered(tree, w) => lines
    ("Wrapped at " + str(w) + ", each line after the first lined up under its text:") ^0
    "+" + "-" * w + "+" => ruler
    ruler ^0
    0 => over
    for s in lines:
        if len(s) > w:
            over + 1 => over
            (s + "   <- past the width") ^0
        else:
            s ^0
    ruler ^0
    if over == 1:
        "1 line runs past the width: a word longer than the room left beside its number." ^0
    elif over > 1:
        (str(over) + " lines run past the width: words longer than the room left beside their numbers.") ^0

def cut(tree):
    ask_int("depth (0 to 3)> ", 0, 3) => d
    if d < 0:
        return
    outline.to_depth(tree, d, True) => a
    outline.to_depth(tree, d, False) => b
    "With a with block:" ^0
    say(a)
    "Keeping the depth by hand, an early return skipping the step back:" ^0
    say(b)
    0 => differ
    for k in [0:len(a) - 1]:
        if a[k] != b[k]:
            differ + 1 => differ
    if differ == 0:
        "The two agree: nothing was cut, so no early return happened." ^0
    else:
        (str(differ) + " of the " + str(len(a)) + " lines differ: each early return leaves every later line by hand one more step too far in.") ^0

def find_path(tree, t):
    # the list of children under a path such as 1.2, or [] with ok False
    if t == "0":
        return [True, []]
    [] => nums
    "" => w
    for c in t + ".":
        if c == ".":
            if w == "" or len(w) > 2:
                return [False, []]
            nums + [int(w)] => nums
            "" => w
        elif c >= "0" and c <= "9":
            w + c => w
        else:
            return [False, []]
    tree => level
    for n in nums:
        if n < 1 or n > len(level):
            return [False, []]
        level[n - 1][1] => level
    return [True, nums]

def add_item(tree):
    plain(input("under (an item's number such as 1.2, or 0 for the top)> ")) => t
    if t == "":
        "Cancelled." ^0
        return tree
    find_path(tree, t) => p
    if not p[0]:
        "Type the number of an item, such as 1 or 1.2, or 0." ^0
        return tree
    plain(input("text> ")) => text
    if text == "":
        "Cancelled." ^0
        return tree
    if len(text) > 120:
        "Up to 120 characters." ^0
        return tree
    add_at(tree, p[1], text) => out
    if len(p[1]) == 0:
        ("Added at the top: " + text) ^0
    else:
        ("Added under " + t + ": " + text) ^0
    return out

def add_at(level, nums, text):
    if len(nums) == 0:
        return level + [[text, []]]
    [] => out
    for i in [0:len(level) - 1]:
        if i == nums[0] - 1:
            out + [[level[i][0], add_at(level[i][1], nums[1:len(nums)], text)]] => out
        else:
            out + [level[i]] => out
    return out

tree_of() => tree

"== Outline writer ==" ^0
"An outline written as an indented string, read into a tree, and written out as bullets, as numbers, and wrapped to a width." ^0
True => running
while running:
    "" ^0
    "1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show_source()
    elif choice == "2":
        say(outline.bullets(tree))
    elif choice == "3":
        say(outline.numbered(tree, 0))
    elif choice == "4":
        wrapped(tree)
    elif choice == "5":
        cut(tree)
    elif choice == "6":
        add_item(tree) => tree
    elif choice == "7":
        False => running
    else:
        "Pick a number from 1 to 7." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import outline

def plain(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 lpad(s, n):
    out = s
    while len(out) < n:
        out = " " + out
    return out

def say(lines):
    for s in lines:
        print(s)

def show_source():
    s = outline.SOURCE
    lines = outline.split_lines(s)
    print("The source string has " + str(len(s)) + " characters in " + str(len(lines)) + " lines (| marks where each line starts):")
    for k in range(0, len(lines)):
        print("  " + lpad(str(k + 1), 2) + " |" + lines[k])
    d = outline.dedent(lines)
    print("Reading it: " + str(d[1]) + " blank lines at the ends dropped (the empty line right after the opening quotes, and the empty one before the closing quotes), and the " + str(d[2]) + " spaces every line shares taken off.")

def tree_of():
    lines = outline.dedent(outline.split_lines(outline.SOURCE))[0]
    return outline.parse(lines)[0]

def ask_int(prompt, low, high):
    t = plain(input(prompt))
    if t == "":
        print("Cancelled.")
        return 0 - 1
    if len(t) > 2:
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")
        return 0 - 1
    for c in t:
        if c < "0" or c > "9":
            print("Type a whole number from " + str(low) + " to " + str(high) + ".")
            return 0 - 1
    if int(t) < low or int(t) > high:
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")
        return 0 - 1
    return int(t)

def wrapped(tree):
    w = ask_int("width (20 to 80)> ", 20, 80)
    if w < 0:
        return
    lines = outline.numbered(tree, w)
    print("Wrapped at " + str(w) + ", each line after the first lined up under its text:")
    ruler = "+" + "-" * w + "+"
    print(ruler)
    over = 0
    for s in lines:
        if len(s) > w:
            over = over + 1
            print(s + "   <- past the width")
        else:
            print(s)
    print(ruler)
    if over == 1:
        print("1 line runs past the width: a word longer than the room left beside its number.")
    elif over > 1:
        print(str(over) + " lines run past the width: words longer than the room left beside their numbers.")

def cut(tree):
    d = ask_int("depth (0 to 3)> ", 0, 3)
    if d < 0:
        return
    a = outline.to_depth(tree, d, True)
    b = outline.to_depth(tree, d, False)
    print("With a with block:")
    say(a)
    print("Keeping the depth by hand, an early return skipping the step back:")
    say(b)
    differ = 0
    for k in range(0, len(a)):
        if a[k] != b[k]:
            differ = differ + 1
    if differ == 0:
        print("The two agree: nothing was cut, so no early return happened.")
    else:
        print(str(differ) + " of the " + str(len(a)) + " lines differ: each early return leaves every later line by hand one more step too far in.")

def find_path(tree, t):
    if t == "0":
        return [True, []]
    nums = []
    w = ""
    for c in t + ".":
        if c == ".":
            if w == "" or len(w) > 2:
                return [False, []]
            nums = nums + [int(w)]
            w = ""
        elif c >= "0" and c <= "9":
            w = w + c
        else:
            return [False, []]
    level = tree
    for n in nums:
        if n < 1 or n > len(level):
            return [False, []]
        level = level[n - 1][1]
    return [True, nums]

def add_item(tree):
    t = plain(input("under (an item's number such as 1.2, or 0 for the top)> "))
    if t == "":
        print("Cancelled.")
        return tree
    p = find_path(tree, t)
    if not p[0]:
        print("Type the number of an item, such as 1 or 1.2, or 0.")
        return tree
    text = plain(input("text> "))
    if text == "":
        print("Cancelled.")
        return tree
    if len(text) > 120:
        print("Up to 120 characters.")
        return tree
    out = add_at(tree, p[1], text)
    if len(p[1]) == 0:
        print("Added at the top: " + text)
    else:
        print("Added under " + t + ": " + text)
    return out

def add_at(level, nums, text):
    if len(nums) == 0:
        return level + [[text, []]]
    out = []
    for i in range(0, len(level)):
        if i == nums[0] - 1:
            out = out + [[level[i][0], add_at(level[i][1], nums[1:len(nums)], text)]]
        else:
            out = out + [level[i]]
    return out

tree = tree_of()
print("== Outline writer ==")
print("An outline written as an indented string, read into a tree, and written out as bullets, as numbers, and wrapped to a width.")
running = True
while running:
    print("")
    print("1) source  2) bullets  3) numbered  4) wrapped  5) to a depth  6) add an item  7) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show_source()
    elif choice == "2":
        say(outline.bullets(tree))
    elif choice == "3":
        say(outline.numbered(tree, 0))
    elif choice == "4":
        wrapped(tree)
    elif choice == "5":
        cut(tree)
    elif choice == "6":
        tree = add_item(tree)
    elif choice == "7":
        running = False
    else:
        print("Pick a number from 1 to 7.")
print("Bye.")
```

### outline.eml

```eml
# P126 outline writer - an outline written as an indented triple-quoted
# string, read into a tree, and written out as bullets, as numbered items,
# and wrapped to a width with a hanging indent. As in the corpus case
# multiline-help-text, the string keeps the newline after its opening
# quotes and the indentation of its lines, and both have to be taken off.
# As in indented-report-writer, the depth of the output is kept by a with
# block, so it comes back down on every way out, early returns included.
#
# A node is [text, children].

"""
    Release plan
      Build
        Compile the interpreter and the transpiler for every platform we ship
        Run the whole test suite
      Check
        Read every golden line by line
        Cross-check each claim a second way, written separately
      Publish
        Build the site
        Deploy
    Afterwards
      Write the journal
""" => SOURCE

def split_lines(text):
    [] => out
    "" => cur
    for ch in text:
        if ch == "\n":
            out + [cur] => out
            "" => cur
        else:
            cur + ch => cur
    return out + [cur]

def lead(s):
    0 => n
    while n < len(s) and s[n] == " ":
        n + 1 => n
    return n

def blank(s):
    return lead(s) == len(s)

def dedent(lines):
    # [lines with blank ones at either end dropped, the common indentation
    #  taken off; how many blank lines were dropped; the indentation]
    0 => a
    len(lines) => b
    while a < b and blank(lines[a]):
        a + 1 => a
    while b > a and blank(lines[b - 1]):
        b - 1 => b
    0 - 1 => common
    for k in [a:b - 1]:
        if not blank(lines[k]):
            if common < 0 or lead(lines[k]) < common:
                lead(lines[k]) => common
    [] => out
    for k in [a:b - 1]:
        if blank(lines[k]):
            out + [""] => out
        else:
            out + [lines[k][common:len(lines[k])]] => out
    return [out, a + len(lines) - b, max2(common, 0)]

def max2(x, y):
    if x > y:
        return x
    return y

def parse(lines):
    # [tree, ""] or [[], what is wrong]; each level is 2 more spaces than
    # the one above
    [] => roots
    [] => stack
    for k in [0:len(lines) - 1]:
        lines[k] => s
        if not blank(s):
            lead(s) => n
            if n % 2 == 1:
                return [[], "line " + str(k + 1) + " is indented by an odd number of spaces"]
            n / 2 => d
            int(d) => d
            if d > len(stack):
                return [[], "line " + str(k + 1) + " is indented more than one level deeper than the line above"]
            [s[n:len(s)], []] => node
            stack[0:d] => stack
            if d == 0:
                roots + [node] => roots
            else:
                stack[d - 1][1] + [node] => stack[d - 1][1]
            stack + [node] => stack
    return [roots, ""]

class Indent:
    def __init__(self, writer):
        writer => self.writer
    def __enter__(self):
        self.writer.depth + 1 => self.writer.depth
        return self.writer
    def __exit__(self, exc_type, exc_value, tb):
        self.writer.depth - 1 => self.writer.depth
        return False

class Writer:
    def __init__(self):
        0 => self.depth
        [] => self.lines
    def line(self, text):
        self.lines + ["  " * self.depth + text] => self.lines
    def block(self):
        return Indent(self)

def bullets(tree):
    Writer() => w
    for node in tree:
        bullet(w, node)
    return w.lines

def bullet(w, node):
    ["-", "*", "+"] => marks
    w.line(marks[w.depth % 3] + " " + node[0])
    for child in node[1]:
        with w.block():
            bullet(w, child)

def numbered(tree, width):
    # numbered items, wrapped to width (0 for no wrapping), each line after
    # the first lined up under the text
    [] => out
    for i in [0:len(tree) - 1]:
        number(tree[i], str(i + 1), 0, width) => more
        out + more => out
    return out

def number(node, label, depth, width):
    "  " * depth + label + " " => head
    wrap(node[0], width - len(head)) => parts
    [head + parts[0]] => out
    for k in [1:len(parts) - 1]:
        out + [" " * len(head) + parts[k]] => out
    for i in [0:len(node[1]) - 1]:
        number(node[1][i], label + "." + str(i + 1), depth + 1, width) => more
        out + more => out
    return out

def wrap(text, room):
    # the text in lines of at most room characters, a word longer than that
    # on a line of its own; room 0 or less: one line
    if room <= 0:
        return [text]
    [] => out
    "" => cur
    "" => word
    for ch in text + " ":
        if ch == " ":
            if word != "":
                if cur == "":
                    word => cur
                elif len(cur) + 1 + len(word) <= room:
                    cur + " " + word => cur
                else:
                    out + [cur] => out
                    word => cur
            "" => word
        else:
            word + ch => word
    return out + [cur]

def to_depth(tree, most, with_block):
    # the outline to a depth, the children below it summed up; with_block
    # False keeps the depth by hand, and an early return skips the step back
    Writer() => w
    for node in tree:
        if with_block:
            cut_with(w, node, 0, most)
        else:
            cut_by_hand(w, node, 0, most)
    return w.lines

def cut_with(w, node, depth, most):
    w.line(node[0])
    with w.block():
        for child in node[1]:
            if depth + 1 > most:
                w.line("(" + str(len(node[1])) + " more)")
                return
            cut_with(w, child, depth + 1, most)

def cut_by_hand(w, node, depth, most):
    w.line(node[0])
    w.depth + 1 => w.depth
    for child in node[1]:
        if depth + 1 > most:
            w.line("(" + str(len(node[1])) + " more)")
            return
        cut_by_hand(w, child, depth + 1, most)
    w.depth - 1 => w.depth
```

Python projection of outline.eml:

```python
SOURCE = "\n    Release plan\n      Build\n        Compile the interpreter and the transpiler for every platform we ship\n        Run the whole test suite\n      Check\n        Read every golden line by line\n        Cross-check each claim a second way, written separately\n      Publish\n        Build the site\n        Deploy\n    Afterwards\n      Write the journal\n"

def split_lines(text):
    out = []
    cur = ""
    for ch in text:
        if ch == "\n":
            out = out + [cur]
            cur = ""
        else:
            cur = cur + ch
    return out + [cur]

def lead(s):
    n = 0
    while n < len(s) and s[n] == " ":
        n = n + 1
    return n

def blank(s):
    return lead(s) == len(s)

def dedent(lines):
    a = 0
    b = len(lines)
    while a < b and blank(lines[a]):
        a = a + 1
    while b > a and blank(lines[b - 1]):
        b = b - 1
    common = 0 - 1
    for k in range(a, b):
        if not blank(lines[k]):
            if common < 0 or lead(lines[k]) < common:
                common = lead(lines[k])
    out = []
    for k in range(a, b):
        if blank(lines[k]):
            out = out + [""]
        else:
            out = out + [lines[k][common:len(lines[k])]]
    return [out, a + len(lines) - b, max2(common, 0)]

def max2(x, y):
    if x > y:
        return x
    return y

def parse(lines):
    roots = []
    stack = []
    for k in range(0, len(lines)):
        s = lines[k]
        if not blank(s):
            n = lead(s)
            if n % 2 == 1:
                return [[], "line " + str(k + 1) + " is indented by an odd number of spaces"]
            d = n / 2
            d = int(d)
            if d > len(stack):
                return [[], "line " + str(k + 1) + " is indented more than one level deeper than the line above"]
            node = [s[n:len(s)], []]
            stack = stack[0:d]
            if d == 0:
                roots = roots + [node]
            else:
                stack[d - 1][1] = stack[d - 1][1] + [node]
            stack = stack + [node]
    return [roots, ""]

class Indent:
    def __init__(self, writer):
        self.writer = writer
    def __enter__(self):
        self.writer.depth = self.writer.depth + 1
        return self.writer
    def __exit__(self, exc_type, exc_value, tb):
        self.writer.depth = self.writer.depth - 1
        return False

class Writer:
    def __init__(self):
        self.depth = 0
        self.lines = []
    def line(self, text):
        self.lines = self.lines + ["  " * self.depth + text]
    def block(self):
        return Indent(self)

def bullets(tree):
    w = Writer()
    for node in tree:
        bullet(w, node)
    return w.lines

def bullet(w, node):
    marks = ["-", "*", "+"]
    w.line(marks[w.depth % 3] + " " + node[0])
    for child in node[1]:
        with w.block():
            bullet(w, child)

def numbered(tree, width):
    out = []
    for i in range(0, len(tree)):
        more = number(tree[i], str(i + 1), 0, width)
        out = out + more
    return out

def number(node, label, depth, width):
    head = "  " * depth + label + " "
    parts = wrap(node[0], width - len(head))
    out = [head + parts[0]]
    for k in range(1, len(parts)):
        out = out + [" " * len(head) + parts[k]]
    for i in range(0, len(node[1])):
        more = number(node[1][i], label + "." + str(i + 1), depth + 1, width)
        out = out + more
    return out

def wrap(text, room):
    if room <= 0:
        return [text]
    out = []
    cur = ""
    word = ""
    for ch in text + " ":
        if ch == " ":
            if word != "":
                if cur == "":
                    cur = word
                elif len(cur) + 1 + len(word) <= room:
                    cur = cur + " " + word
                else:
                    out = out + [cur]
                    cur = word
            word = ""
        else:
            word = word + ch
    return out + [cur]

def to_depth(tree, most, with_block):
    w = Writer()
    for node in tree:
        if with_block:
            cut_with(w, node, 0, most)
        else:
            cut_by_hand(w, node, 0, most)
    return w.lines

def cut_with(w, node, depth, most):
    w.line(node[0])
    with w.block():
        for child in node[1]:
            if depth + 1 > most:
                w.line("(" + str(len(node[1])) + " more)")
                return
            cut_with(w, child, depth + 1, most)

def cut_by_hand(w, node, depth, most):
    w.line(node[0])
    w.depth = w.depth + 1
    for child in node[1]:
        if depth + 1 > most:
            w.line("(" + str(len(node[1])) + " more)")
            return
        cut_by_hand(w, child, depth + 1, most)
    w.depth = w.depth - 1
```

## README

# P126 - Outline writer

An outline written in the program as an indented triple-quoted string,
read into a tree, and written out as bullets, as numbered items, and
wrapped to a width with each line after the first lined up under its
text.

- `main.eml` - the menu, the source string, the three ways of writing
  the outline, cutting it to a depth, and adding items
- `outline.eml` - the source, splitting and dedenting its lines, reading
  them into a tree, the writer and its with block, and the renderings

How each part works:

- As in the corpus case `multiline-help-text`, a triple-quoted string
  keeps what the source layout put in it: here an empty line after the
  opening quotes, an empty one before the closing quotes, and 4 spaces
  shared by every line. Reading it drops the blank lines at the ends and
  takes the shared spaces off; then each level is 2 more spaces than the
  one above. An odd indentation, or one more than a level deeper, is
  refused with its line number.
- Wrapping puts as many words on a line as fit beside the item's number;
  a word longer than the room left goes on a line of its own and runs
  past the width, and the program counts those lines.
- As in the corpus case `indented-report-writer`, the writer keeps its
  depth with a with block, which steps back on every way out. Cutting the
  outline to a depth returns early under each item whose children are
  cut. Kept by hand instead, each early return skips the step back and
  leaves every later line one more step too far in.

What is checked: menu choices 1 to 7; a width from 20 to 80 and a depth
from 0 to 3; an item's number such as 1.2, or 0 for the top, and a text
of up to 120 characters. An empty answer cancels.

Sessions: `sessions/basic.in` gives:
- the source string, the bullets and the numbered items;
- wrapped at 40, and at 20 (two words longer than the room);
- cut to depth 1, where the depth by hand drifts, and to depth 3, where
  nothing is cut and the two agree;
- Tell Splice added under 1.3 and Rest at the top, and the numbers again.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- widths: a cancel, 19, 81 and x; depths: a cancel, 4 and -1;
- items: a cancel, 3, 1.9, 1..2, a and 1.1.1.1, then under 1 a cancel
  and 121 characters, and Sub-step under 1.1.1, cut to depth 3.

Built on the verified corpus cases `indented-report-writer` (a with block
that keeps the shape of the output) and `multiline-help-text` (what a
triple-quoted string captures from the source layout).
