diff --git a/.github/workflows/bar.yml b/.github/workflows/bar.yml
new file mode 100644
index 0000000..32457ed
--- /dev/null
+++ b/.github/workflows/bar.yml
@@ -0,0 +1,21 @@
+name: verified bar
+
+on:
+ push:
+ pull_request:
+ # Rerun when the spec reviewer approves, or an approval is dismissed.
+ pull_request_review:
+ types: [submitted, dismissed]
+
+jobs:
+ bar:
+ # pull-requests: read lets the check confirm the spec approval on GitHub.
+ permissions:
+ contents: read
+ pull-requests: read
+ # Both lines pin the same commit of Proven-Commons/mission: bar v2, as
+ # proposed in its pull request #2. Move both to a release tag once
+ # mission publishes one.
+ uses: Proven-Commons/mission/.github/workflows/verified-bar.yml@6ba85c27abaaf07905c9316aa96dc29f7b5847a2
+ with:
+ bar-ref: 6ba85c27abaaf07905c9316aa96dc29f7b5847a2
diff --git a/.gitignore b/.gitignore
index 01f8cdb..ae73679 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
.lake/
+__pycache__/
diff --git a/Main.lean b/Main.lean
new file mode 100644
index 0000000..d8bfd04
--- /dev/null
+++ b/Main.lean
@@ -0,0 +1,87 @@
+/-
+ Command line front end, used by the tests.
+
+ Reads one request per line and prints one answer per line. A
+ request is an operation and its input as comma-separated decimal
+ character codes.
+
+ parse 60,97,62,120,60,47,97,62 prints ok:60,97,62,120,60,47,97,62
+ parse 60,97,47,62 prints ok:60,97,47,62
+ parse 120 prints none
+
+ The one operation is `parse`. Its input is the character codes of
+ an XML text in the safe subset. Its answer is `ok:` and the
+ character codes of the canonical print of the parsed document,
+ or `none` when the text is rejected.
+
+ A request is an operation, then optionally one space and its
+ numbers; with no numbers after the space, the input is empty. A
+ malformed request prints `error`: any other line, an unknown
+ operation, a number that is not plain decimal digits, or a
+ character code that is not a Unicode scalar value. A line may end
+ in LF or CR LF; nothing else around a request is ignored.
+
+ The parsing and printing here call `Xml.parse` and `Xml.print`
+ directly. The line handling is covered by the tests, not by a
+ proof.
+-/
+
+import Xml
+
+open Xml
+
+/-- A number in plain decimal digits. Longer numbers than any
+ character code are refused before they are converted, since
+ that takes time quadratic in their length. -/
+def number (t : String) : Option Nat :=
+ let ds := t.toList
+ if ds.isEmpty || !ds.all Char.isDigit || (ds.dropWhile (· = '0')).length > 7 then none
+ else t.toNat?
+
+/-- The comma-separated numbers in `s`. -/
+def numbers (s : String) : Option (List Nat) :=
+ if s.isEmpty then some [] else (s.splitOn ",").mapM number
+
+def toChars (ns : List Nat) : Option (List Char) :=
+ ns.mapM fun n => if n.isValidChar then some (Char.ofNat n) else none
+
+def showCodes (cs : List Char) : String :=
+ ",".intercalate (cs.map fun c => toString c.toNat)
+
+/-- The answer to one request, or `none` when the request is
+ malformed. -/
+def answer (op : String) (ns : List Nat) : Option String :=
+ match op with
+ | "parse" =>
+ match toChars ns with
+ | none => none
+ | some cs =>
+ match parse cs with
+ | some d => some ("ok:" ++ showCodes (print d))
+ | none => some "none"
+ | _ => none
+
+/-- The answer to one line: an operation, then optionally one space
+ and its numbers. -/
+def request (line : String) : Option String :=
+ match line.splitOn " " with
+ | [op] => answer op []
+ | [op, arg] => numbers arg >>= answer op
+ | _ => none
+
+/-- The line without its line ending: LF, or CR LF. -/
+def chomp (line : String) : String :=
+ let cs := line.toList
+ if cs.getLast? = some '\n' then
+ let cs := cs.dropLast
+ String.ofList (if cs.getLast? = some '\r' then cs.dropLast else cs)
+ else line
+
+def main : IO Unit := do
+ let stdin ← IO.getStdin
+ let stdout ← IO.getStdout
+ repeat
+ let line ← stdin.getLine
+ if line.isEmpty then break
+ let line := chomp line
+ stdout.putStrLn ((request line).getD "error")
diff --git a/Proofs.lean b/Proofs.lean
new file mode 100644
index 0000000..2a0b7e9
--- /dev/null
+++ b/Proofs.lean
@@ -0,0 +1,2 @@
+import Proofs.RoundTrip
+import Proofs.SpecProofs
diff --git a/Proofs/RoundTrip.lean b/Proofs/RoundTrip.lean
new file mode 100644
index 0000000..377c90b
--- /dev/null
+++ b/Proofs/RoundTrip.lean
@@ -0,0 +1,1636 @@
+/-
+ The round trip proof: for every well formed document, parsing
+ its printed form gives the document back.
+
+ The statement proved here is `parse_print`. A matching statement
+ in a spec file still has to be added by a reviewer, so this file
+ builds on its own, in the same shape as the proofs of the other
+ slices.
+
+ The proof goes layer by layer: references, text runs, attribute
+ values, character data, names, comment bodies, processing
+ instruction data, attributes, then elements and content together
+ by a strong induction on the length of the printed form, then
+ the prolog and epilog lists, and at last the document.
+-/
+
+import Xml
+
+namespace Xml.Proofs
+
+open Xml
+
+set_option maxRecDepth 10000
+
+/-! ## Small facts -/
+
+theorem isWs_of_ne {c : Char} (h1 : c ≠ ' ') (h2 : c ≠ '\t')
+ (h3 : c ≠ '\n') (h4 : c ≠ '\r') : isWs c = false := by
+ simp [isWs, h1, h2, h3, h4]
+
+theorem isWs_lt : isWs '<' = false := by decide
+theorem isWs_slash : isWs '/' = false := by decide
+
+/-- Skipping whitespace over a list whose head is not whitespace
+ changes nothing. -/
+theorem skipWs_cons {c : Char} {cs : List Char} (h : isWs c = false) :
+ skipWs (c :: cs) = c :: cs := by
+ simp [skipWs, h]
+
+theorem isLegalChar_of {c : Char} (h : LegalChar c) : isLegalChar c = true :=
+ isLegalChar_iff.mpr h
+
+theorem isNameStart_of {c : Char} (h : IsNameStart c) : isNameStart c = true :=
+ isNameStart_iff.mpr h
+
+theorem isNameChar_of {c : Char} (h : IsNameChar c) : isNameChar c = true :=
+ isNameChar_iff.mpr h
+
+theorem isNameChar_false_of {c : Char} (h : ¬ IsNameChar c) :
+ isNameChar c = false := by
+ cases hb : isNameChar c with
+ | true => exact absurd (isNameChar_iff.mp hb) h
+ | false => rfl
+
+/-- A name start character is a name character. -/
+theorem IsNameStart.toNameChar {c : Char} (h : IsNameStart c) :
+ IsNameChar c :=
+ Or.inl h
+
+/-- A name start character differs from any character that fails
+ the name start test. -/
+theorem IsNameStart.ne_of_test {c d : Char} (h : IsNameStart c)
+ (hd : isNameStart d = false) : c ≠ d := by
+ intro e
+ subst e
+ have hb : isNameStart c = true := isNameStart_of h
+ rw [hb] at hd
+ exact Bool.false_ne_true hd.symm
+
+/-- A name start character is none of the marks that can follow a
+ name in a printed tag. -/
+theorem IsNameStart.ne_lt {c : Char} (h : IsNameStart c) : c ≠ '<' :=
+ IsNameStart.ne_of_test h (by decide)
+
+theorem IsNameStart.ne_bang {c : Char} (h : IsNameStart c) : c ≠ '!' :=
+ IsNameStart.ne_of_test h (by decide)
+
+theorem IsNameStart.ne_ws {c : Char} (h : IsNameStart c) : ¬ IsWs c := by
+ intro hw
+ rcases hw with e | e | e | e
+ · exact IsNameStart.ne_of_test h (by decide) e
+ · exact IsNameStart.ne_of_test h (by decide) e
+ · exact IsNameStart.ne_of_test h (by decide) e
+ · exact IsNameStart.ne_of_test h (by decide) e
+
+/-- A character that fails the name character test is not a name
+ character. -/
+theorem not_nameChar_of_test {c : Char} (h : isNameChar c = false) :
+ ¬ IsNameChar c := by
+ intro hc
+ have hb := isNameChar_iff.mpr hc
+ rw [h] at hb
+ exact Bool.false_ne_true hb
+
+/-- The marks that follow a name in a printed tag are not name
+ characters. -/
+theorem not_nameChar_slash : ¬ IsNameChar '/' :=
+ not_nameChar_of_test (by decide)
+theorem not_nameChar_gt : ¬ IsNameChar '>' :=
+ not_nameChar_of_test (by decide)
+theorem not_nameChar_eq : ¬ IsNameChar '=' :=
+ not_nameChar_of_test (by decide)
+theorem not_nameChar_quest : ¬ IsNameChar '?' :=
+ not_nameChar_of_test (by decide)
+theorem not_nameChar_space : ¬ IsNameChar ' ' :=
+ not_nameChar_of_test (by decide)
+
+/-- The printed form of one character of character data: either
+ an escape, whose head is an ampersand, or the character
+ itself. -/
+theorem escapeTextChar_head (c : Char) :
+ (escapeTextChar c).head? = some '&' ∨ escapeTextChar c = [c] := by
+ unfold escapeTextChar
+ split
+ · simp
+ · split
+ · simp
+ · split
+ · simp
+ · split
+ · simp
+ · exact Or.inr rfl
+
+/-- An escaped character is never the empty list, and its head is
+ never a greater sign. The second fact is what keeps `]]>` from
+ ever forming in printed text: a greater sign only ever appears
+ inside `>`, behind an ampersand. -/
+theorem escapeTextChar_ne_nil (c : Char) : escapeTextChar c ≠ [] := by
+ intro he
+ rcases escapeTextChar_head c with h | h
+ · rw [he] at h
+ simp at h
+ · rw [he] at h
+ simp at h
+
+theorem escapeTextChar_head_ne_gt (c : Char) :
+ (escapeTextChar c).head? ≠ some '>' := by
+ rcases escapeTextChar_head c with h | h
+ · rw [h]; decide
+ · rw [h]
+ intro hcon
+ simp at hcon
+ subst hcon
+ have hgt : escapeTextChar '>' = ['&', 'g', 't', ';'] := by decide
+ rw [hgt] at h
+ simp at h
+
+/-- An escaped attribute character is never the empty list, and
+ its head is an ampersand when it is an escape. -/
+theorem escapeAttrChar_head (c : Char) :
+ (escapeAttrChar c).head? = some '&' ∨ escapeAttrChar c = [c] := by
+ unfold escapeAttrChar
+ split
+ · simp
+ · split
+ · simp
+ · split
+ · simp
+ · split
+ · simp
+ · split
+ · simp
+ · split
+ · simp
+ · exact Or.inr rfl
+
+theorem escapeAttrChar_ne_nil (c : Char) : escapeAttrChar c ≠ [] := by
+ intro he
+ rcases escapeAttrChar_head c with h | h
+ · rw [he] at h
+ simp at h
+ · rw [he] at h
+ simp at h
+
+/-- The head of a printed text run followed by more text: the
+ printed run of a nonempty list starts with the printed form of
+ its first character. -/
+theorem escapeText_head {c : Char} {cs : List Char} :
+ (escapeText (c :: cs)).head? = (escapeTextChar c).head? := by
+ have hne := escapeTextChar_ne_nil c
+ simp only [escapeText, List.flatMap_cons]
+ cases h : escapeTextChar c with
+ | nil => exact absurd h hne
+ | cons x xs => simp
+
+theorem escapeText_length_ge (cs : List Char) :
+ cs.length ≤ (escapeText cs).length := by
+ induction cs with
+ | nil => simp [escapeText]
+ | cons c cs ih =>
+ have h1 : 1 ≤ (escapeTextChar c).length := by
+ cases h : escapeTextChar c with
+ | nil => exact absurd h (escapeTextChar_ne_nil c)
+ | cons x xs => simp
+ have hlen : (escapeText (c :: cs)).length =
+ (escapeTextChar c).length + (escapeText cs).length := by
+ have e1 : escapeText (c :: cs) = escapeTextChar c ++ escapeText cs := rfl
+ rw [e1, List.length_append]
+ rw [List.length_cons, hlen]
+ omega
+
+theorem escapeAttr_length_ge (cs : List Char) :
+ cs.length ≤ (escapeAttr cs).length := by
+ induction cs with
+ | nil => simp [escapeAttr]
+ | cons c cs ih =>
+ have h1 : 1 ≤ (escapeAttrChar c).length := by
+ cases h : escapeAttrChar c with
+ | nil => exact absurd h (escapeAttrChar_ne_nil c)
+ | cons x xs => simp
+ have hlen : (escapeAttr (c :: cs)).length =
+ (escapeAttrChar c).length + (escapeAttr cs).length := by
+ have e1 : escapeAttr (c :: cs) = escapeAttrChar c ++ escapeAttr cs := rfl
+ rw [e1, List.length_append]
+ rw [List.length_cons, hlen]
+ omega
+
+/-! ## References -/
+
+theorem parseRef_lt {rest : List Char} :
+ parseRef ('l' :: 't' :: ';' :: rest) = some ('<', rest) := rfl
+
+theorem parseRef_gt {rest : List Char} :
+ parseRef ('g' :: 't' :: ';' :: rest) = some ('>', rest) := rfl
+
+theorem parseRef_amp {rest : List Char} :
+ parseRef ('a' :: 'm' :: 'p' :: ';' :: rest) = some ('&', rest) := rfl
+
+theorem parseRef_quot {rest : List Char} :
+ parseRef ('q' :: 'u' :: 'o' :: 't' :: ';' :: rest) = some ('"', rest) := rfl
+
+/-- A run of digits at the head of a text is read whole: when the
+ text after the run does not start with a digit, the digit run
+ reader stops at the end of the run. -/
+theorem takeDigits_append {ds rest : List Char}
+ (hd : ∀ d ∈ ds, isDigit d = true)
+ (hr : ∀ c, rest.head? = some c → isDigit c = false) :
+ takeDigits (ds ++ rest) = (ds, rest) := by
+ induction ds generalizing rest with
+ | nil =>
+ cases rest with
+ | nil => rfl
+ | cons c r =>
+ have hc : isDigit c = false := hr c rfl
+ simp [takeDigits, hc]
+ | cons d ds ih =>
+ have hdd : isDigit d = true := hd d (by simp)
+ have ih' := ih (fun x hx => hd x (List.mem_cons_of_mem d hx)) hr
+ simp [takeDigits, hdd, ih']
+
+theorem parseRef_dec13 {rest : List Char} :
+ parseRef ('#' :: '1' :: '3' :: ';' :: rest) = some ('\r', rest) := by
+ have htake : takeDigits ('1' :: '3' :: ';' :: rest) =
+ (['1', '3'], ';' :: rest) := by
+ show takeDigits (['1', '3'] ++ (';' :: rest)) = (['1', '3'], ';' :: rest)
+ apply takeDigits_append
+ · decide
+ · intro c hc
+ have hcc : c = ';' := (Option.some.inj hc).symm
+ rw [hcc]
+ decide
+ have hval : digitsValue 10 (fun d => d.toNat - 48) ['1', '3'] = 13 := by
+ decide
+ have hchar : charRefChar 13 = some '\r' := by decide
+ simp [parseRef, htake, hval, hchar]
+
+theorem parseRef_dec9 {rest : List Char} :
+ parseRef ('#' :: '9' :: ';' :: rest) = some ('\t', rest) := by
+ have htake : takeDigits ('9' :: ';' :: rest) = (['9'], ';' :: rest) := by
+ show takeDigits (['9'] ++ (';' :: rest)) = (['9'], ';' :: rest)
+ apply takeDigits_append
+ · decide
+ · intro c hc
+ have hcc : c = ';' := (Option.some.inj hc).symm
+ rw [hcc]
+ decide
+ have hval : digitsValue 10 (fun d => d.toNat - 48) ['9'] = 9 := by decide
+ have hchar : charRefChar 9 = some '\t' := by decide
+ simp [parseRef, htake, hval, hchar]
+
+theorem parseRef_dec10 {rest : List Char} :
+ parseRef ('#' :: '1' :: '0' :: ';' :: rest) = some ('\n', rest) := by
+ have htake : takeDigits ('1' :: '0' :: ';' :: rest) =
+ (['1', '0'], ';' :: rest) := by
+ show takeDigits (['1', '0'] ++ (';' :: rest)) = (['1', '0'], ';' :: rest)
+ apply takeDigits_append
+ · decide
+ · intro c hc
+ have hcc : c = ';' := (Option.some.inj hc).symm
+ rw [hcc]
+ decide
+ have hval : digitsValue 10 (fun d => d.toNat - 48) ['1', '0'] = 10 := by
+ decide
+ have hchar : charRefChar 10 = some '\n' := by decide
+ simp [parseRef, htake, hval, hchar]
+
+/-! ## Text runs and attribute values -/
+
+/-- The five shapes of a printed character of character data, with
+ the character each shape comes from. -/
+theorem escapeTextChar_cases (c : Char) :
+ (c = '&' ∧ escapeTextChar c = ['&', 'a', 'm', 'p', ';']) ∨
+ (c = '<' ∧ escapeTextChar c = ['&', 'l', 't', ';']) ∨
+ (c = '>' ∧ escapeTextChar c = ['&', 'g', 't', ';']) ∨
+ (c = '\r' ∧ escapeTextChar c = ['&', '#', '1', '3', ';']) ∨
+ (c ≠ '&' ∧ c ≠ '<' ∧ c ≠ '>' ∧ c ≠ '\r' ∧ escapeTextChar c = [c]) := by
+ by_cases h1 : c = '&'
+ · exact Or.inl ⟨h1, by simp [escapeTextChar, h1]⟩
+ · by_cases h2 : c = '<'
+ · exact Or.inr (Or.inl ⟨h2, by simp [escapeTextChar, h1, h2]⟩)
+ · by_cases h3 : c = '>'
+ · exact Or.inr (Or.inr (Or.inl ⟨h3, by simp [escapeTextChar, h1, h2, h3]⟩))
+ · by_cases h4 : c = '\r'
+ · exact Or.inr (Or.inr (Or.inr
+ (Or.inl ⟨h4, by simp [escapeTextChar, h1, h2, h3, h4]⟩)))
+ · exact Or.inr (Or.inr (Or.inr (Or.inr
+ ⟨h1, h2, h3, h4, by simp [escapeTextChar, h1, h2, h3, h4]⟩)))
+
+/-- The seven shapes of a printed character of an attribute
+ value. -/
+theorem escapeAttrChar_cases (c : Char) :
+ (c = '&' ∧ escapeAttrChar c = ['&', 'a', 'm', 'p', ';']) ∨
+ (c = '<' ∧ escapeAttrChar c = ['&', 'l', 't', ';']) ∨
+ (c = '"' ∧ escapeAttrChar c = ['&', 'q', 'u', 'o', 't', ';']) ∨
+ (c = '\t' ∧ escapeAttrChar c = ['&', '#', '9', ';']) ∨
+ (c = '\n' ∧ escapeAttrChar c = ['&', '#', '1', '0', ';']) ∨
+ (c = '\r' ∧ escapeAttrChar c = ['&', '#', '1', '3', ';']) ∨
+ (c ≠ '&' ∧ c ≠ '<' ∧ c ≠ '"' ∧ c ≠ '\t' ∧ c ≠ '\n' ∧ c ≠ '\r' ∧
+ escapeAttrChar c = [c]) := by
+ by_cases h1 : c = '&'
+ · exact Or.inl ⟨h1, by simp [escapeAttrChar, h1]⟩
+ · by_cases h2 : c = '<'
+ · exact Or.inr (Or.inl ⟨h2, by simp [escapeAttrChar, h1, h2]⟩)
+ · by_cases h3 : c = '"'
+ · exact Or.inr (Or.inr (Or.inl ⟨h3, by simp [escapeAttrChar, h1, h2, h3]⟩))
+ · by_cases h4 : c = '\t'
+ · exact Or.inr (Or.inr (Or.inr
+ (Or.inl ⟨h4, by simp [escapeAttrChar, h1, h2, h3, h4]⟩)))
+ · by_cases h5 : c = '\n'
+ · exact Or.inr (Or.inr (Or.inr (Or.inr
+ (Or.inl ⟨h5, by simp [escapeAttrChar, h1, h2, h3, h4, h5]⟩))))
+ · by_cases h6 : c = '\r'
+ · exact Or.inr (Or.inr (Or.inr (Or.inr (Or.inr
+ (Or.inl ⟨h6, by simp [escapeAttrChar, h1, h2, h3, h4, h5, h6]⟩)))))
+ · exact Or.inr (Or.inr (Or.inr (Or.inr (Or.inr (Or.inr
+ ⟨h1, h2, h3, h4, h5, h6,
+ by simp [escapeAttrChar, h1, h2, h3, h4, h5, h6]⟩)))))
+
+/-- A printed text run followed by the end of the text or by a
+ mark never holds the run `]]>` at the junction the text reader
+ is about to step over: the two characters the reader looks at
+ after a `]` it just read are never `]` and `>`. -/
+theorem escapeText_not_bracketGt {cs : List Char} {rest : List Char}
+ (hrest : rest = [] ∨ rest.head? = some '<') :
+ ¬ ((escapeText cs ++ rest).head? = some ']' ∧
+ ((escapeText cs ++ rest).tail).head? = some '>') := by
+ intro hcon
+ obtain ⟨h1, h2⟩ := hcon
+ cases cs with
+ | nil =>
+ simp only [escapeText, List.flatMap_nil, List.nil_append] at h1
+ rcases hrest with h | h
+ · rw [h] at h1
+ simp at h1
+ · rw [h] at h1
+ simp at h1
+ | cons d ds =>
+ have e1 : escapeText (d :: ds) ++ rest =
+ escapeTextChar d ++ (escapeText ds ++ rest) := by
+ have e0 : escapeText (d :: ds) = escapeTextChar d ++ escapeText ds := rfl
+ rw [e0, List.append_assoc]
+ rw [e1] at h1 h2
+ rcases escapeTextChar_head d with hd | hd
+ · have hh : (escapeTextChar d ++ (escapeText ds ++ rest)).head? =
+ some '&' := by
+ cases hdd : escapeTextChar d with
+ | nil => exact absurd hdd (escapeTextChar_ne_nil d)
+ | cons x xs =>
+ have hx : x = '&' := by
+ have h2' := hd
+ rw [hdd] at h2'
+ simp at h2'
+ exact h2'
+ simp [hdd, hx]
+ rw [hh] at h1
+ simp at h1
+ · rw [hd] at h2
+ simp only [List.cons_append, List.tail_cons, List.nil_append] at h2
+ cases ds with
+ | nil =>
+ simp only [escapeText, List.flatMap_nil, List.nil_append] at h2
+ rcases hrest with h | h
+ · rw [h] at h2
+ simp at h2
+ · rw [h] at h2
+ simp at h2
+ | cons e es =>
+ have hgt := escapeTextChar_head_ne_gt e
+ have hh : (escapeText (e :: es) ++ rest).head? =
+ (escapeTextChar e).head? := by
+ have e2 : escapeText (e :: es) ++ rest =
+ escapeTextChar e ++ (escapeText es ++ rest) := by
+ have e0 : escapeText (e :: es) =
+ escapeTextChar e ++ escapeText es := rfl
+ rw [e0, List.append_assoc]
+ rw [e2]
+ cases hdd : escapeTextChar e with
+ | nil => exact absurd hdd (escapeTextChar_ne_nil e)
+ | cons x xs => simp [hdd]
+ rw [hh] at h2
+ exact hgt h2
+
+/-- Reading back a printed text run gives the run back, when the
+ text after it is empty or starts with a less sign (so the
+ reader stops there). -/
+theorem parseTextRun_escapeText : ∀ (cs : List Char) (rest : List Char),
+ (∀ c ∈ cs, LegalChar c) →
+ (rest = [] ∨ rest.head? = some '<') →
+ ∀ (f : Nat), cs.length + 1 ≤ f →
+ parseTextRun f (escapeText cs ++ rest) = some (cs, rest) := by
+ intro cs
+ induction cs with
+ | nil =>
+ intro rest _ hrest f hf
+ cases f with
+ | zero => simp at hf
+ | succ f' =>
+ cases rest with
+ | nil => simp [parseTextRun, escapeText]
+ | cons c t =>
+ have hc : c = '<' := by
+ rcases hrest with h | h
+ · simp at h
+ · have h2 : (c :: t).head? = some c := rfl
+ rw [h2] at h
+ exact Option.some.inj h
+ subst hc
+ simp [parseTextRun, escapeText]
+ | cons c cs ih =>
+ intro rest hlegal hrest f hf
+ have hlegal' : ∀ x ∈ cs, LegalChar x :=
+ fun x hx => hlegal x (List.mem_cons_of_mem c hx)
+ have hclegal : LegalChar c := hlegal c (by simp)
+ cases f with
+ | zero =>
+ simp only [List.length_cons] at hf
+ omega
+ | succ f' =>
+ have hf' : cs.length + 1 ≤ f' := by
+ simp only [List.length_cons] at hf
+ omega
+ have ih' := ih rest hlegal' hrest f' hf'
+ have e0 : escapeText (c :: cs) = escapeTextChar c ++ escapeText cs := rfl
+ rw [e0, List.append_assoc]
+ rcases escapeTextChar_cases c with
+ ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2, h3, h4, h5⟩
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseTextRun, ih', parseRef_amp]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseTextRun, ih', parseRef_lt]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseTextRun, ih', parseRef_gt]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseTextRun, ih', parseRef_dec13]
+ · rw [h5]
+ simp only [List.cons_append]
+ by_cases hc : c = ']'
+ · subst hc
+ have hnb := escapeText_not_bracketGt (cs := cs) (rest := rest) hrest
+ simp only [parseTextRun, List.nil_append]
+ rw [if_pos trivial, if_neg hnb, ih']
+ simp
+ · simp [parseTextRun, ih', h1, h2, h3, h4, hc,
+ isLegalChar_of hclegal]
+
+/-- Reading back a printed attribute value gives the value back.
+ The printer uses double quotes, so the quote here is the
+ double quote. -/
+theorem parseAttrValue_escapeAttr : ∀ (cs : List Char) (rest : List Char),
+ (∀ c ∈ cs, LegalChar c) →
+ ∀ (f : Nat), cs.length + 1 ≤ f →
+ parseAttrValue '"' f (escapeAttr cs ++ '"' :: rest) = some (cs, rest) := by
+ intro cs
+ induction cs with
+ | nil =>
+ intro rest _ f hf
+ cases f with
+ | zero => simp at hf
+ | succ f' => simp [parseAttrValue, escapeAttr]
+ | cons c cs ih =>
+ intro rest hlegal f hf
+ have hlegal' : ∀ x ∈ cs, LegalChar x :=
+ fun x hx => hlegal x (List.mem_cons_of_mem c hx)
+ have hclegal : LegalChar c := hlegal c (by simp)
+ cases f with
+ | zero =>
+ simp only [List.length_cons] at hf
+ omega
+ | succ f' =>
+ have hf' : cs.length + 1 ≤ f' := by
+ simp only [List.length_cons] at hf
+ omega
+ have ih' := ih rest hlegal' f' hf'
+ have e0 : escapeAttr (c :: cs) = escapeAttrChar c ++ escapeAttr cs := rfl
+ rw [e0, List.append_assoc]
+ rcases escapeAttrChar_cases c with
+ ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ |
+ ⟨h1, h2, h3, h4, h5, h6, h7⟩
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseAttrValue, ih', parseRef_amp]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseAttrValue, ih', parseRef_lt]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseAttrValue, ih', parseRef_quot]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseAttrValue, ih', parseRef_dec9]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseAttrValue, ih', parseRef_dec10]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ simp [parseAttrValue, ih', parseRef_dec13]
+ · rw [h7]
+ simp only [List.cons_append]
+ simp [parseAttrValue, ih', h1, h2, h3, h4, h5, h6,
+ isLegalChar_of hclegal]
+
+/-- Reading back printed character data gives the data back, when
+ the reader stops at the text that follows. -/
+theorem parseCharData_escapeText (cs : List Char) (rest : List Char)
+ (hne : cs ≠ [])
+ (hlegal : ∀ c ∈ cs, LegalChar c)
+ (hrest : rest = [] ∨ rest.head? = some '<')
+ (hstop : ∀ f, 1 ≤ f → parseCharData f rest = some ([], rest))
+ (f : Nat) (hf : cs.length + 2 ≤ f) :
+ parseCharData f (escapeText cs ++ rest) = some (cs, rest) := by
+ cases f with
+ | zero => omega
+ | succ f' =>
+ have hf1 : cs.length + 1 ≤ f' := by omega
+ have htr := parseTextRun_escapeText cs rest hlegal hrest f' hf1
+ have hst := hstop f' (by
+ have h1 : 1 ≤ cs.length := by
+ cases cs with
+ | nil => exact absurd rfl hne
+ | cons c cs' => simp
+ omega)
+ obtain ⟨c, cs', hcs⟩ : ∃ c cs', cs = c :: cs' := by
+ cases cs with
+ | nil => exact absurd rfl hne
+ | cons c cs' => exact ⟨c, cs', rfl⟩
+ subst hcs
+ have e0 : escapeText (c :: cs') = escapeTextChar c ++ escapeText cs' := rfl
+ rw [e0, List.append_assoc]
+ rcases escapeTextChar_cases c with
+ ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2, h3, h4, h5⟩
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ have htr2 : parseTextRun f'
+ ('&' :: 'a' :: 'm' :: 'p' :: ';' :: (escapeText cs' ++ rest)) =
+ some ('&' :: cs', rest) := htr
+ simp [parseCharData, htr2, hst]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ have htr2 : parseTextRun f'
+ ('&' :: 'l' :: 't' :: ';' :: (escapeText cs' ++ rest)) =
+ some ('<' :: cs', rest) := htr
+ simp [parseCharData, htr2, hst]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ have htr2 : parseTextRun f'
+ ('&' :: 'g' :: 't' :: ';' :: (escapeText cs' ++ rest)) =
+ some ('>' :: cs', rest) := htr
+ simp [parseCharData, htr2, hst]
+ · subst h1
+ rw [h2]
+ simp only [List.cons_append, List.append_assoc]
+ have htr2 : parseTextRun f'
+ ('&' :: '#' :: '1' :: '3' :: ';' :: (escapeText cs' ++ rest)) =
+ some ('\r' :: cs', rest) := htr
+ simp [parseCharData, htr2, hst]
+ · rw [h5]
+ simp only [List.cons_append]
+ have htr2 : parseTextRun f' (c :: (escapeText cs' ++ rest)) =
+ some (c :: cs', rest) := by
+ have htr' := htr
+ rw [e0, h5] at htr'
+ simpa only [List.cons_append, List.append_assoc, List.nil_append]
+ using htr'
+ simp [parseCharData, htr2, hst, h2]
+
+/-! ## Names -/
+
+theorem takeNameChars_append {ds rest : List Char}
+ (hd : ∀ d ∈ ds, isNameChar d = true)
+ (hr : ∀ c, rest.head? = some c → isNameChar c = false) :
+ takeNameChars (ds ++ rest) = (ds, rest) := by
+ induction ds generalizing rest with
+ | nil =>
+ cases rest with
+ | nil => rfl
+ | cons c r =>
+ have hc : isNameChar c = false := hr c rfl
+ simp [takeNameChars, hc]
+ | cons d ds ih =>
+ have hdd : isNameChar d = true := hd d (by simp)
+ have ih' := ih (fun x hx => hd x (List.mem_cons_of_mem d hx)) hr
+ simp [takeNameChars, hdd, ih']
+
+/-- A valid name at the head of a text is read whole, when the text
+ after it does not start with a name character. -/
+theorem parseName_append {cs rest : List Char} (hv : ValidName cs)
+ (hr : ∀ c, rest.head? = some c → isNameChar c = false) :
+ parseName (cs ++ rest) = some (cs, rest) := by
+ obtain ⟨c, ds, hcs, hstart, hchars⟩ := hv
+ subst hcs
+ have hsc : isNameStart c = true := isNameStart_of hstart
+ have htake := takeNameChars_append (ds := ds) (rest := rest)
+ (fun d hd => isNameChar_of (hchars d hd)) hr
+ simp [parseName, hsc, htake]
+
+
+/-! ## Comments and processing instructions -/
+
+/-- One step of the comment body reader over a dash that does not
+ close the comment. -/
+theorem parseCommentBody_dash_cons {d : Char} {xs : List Char}
+ (hd : d ≠ '-') :
+ parseCommentBody ('-' :: d :: xs) =
+ (parseCommentBody (d :: xs)).map fun (b, r) => ('-' :: b, r) := by
+ simp [parseCommentBody, hd]
+
+/-- Reading back a printed comment body gives the body back. -/
+theorem parseCommentBody_print : ∀ (cs : List Char) (rest : List Char),
+ NoDashDash cs → NotDashEnd cs → (∀ c ∈ cs, LegalChar c) →
+ parseCommentBody (cs ++ '-' :: '-' :: '>' :: rest) = some (cs, rest) := by
+ intro cs
+ induction cs with
+ | nil =>
+ intro rest _ _ _
+ simp [parseCommentBody]
+ | cons c cs ih =>
+ intro rest hdd hde hlegal
+ have hlegal' : ∀ x ∈ cs, LegalChar x :=
+ fun x hx => hlegal x (List.mem_cons_of_mem c hx)
+ have hclegal : LegalChar c := hlegal c (by simp)
+ have hdd' : NoDashDash cs := by
+ intro pre suf h
+ exact hdd (c :: pre) suf (by simp [h])
+ have hde' : NotDashEnd cs := by
+ intro pre h
+ exact hde (c :: pre) (by simp [h])
+ have ih' := ih rest hdd' hde' hlegal'
+ by_cases hc : c = '-'
+ · subst hc
+ cases cs with
+ | nil =>
+ exfalso
+ exact hde [] (by simp)
+ | cons d ds =>
+ have hd : d ≠ '-' := by
+ intro e
+ subst e
+ exact hdd [] ds (by simp)
+ have e1 : ('-' :: d :: ds) ++ '-' :: '-' :: '>' :: rest =
+ '-' :: d :: (ds ++ '-' :: '-' :: '>' :: rest) := by
+ simp [List.append_assoc]
+ rw [e1, parseCommentBody_dash_cons hd]
+ have ih2 : parseCommentBody (d :: (ds ++ '-' :: '-' :: '>' :: rest)) =
+ some (d :: ds, rest) := by
+ have := ih'
+ rwa [show (d :: ds) ++ '-' :: '-' :: '>' :: rest =
+ d :: (ds ++ '-' :: '-' :: '>' :: rest) by simp [List.append_assoc]] at this
+ rw [ih2]
+ rfl
+ · have e1 : (c :: cs) ++ '-' :: '-' :: '>' :: rest =
+ c :: (cs ++ '-' :: '-' :: '>' :: rest) := rfl
+ rw [e1]
+ simp [parseCommentBody, ih', hc, isLegalChar_of hclegal]
+
+/-- One step of the processing instruction data reader over a
+ question mark that does not close the instruction. -/
+theorem parsePIData_quest_cons {d : Char} {xs : List Char}
+ (hd : d ≠ '>') :
+ parsePIData ('?' :: d :: xs) =
+ (parsePIData (d :: xs)).map fun (b, r) => ('?' :: b, r) := by
+ simp [parsePIData, hd]
+
+/-- Reading back printed processing instruction data gives the
+ data back. -/
+theorem parsePIData_print : ∀ (d : List Char) (rest : List Char),
+ NoQClose d → (∀ c ∈ d, LegalChar c) →
+ parsePIData (d ++ '?' :: '>' :: rest) = some (d, rest) := by
+ intro d
+ induction d with
+ | nil =>
+ intro rest _ _
+ simp [parsePIData]
+ | cons c d ih =>
+ intro rest hq hlegal
+ have hlegal' : ∀ x ∈ d, LegalChar x :=
+ fun x hx => hlegal x (List.mem_cons_of_mem c hx)
+ have hclegal : LegalChar c := hlegal c (by simp)
+ have hq' : NoQClose d := by
+ intro pre suf h
+ exact hq (c :: pre) suf (by simp [h])
+ have ih' := ih rest hq' hlegal'
+ by_cases hc : c = '?'
+ · subst hc
+ cases d with
+ | nil =>
+ simp [parsePIData]
+ | cons e es =>
+ have he : e ≠ '>' := by
+ intro hgt
+ subst hgt
+ exact hq [] es (by simp)
+ have e1 : ('?' :: e :: es) ++ '?' :: '>' :: rest =
+ '?' :: e :: (es ++ '?' :: '>' :: rest) := by
+ simp [List.append_assoc]
+ rw [e1, parsePIData_quest_cons he]
+ have ih2 : parsePIData (e :: (es ++ '?' :: '>' :: rest)) =
+ some (e :: es, rest) := ih'
+ rw [ih2]
+ rfl
+ · have e1 : (c :: d) ++ '?' :: '>' :: rest =
+ c :: (d ++ '?' :: '>' :: rest) := rfl
+ rw [e1]
+ simp [parsePIData, ih', hc, isLegalChar_of hclegal]
+
+/-- Whitespace, as the test the parser runs, is false of a
+ character that is no whitespace character. -/
+theorem isWs_false_of_not {c : Char} (h : ¬ IsWs c) : isWs c = false := by
+ have h1 : c ≠ ' ' := fun e => h (Or.inl e)
+ have h2 : c ≠ '\t' := fun e => h (Or.inr (Or.inl e))
+ have h3 : c ≠ '\n' := fun e => h (Or.inr (Or.inr (Or.inl e)))
+ have h4 : c ≠ '\r' := fun e => h (Or.inr (Or.inr (Or.inr e)))
+ exact isWs_of_ne h1 h2 h3 h4
+
+/-- Reading back a printed processing instruction gives the
+ instruction back. The input is the text after ``, exactly as
+ `parsePI` receives it. -/
+theorem parsePI_print (t d : List Char) (rest : List Char)
+ (hname : ValidName t) (hres : ¬ IsXmlTarget t)
+ (hq : NoQClose d) (hlegal : ∀ c ∈ d, LegalChar c)
+ (hstart : d = [] ∨ ∀ c cs, d = c :: cs → ¬ IsWs c) :
+ parsePI (t ++ (if d = [] then ['?', '>'] else ' ' :: d ++ ['?', '>']) ++
+ rest) =
+ some (.pi t d, rest) := by
+ have hres' : ¬ (t.map Char.toLower = ['x', 'm', 'l']) := hres
+ cases hd : d with
+ | nil =>
+ subst hd
+ have hpn := parseName_append hname (rest := ['?', '>'] ++ rest) (by
+ intro c hc
+ simp at hc
+ rw [← hc]
+ exact isNameChar_false_of not_nameChar_quest)
+ rw [if_pos rfl, List.append_assoc]
+ simp only [parsePI]
+ rw [hpn]
+ simp [hres']
+ | cons c0 d0 =>
+ subst hd
+ have hpn := parseName_append hname
+ (rest := (' ' :: (c0 :: d0) ++ ['?', '>']) ++ rest) (by
+ intro c hc
+ simp at hc
+ rw [← hc]
+ exact isNameChar_false_of not_nameChar_space)
+ have hstart' : ¬ IsWs c0 := by
+ rcases hstart with h | h
+ · simp at h
+ · exact h c0 d0 rfl
+ have hsk : skipWs ((' ' :: (c0 :: d0) ++ ['?', '>']) ++ rest) =
+ (c0 :: d0) ++ '?' :: '>' :: rest := by
+ have e1 : (' ' :: (c0 :: d0) ++ ['?', '>']) ++ rest =
+ ' ' :: ((c0 :: d0) ++ '?' :: '>' :: rest) := by
+ simp [List.append_assoc]
+ rw [e1]
+ have hw : isWs ' ' = true := by decide
+ have hc0 : isWs c0 = false := isWs_false_of_not hstart'
+ simp [skipWs, hw, hc0]
+ have hpd := parsePIData_print (c0 :: d0) rest hq hlegal
+ rw [if_neg (by simp), List.append_assoc]
+ simp only [parsePI]
+ rw [hpn]
+ simp [hres', hsk, hpd, List.append_assoc, isWs]
+ have hsk2 : skipWs (' ' :: c0 :: (d0 ++ '?' :: '>' :: rest)) =
+ (c0 :: d0) ++ '?' :: '>' :: rest := by
+ have h1 : (' ' :: c0 :: (d0 ++ '?' :: '>' :: rest)) =
+ (' ' :: (c0 :: d0) ++ ['?', '>']) ++ rest := by
+ simp [List.append_assoc]
+ rw [h1]
+ exact hsk
+ rw [hsk2, hpd]
+
+/-! ## Printed lengths -/
+
+/-- The printed attribute list is at least as long as the list of
+ attributes. -/
+theorem printAttrs_length_ge : ∀ (attrs : List (List Char × List Char)),
+ attrs.length ≤ (printAttrs attrs).length := by
+ intro attrs
+ induction attrs with
+ | nil => simp [printAttrs]
+ | cons kv rest ih =>
+ have h2 := escapeAttr_length_ge kv.2
+ simp only [printAttrs, List.length_append, List.length_cons]
+ omega
+
+/-- The printed attribute list is long enough to cover any one
+ value in it, plus one character per attribute. -/
+theorem printAttrs_length_ge_value :
+ ∀ (attrs : List (List Char × List Char)) (kv : List Char × List Char),
+ kv ∈ attrs → kv.2.length + attrs.length ≤ (printAttrs attrs).length := by
+ intro attrs
+ induction attrs with
+ | nil => intro kv h; simp at h
+ | cons kv0 rest ih =>
+ intro kv h
+ have h3 := printAttrs_length_ge rest
+ rcases List.mem_cons.mp h with rfl | h'
+ · have h2 := escapeAttr_length_ge kv.2
+ simp only [printAttrs, List.length_append, List.length_cons]
+ omega
+ · have h4 := ih kv h'
+ have h2 := escapeAttr_length_ge kv0.2
+ simp only [printAttrs, List.length_append, List.length_cons]
+ omega
+
+/-! ## Stopping character data -/
+
+/-- Character data stops at a less sign whose next character is
+ not the bang that would open a CDATA section. -/
+theorem parseCharData_stop_lt_cons {c : Char} {r : List Char}
+ (hc : c ≠ '!') :
+ ∀ f, 1 ≤ f →
+ parseCharData f ('<' :: c :: r) = some ([], '<' :: c :: r) := by
+ intro f hf
+ cases f with
+ | zero => simp at hf
+ | succ f' => simp [parseCharData, hc]
+
+/-- Character data stops at a less sign at the end of the text. -/
+theorem parseCharData_stop_lt_nil :
+ ∀ f, 1 ≤ f → parseCharData f ['<'] = some ([], ['<']) := by
+ intro f hf
+ cases f with
+ | zero => simp at hf
+ | succ f' => simp [parseCharData]
+
+/-- Character data stops at the marks that open a comment. -/
+theorem parseCharData_stop_comment {r : List Char} :
+ ∀ f, 1 ≤ f →
+ parseCharData f ('<' :: '!' :: '-' :: '-' :: r) =
+ some ([], '<' :: '!' :: '-' :: '-' :: r) := by
+ intro f hf
+ cases f with
+ | zero => simp at hf
+ | succ f' => simp [parseCharData]
+
+/-! ## Attributes -/
+
+/-- Reading back a printed attribute list gives the attributes
+ back, on top of the ones already read. The end of the tag is
+ left to the caller: the statement ends in a hypothesis that
+ says what the reader does at the text that follows the printed
+ attributes. -/
+theorem parseAttrs_print : ∀ (attrs : List (List Char × List Char))
+ (acc : List (List Char × List Char)) (tail : List Char) (f : Nat)
+ (res : Option (List (List Char × List Char) × List Char × Bool)),
+ AttrsOk attrs →
+ (∀ kv ∈ attrs, ¬ acc.any (fun p => p.1 = kv.1)) →
+ (∀ kv ∈ attrs, kv.2.length + attrs.length + 2 ≤ f) →
+ attrs.length + 1 ≤ f →
+ (∀ f', 1 ≤ f' → parseAttrs f' (acc ++ attrs) tail = res) →
+ parseAttrs f acc (printAttrs attrs ++ tail) = res := by
+ intro attrs
+ induction attrs with
+ | nil =>
+ intro acc tail f res _ _ _ hf hfin
+ have h1 : parseAttrs f (acc ++ []) tail = res := hfin f (by
+ cases f with
+ | zero => simp at hf
+ | succ f' => omega)
+ simpa [printAttrs] using h1
+ | cons kv attrs ih =>
+ intro acc tail f res hok hdisj hfv hf hfin
+ obtain ⟨a, v⟩ := kv
+ have hname : ValidName a := hok.1
+ have hval : ∀ c ∈ v, LegalChar c := hok.2.1
+ have hnodup : ∀ kv2 ∈ attrs, kv2.1 ≠ a := hok.2.2.1
+ have hok' : AttrsOk attrs := hok.2.2.2
+ obtain ⟨c0, ds, ha, hc0, hds⟩ := hname
+ subst ha
+ cases f with
+ | zero =>
+ simp only [List.length_cons] at hf
+ omega
+ | succ f' =>
+ have hf' : attrs.length + 1 ≤ f' := by
+ simp only [List.length_cons] at hf
+ omega
+ have hfv' : ∀ kv2 ∈ attrs, kv2.2.length + attrs.length + 2 ≤ f' := by
+ intro kv2 h2
+ have h3 := hfv kv2 (List.mem_cons_of_mem _ h2)
+ simp only [List.length_cons] at h3
+ omega
+ have hdisj' : ∀ kv2 ∈ attrs,
+ ¬ (acc ++ [((c0 :: ds), v)]).any (fun p => p.1 = kv2.1) := by
+ intro kv2 h2
+ rw [List.any_append]
+ simp only [List.any_cons, List.any_nil, Bool.or_eq_true,
+ Bool.or_false]
+ intro e
+ rcases e with e | e
+ · exact hdisj kv2 (List.mem_cons_of_mem _ h2) e
+ · exact hnodup kv2 h2 (of_decide_eq_true e).symm
+ have hfin' : ∀ f'', 1 ≤ f'' →
+ parseAttrs f'' ((acc ++ [((c0 :: ds), v)]) ++ attrs) tail = res := by
+ intro f'' hf''
+ have h3 := hfin f'' hf''
+ have e : (acc ++ [((c0 :: ds), v)]) ++ attrs =
+ acc ++ (((c0 :: ds), v) :: attrs) := by
+ simp [List.append_assoc]
+ rwa [← e] at h3
+ have ih' := ih (acc ++ [((c0 :: ds), v)]) tail f' res hok' hdisj'
+ hfv' hf' hfin'
+ have hsk : skipWs (printAttrs (((c0 :: ds), v) :: attrs) ++ tail) =
+ (c0 :: ds) ++
+ ('=' :: '"' :: escapeAttr v ++ '"' :: (printAttrs attrs ++ tail)) := by
+ have hws1 : isWs ' ' = true := by decide
+ have hws2 : isWs c0 = false :=
+ isWs_false_of_not (IsNameStart.ne_ws hc0)
+ simp [printAttrs, skipWs, hws1, hws2, List.append_assoc]
+ have hpn : parseName
+ ((c0 :: ds) ++
+ ('=' :: '"' :: escapeAttr v ++ '"' :: (printAttrs attrs ++ tail))) =
+ some ((c0 :: ds),
+ '=' :: '"' :: escapeAttr v ++ '"' :: (printAttrs attrs ++ tail)) :=
+ parseName_append ⟨c0, ds, rfl, hc0, hds⟩ (by
+ intro c hc
+ simp only [List.head?] at hc
+ rw [← Option.some.inj hc]
+ exact isNameChar_false_of not_nameChar_eq)
+ have hpn2 : parseName
+ (c0 :: (ds ++
+ ('=' :: '"' :: (escapeAttr v ++ '"' :: (printAttrs attrs ++ tail))))) =
+ some ((c0 :: ds),
+ '=' :: '"' :: (escapeAttr v ++ '"' :: (printAttrs attrs ++ tail))) :=
+ hpn
+ have hval' : parseAttrValue '"' f'
+ (escapeAttr v ++ '"' :: (printAttrs attrs ++ tail)) =
+ some (v, printAttrs attrs ++ tail) :=
+ parseAttrValue_escapeAttr v (printAttrs attrs ++ tail) hval f' (by
+ have h3 := hfv ((c0 :: ds), v) (by simp)
+ simp only [List.length_cons] at h3
+ omega)
+ have hdup : acc.any (fun p => p.1 = (c0 :: ds)) = false := by
+ have h3 := hdisj ((c0 :: ds), v) (by simp)
+ cases h4 : acc.any (fun p => p.1 = (c0 :: ds)) with
+ | false => rfl
+ | true => exact absurd h4 (by exact h3)
+ have hc0ne1 : c0 ≠ '/' := by
+ intro e
+ subst e
+ have h5 : isNameStart '/' = true := isNameStart_of hc0
+ have h6 : isNameStart '/' = false := by decide
+ rw [h6] at h5
+ exact Bool.false_ne_true h5
+ have hc0ne2 : c0 ≠ '>' := by
+ intro e
+ subst e
+ have h5 : isNameStart '>' = true := isNameStart_of hc0
+ have h6 : isNameStart '>' = false := by decide
+ rw [h6] at h5
+ exact Bool.false_ne_true h5
+ have hstep : parseAttrs (f' + 1) acc
+ (printAttrs (((c0 :: ds), v) :: attrs) ++ tail) =
+ parseAttrs f' (acc ++ [((c0 :: ds), v)])
+ (printAttrs attrs ++ tail) := by
+ simp [parseAttrs, hsk, hpn2, hval', hdup, hc0ne1, hc0ne2,
+ isNameStart_of hc0, List.cons_append, List.append_assoc,
+ skipWs_cons (by decide : isWs '=' = false),
+ skipWs_cons (by decide : isWs '"' = false)]
+ rwa [hstep]
+
+/-- Reading back a printed attribute list that ends a short form
+ tag. -/
+theorem parseAttrs_print_closed
+ (attrs : List (List Char × List Char))
+ (acc : List (List Char × List Char)) (rest : List Char) (f : Nat)
+ (hok : AttrsOk attrs)
+ (hdisj : ∀ kv ∈ attrs, ¬ acc.any (fun p => p.1 = kv.1))
+ (hfv : ∀ kv ∈ attrs, kv.2.length + attrs.length + 2 ≤ f)
+ (hf : attrs.length + 1 ≤ f) :
+ parseAttrs f acc (printAttrs attrs ++ '/' :: '>' :: rest) =
+ some (acc ++ attrs, rest, true) := by
+ apply parseAttrs_print attrs acc _ f _ hok hdisj hfv hf
+ intro f' hf'
+ cases f' with
+ | zero => simp at hf'
+ | succ f'' =>
+ simp [parseAttrs, skipWs, isWs_slash,
+ (by decide : isWs '>' = false)]
+
+/-- Reading back a printed attribute list that ends a start tag. -/
+theorem parseAttrs_print_open
+ (attrs : List (List Char × List Char))
+ (acc : List (List Char × List Char)) (rest : List Char) (f : Nat)
+ (hok : AttrsOk attrs)
+ (hdisj : ∀ kv ∈ attrs, ¬ acc.any (fun p => p.1 = kv.1))
+ (hfv : ∀ kv ∈ attrs, kv.2.length + attrs.length + 2 ≤ f)
+ (hf : attrs.length + 1 ≤ f) :
+ parseAttrs f acc (printAttrs attrs ++ '>' :: rest) =
+ some (acc ++ attrs, rest, false) := by
+ apply parseAttrs_print attrs acc _ f _ hok hdisj hfv hf
+ intro f' hf'
+ cases f' with
+ | zero => simp at hf'
+ | succ f'' =>
+ simp [parseAttrs, skipWs, (by decide : isWs '>' = false)]
+
+/-! ## Elements and content -/
+
+/-- The element half of the round trip, at a bound on the length
+ of the printed element. -/
+abbrev ElemProp (n : Nat) : Prop :=
+ ∀ (f : Nat), n + 1 ≤ f → ∀ (name : List Char)
+ (attrs : List (List Char × List Char)) (content : List Node)
+ (rest : List Char),
+ WFNode (.elem name attrs content) →
+ (printNode (.elem name attrs content)).length = n →
+ parseElement f (printNode (.elem name attrs content) ++ rest) =
+ some (.elem name attrs content, rest)
+
+/-- The content half of the round trip, at a bound two more than
+ the length of the printed content: the two end tag characters
+ that follow the content are part of the bound, which is what
+ makes the bound drop at an element inside the content. -/
+abbrev NodesProp (n : Nat) : Prop :=
+ ∀ (f : Nat), n + 1 ≤ f → ∀ (nodes : List Node) (rest : List Char),
+ WFNodes nodes →
+ (printNodes nodes).length + 2 = n →
+ parseNodes f (printNodes nodes ++ '<' :: '/' :: rest) =
+ some (nodes, '<' :: '/' :: rest)
+
+/-- A name head is not any of the characters that would send the
+ content reader down another arm. -/
+theorem IsNameStart.ne_slash {c : Char} (h : IsNameStart c) : c ≠ '/' := by
+ intro e
+ subst e
+ have h5 : isNameStart '/' = true := isNameStart_of h
+ have h6 : isNameStart '/' = false := by decide
+ rw [h6] at h5
+ exact Bool.false_ne_true h5
+
+theorem IsNameStart.ne_quest {c : Char} (h : IsNameStart c) : c ≠ '?' := by
+ intro e
+ subst e
+ have h5 : isNameStart '?' = true := isNameStart_of h
+ have h6 : isNameStart '?' = false := by decide
+ rw [h6] at h5
+ exact Bool.false_ne_true h5
+
+/-- The two halves together, by strong induction on the bound. -/
+theorem elem_nodes : ∀ n, ElemProp n ∧ NodesProp n := by
+ have aux : ∀ k, ∀ n, n ≤ k → ElemProp n ∧ NodesProp n := by
+ intro k
+ induction k with
+ | zero =>
+ intro n hn
+ have hn0 : n = 0 := by omega
+ subst hn0
+ constructor
+ · intro f hf name attrs content rest hwf hlen
+ simp [printNode, printElem] at hlen
+ · intro f hf nodes rest hwf hlen
+ omega
+ | succ k ihk =>
+ intro n hn
+ by_cases hn2 : n ≤ k
+ · exact ihk n hn2
+ · have ih : ∀ m, m < n → ElemProp m ∧ NodesProp m :=
+ fun m hm => ihk m (by omega)
+ constructor
+ · -- elements
+ intro f hf name attrs content rest hwf hlen
+ cases f with
+ | zero => simp at hf
+ | succ f' =>
+ have hname : ValidName name := match hwf with
+ | .elem a _ _ => a
+ have hattrs : AttrsOk attrs := match hwf with
+ | .elem _ a _ => a
+ have hcontent : WFNodes content := match hwf with
+ | .elem _ _ a => a
+ obtain ⟨c0, ds, hnameq, hc0, hds⟩ := hname
+ subst hnameq
+ have hple : (printAttrs attrs).length + 4 ≤
+ (printNode (.elem (c0 :: ds) attrs content)).length := by
+ by_cases hc : content = []
+ · subst hc
+ simp [printNode, printElem, List.length_append]
+ · simp [printNode, printElem, hc, List.length_append]
+ omega
+ have hfv : ∀ kv ∈ attrs, kv.2.length + attrs.length + 2 ≤ f' := by
+ intro kv hkv
+ have h1 := printAttrs_length_ge_value attrs kv hkv
+ omega
+ have hfattrs : attrs.length + 1 ≤ f' := by
+ have h1 := printAttrs_length_ge attrs
+ omega
+ by_cases hc : content = []
+ · subst hc
+ have hpn : parseName
+ ((c0 :: ds) ++ (printAttrs attrs ++ '/' :: '>' :: rest)) =
+ some ((c0 :: ds), printAttrs attrs ++ '/' :: '>' :: rest) :=
+ parseName_append ⟨c0, ds, rfl, hc0, hds⟩ (by
+ intro c hc
+ have hc2 : ∀ x, (printAttrs attrs ++ '/' :: '>' :: rest).head? =
+ some x → isNameChar x = false := by
+ intro x hx
+ cases attrs with
+ | nil =>
+ simp only [printAttrs, List.nil_append,
+ List.head?_cons] at hx
+ rw [← Option.some.inj hx]
+ exact isNameChar_false_of not_nameChar_slash
+ | cons kv as =>
+ simp only [printAttrs, List.cons_append,
+ List.head?_cons] at hx
+ rw [← Option.some.inj hx]
+ exact isNameChar_false_of not_nameChar_space
+ exact hc2 c hc)
+ have hpn2 : parseName
+ (c0 :: (ds ++ (printAttrs attrs ++ '/' :: '>' :: rest))) =
+ some ((c0 :: ds), printAttrs attrs ++ '/' :: '>' :: rest) :=
+ hpn
+ have hat : parseAttrs f' [] (printAttrs attrs ++ '/' :: '>' :: rest) =
+ some (attrs, rest, true) := by
+ have h := parseAttrs_print_closed attrs [] rest f' hattrs
+ (by intro kv _ e; simp at e) hfv hfattrs
+ simpa using h
+ simp [parseElement, printNode, printElem, hpn2, hat,
+ List.append_assoc]
+ · have hpn : parseName
+ ((c0 :: ds) ++ (printAttrs attrs ++
+ ('>' :: (printNodes content ++
+ '<' :: '/' :: ((c0 :: ds) ++ '>' :: rest))))) =
+ some ((c0 :: ds), printAttrs attrs ++
+ ('>' :: (printNodes content ++
+ '<' :: '/' :: ((c0 :: ds) ++ '>' :: rest)))) :=
+ parseName_append ⟨c0, ds, rfl, hc0, hds⟩ (by
+ intro c hc
+ have hc2 : ∀ x, (printAttrs attrs ++
+ ('>' :: (printNodes content ++
+ '<' :: '/' :: ((c0 :: ds) ++ '>' :: rest)))).head? =
+ some x → isNameChar x = false := by
+ intro x hx
+ cases attrs with
+ | nil =>
+ simp only [printAttrs, List.nil_append,
+ List.head?_cons] at hx
+ rw [← Option.some.inj hx]
+ exact isNameChar_false_of not_nameChar_gt
+ | cons kv as =>
+ simp only [printAttrs, List.cons_append,
+ List.head?_cons] at hx
+ rw [← Option.some.inj hx]
+ exact isNameChar_false_of not_nameChar_space
+ exact hc2 c hc)
+ have hpn2 : parseName
+ (c0 :: (ds ++ (printAttrs attrs ++
+ ('>' :: (printNodes content ++
+ '<' :: '/' :: c0 :: (ds ++ '>' :: rest)))))) =
+ some ((c0 :: ds), printAttrs attrs ++
+ ('>' :: (printNodes content ++
+ '<' :: '/' :: c0 :: (ds ++ '>' :: rest)))) :=
+ hpn
+ have hat : parseAttrs f' []
+ (printAttrs attrs ++
+ ('>' :: (printNodes content ++
+ '<' :: '/' :: c0 :: (ds ++ '>' :: rest)))) =
+ some (attrs, printNodes content ++
+ '<' :: '/' :: c0 :: (ds ++ '>' :: rest), false) := by
+ have h := parseAttrs_print_open attrs []
+ (printNodes content ++ '<' :: '/' :: c0 :: (ds ++ '>' :: rest))
+ f' hattrs (by intro kv _ e; simp at e) hfv hfattrs
+ simpa using h
+ have hm : (printNodes content).length + 2 < n := by
+ have hlb : (printNodes content).length + 3 ≤
+ (printNode (.elem (c0 :: ds) attrs content)).length := by
+ simp [printNode, printElem, hc, List.length_append]
+ omega
+ omega
+ have hnodes : parseNodes f'
+ (printNodes content ++ '<' :: '/' :: c0 :: (ds ++ '>' :: rest)) =
+ some (content, '<' :: '/' :: c0 :: (ds ++ '>' :: rest)) :=
+ (ih ((printNodes content).length + 2) hm).2 f' (by omega)
+ content (c0 :: (ds ++ '>' :: rest)) hcontent rfl
+ have hend2 : parseName (c0 :: (ds ++ '>' :: rest)) =
+ some ((c0 :: ds), '>' :: rest) :=
+ parseName_append ⟨c0, ds, rfl, hc0, hds⟩ (by
+ intro c hc
+ simp only [List.head?_cons] at hc
+ rw [← Option.some.inj hc]
+ exact isNameChar_false_of not_nameChar_gt)
+ have hend : parseName ((c0 :: ds) ++ '>' :: rest) =
+ some ((c0 :: ds), '>' :: rest) :=
+ parseName_append ⟨c0, ds, rfl, hc0, hds⟩ (by
+ intro c hc
+ simp only [List.head?_cons] at hc
+ rw [← Option.some.inj hc]
+ exact isNameChar_false_of not_nameChar_gt)
+ simp [parseElement, printNode, printElem, hc, hpn2, hat, hnodes,
+ hend2, skipWs_cons (by decide : isWs '>' = false),
+ List.append_assoc, List.cons_append]
+ · -- content lists
+ intro f hf nodes rest hwf hlen
+ cases f with
+ | zero => simp at hf
+ | succ f' =>
+ cases nodes with
+ | nil =>
+ simp [parseNodes, printNodes]
+ | cons n0 ns =>
+ have hn0 : WFNode n0 := match hwf with
+ | .cons a _ _ => a
+ have hns : WFNodes ns := match hwf with
+ | .cons _ a _ => a
+ have hadj : (∃ cs, n0 = .text cs) →
+ ∀ cs, ns.head? ≠ some (.text cs) := match hwf with
+ | .cons _ _ a => a
+ have hlen2 : (printNode n0).length + (printNodes ns).length + 2 =
+ n := by
+ have h1 := hlen
+ simp only [printNodes, List.length_append] at h1
+ omega
+ have hm : (printNodes ns).length + 2 < n := by
+ have hp : 1 ≤ (printNode n0).length := by
+ cases n0 with
+ | text cs =>
+ cases hn0 with
+ | text hne hlegal =>
+ have h1 := escapeText_length_ge cs
+ have h2 : 1 ≤ cs.length := by
+ cases cs with
+ | nil => exact absurd rfl hne
+ | cons c cs' => simp
+ simp only [printNode]
+ omega
+ | elem name attrs content => simp [printNode, printElem]
+ | comment cs => simp [printNode]
+ | pi t d => simp [printNode]
+ omega
+ have hIH : parseNodes f' (printNodes ns ++ '<' :: '/' :: rest) =
+ some (ns, '<' :: '/' :: rest) :=
+ (ih ((printNodes ns).length + 2) hm).2 f' (by omega) ns rest
+ hns rfl
+ cases n0 with
+ | text t =>
+ cases hn0 with
+ | text hne hlegal =>
+ have hadj' : ∀ cs, ns.head? ≠ some (.text cs) :=
+ hadj ⟨t, rfl⟩
+ cases t with
+ | nil => exact absurd rfl hne
+ | cons c t' =>
+ have hrest : (printNodes ns ++ '<' :: '/' :: rest) = [] ∨
+ (printNodes ns ++ '<' :: '/' :: rest).head? =
+ some '<' := by
+ cases ns with
+ | nil =>
+ right
+ simp [printNodes]
+ | cons n1 ns' =>
+ right
+ cases n1 with
+ | text cs =>
+ exfalso
+ exact hadj' cs rfl
+ | elem name attrs content =>
+ simp [printNodes, printNode, printElem, List.cons_append]
+ | comment cs =>
+ simp [printNodes, printNode, List.cons_append]
+ | pi t d =>
+ simp [printNodes, printNode, List.cons_append]
+ have hstop : ∀ f, 1 ≤ f →
+ parseCharData f (printNodes ns ++ '<' :: '/' :: rest) =
+ some ([], printNodes ns ++ '<' :: '/' :: rest) := by
+ cases ns with
+ | nil =>
+ intro f hf2
+ simpa [printNodes] using
+ parseCharData_stop_lt_cons (c := '/') (by decide) f hf2
+ | cons n1 ns' =>
+ cases n1 with
+ | text cs =>
+ intro f _
+ exfalso
+ exact hadj' cs rfl
+ | elem name attrs content =>
+ have hn1 : WFNode (.elem name attrs content) :=
+ match hns with
+ | .cons a _ _ => a
+ cases hn1 with
+ | elem hname1 hattrs1 hcontent1 =>
+ obtain ⟨c1, ds1, hq1, hc1, -⟩ := hname1
+ intro f hf2
+ cases f with
+ | zero => simp at hf2
+ | succ f'' =>
+ have hc1ne : c1 ≠ '!' :=
+ IsNameStart.ne_bang hc1
+ simp [parseCharData, printNodes, printNode,
+ printElem, hq1, hc1ne, List.append_assoc]
+ | comment cs =>
+ intro f hf2
+ simpa [printNodes, printNode] using
+ parseCharData_stop_comment
+ (r := (cs ++ '-' :: '-' :: '>' :: []) ++
+ (printNodes ns' ++ '<' :: '/' :: rest)) f hf2
+ | pi t d =>
+ intro f hf2
+ simpa [printNodes, printNode] using
+ parseCharData_stop_lt_cons (c := '?') (by decide) f hf2
+ have hcd : parseCharData f'
+ (escapeText (c :: t') ++
+ (printNodes ns ++ '<' :: '/' :: rest)) =
+ some (c :: t', printNodes ns ++ '<' :: '/' :: rest) :=
+ parseCharData_escapeText (c :: t') _ (by simp) hlegal
+ hrest hstop f' (by
+ have h1 := escapeText_length_ge (c :: t')
+ have hlink : (printNode (.text (c :: t'))).length =
+ (escapeText (c :: t')).length := by
+ simp [printNode]
+ omega)
+ rcases escapeTextChar_cases c with
+ ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ | ⟨h1, h2⟩ |
+ ⟨h1, h2, h3, h4, h5⟩
+ · subst h1
+ have hcdX : parseCharData f'
+ ('&' :: 'a' :: 'm' :: 'p' :: ';' :: (List.flatMap escapeTextChar t' ++
+ (printNodes ns ++ '<' :: '/' :: rest))) =
+ some ('&' :: t', printNodes ns ++ '<' :: '/' :: rest) :=
+ hcd
+ simp [parseNodes, printNodes, printNode, escapeText, h2,
+ List.append_assoc]
+ rw [hcdX]
+ simp [hIH]
+ · subst h1
+ have hcdX : parseCharData f'
+ ('&' :: 'l' :: 't' :: ';' :: (List.flatMap escapeTextChar t' ++
+ (printNodes ns ++ '<' :: '/' :: rest))) =
+ some ('<' :: t', printNodes ns ++ '<' :: '/' :: rest) :=
+ hcd
+ simp [parseNodes, printNodes, printNode, escapeText, h2,
+ List.append_assoc]
+ rw [hcdX]
+ simp [hIH]
+ · subst h1
+ have hcdX : parseCharData f'
+ ('&' :: 'g' :: 't' :: ';' :: (List.flatMap escapeTextChar t' ++
+ (printNodes ns ++ '<' :: '/' :: rest))) =
+ some ('>' :: t', printNodes ns ++ '<' :: '/' :: rest) :=
+ hcd
+ simp [parseNodes, printNodes, printNode, escapeText, h2,
+ List.append_assoc]
+ rw [hcdX]
+ simp [hIH]
+ · subst h1
+ have hcdX : parseCharData f'
+ ('&' :: '#' :: '1' :: '3' :: ';' :: (List.flatMap escapeTextChar t' ++
+ (printNodes ns ++ '<' :: '/' :: rest))) =
+ some ('\r' :: t', printNodes ns ++ '<' :: '/' :: rest) :=
+ hcd
+ simp [parseNodes, printNodes, printNode, escapeText, h2,
+ List.append_assoc]
+ rw [hcdX]
+ simp [hIH]
+ · have hcdX : parseCharData f'
+ (c :: (List.flatMap escapeTextChar t' ++
+ (printNodes ns ++ '<' :: '/' :: rest))) =
+ some (c :: t', printNodes ns ++ '<' :: '/' :: rest) := by
+ have h := hcd
+ rw [show escapeText (c :: t') = c :: escapeText t' by
+ simp [escapeText, h5]] at h
+ exact h
+ simp [parseNodes, printNodes, printNode, escapeText,
+ h5, h1, h2, List.append_assoc]
+ rw [hcdX]
+ simp [hIH]
+ | comment body =>
+ cases hn0 with
+ | comment hdd hde hlegal =>
+ have hcb : parseCommentBody
+ (body ++ '-' :: '-' :: '>' ::
+ (printNodes ns ++ '<' :: '/' :: rest)) =
+ some (body, printNodes ns ++ '<' :: '/' :: rest) :=
+ parseCommentBody_print body _ hdd hde hlegal
+ simp [parseNodes, printNodes, printNode, hcb, hIH]
+ | pi t d =>
+ cases hn0 with
+ | pi hname hres hq hlegal hstart =>
+ have hpi : parsePI
+ (t ++ (if d = [] then ['?', '>']
+ else ' ' :: d ++ ['?', '>']) ++
+ (printNodes ns ++ '<' :: '/' :: rest)) =
+ some (.pi t d, printNodes ns ++ '<' :: '/' :: rest) :=
+ parsePI_print t d _ hname hres hq hlegal hstart
+ have hpi2 := hpi
+ rw [List.append_assoc] at hpi2
+ simp only [List.cons_append, List.nil_append] at hpi2
+ simp [parseNodes, printNodes, printNode]
+ rw [hpi2]
+ simp [hIH]
+ | elem name attrs content =>
+ cases hn0 with
+ | elem hname hattrs hcontent =>
+ obtain ⟨c0, ds, hq0, hc0, hds⟩ := hname
+ have hmE : (printNode (.elem name attrs content)).length <
+ n := by
+ omega
+ have hE : parseElement f'
+ (printNode (.elem name attrs content) ++
+ (printNodes ns ++ '<' :: '/' :: rest)) =
+ some (.elem name attrs content,
+ printNodes ns ++ '<' :: '/' :: rest) :=
+ (ih (printNode (.elem name attrs content)).length hmE).1
+ f' (by omega) name attrs content
+ (printNodes ns ++ '<' :: '/' :: rest)
+ (WFNode.elem ⟨c0, ds, hq0, hc0, hds⟩ hattrs hcontent) rfl
+ have hc0ne1 : c0 ≠ '/' := IsNameStart.ne_slash hc0
+ have hc0ne2 : c0 ≠ '!' := IsNameStart.ne_bang hc0
+ have hc0ne3 : c0 ≠ '?' := IsNameStart.ne_quest hc0
+ simp only [printNode, printElem, hq0, List.append_assoc,
+ List.cons_append] at hE
+ simp [parseNodes, printNodes, printNode, printElem, hq0,
+ hc0ne1, hc0ne2, hc0ne3, isNameStart_of hc0,
+ List.append_assoc]
+ rw [hE]
+ simp [hIH]
+
+ intro n
+ exact aux n n (Nat.le_refl n)
+
+/-- The misc parser stops at a tag whose second character starts
+ a name: that is an element start, not a comment or a
+ processing instruction. -/
+theorem parseMisc_stop {f : Nat} {c : Char} {cs : List Char}
+ (hc : IsNameStart c) :
+ parseMisc (f + 1) ('<' :: c :: cs) = some ([], '<' :: c :: cs) := by
+ have h1 : c ≠ '!' := IsNameStart.ne_bang hc
+ have h2 : c ≠ '?' := IsNameStart.ne_quest hc
+ have h3 : skipWs ('<' :: c :: cs) = '<' :: c :: cs :=
+ skipWs_cons (by decide)
+ simp [parseMisc, h3, h1, h2]
+
+/-- Prologs and epilogs round trip: printing a list of comments
+ and processing instructions and parsing gives the list back,
+ with the continuation reached after it. -/
+theorem parseMisc_print :
+ ∀ (ms : List Node) (rest : List Char),
+ MiscOk ms →
+ (∀ f, 1 ≤ f → parseMisc f rest = some ([], skipWs rest)) →
+ ∀ f, (printNodes ms).length + 1 ≤ f →
+ parseMisc f (printNodes ms ++ rest) = some (ms, skipWs rest) := by
+ intro ms
+ induction ms with
+ | nil =>
+ intro rest _ hstop f hf
+ simp only [printNodes, List.nil_append]
+ exact hstop f (by omega)
+ | cons n ms ih =>
+ intro rest hok hstop f hf
+ have hokn : WFNode n ∧
+ ((∃ cs, n = .comment cs) ∨ (∃ t d, n = .pi t d)) :=
+ hok n (by simp)
+ have hoktail : MiscOk ms :=
+ fun m hm => hok m (by simp [hm])
+ cases f with
+ | zero => omega
+ | succ f' =>
+ rcases hokn with ⟨hwf, hkind⟩
+ rcases hkind with ⟨cs, rfl⟩ | ⟨t, d, rfl⟩
+ · cases hwf with
+ | comment hdd hde hlegal =>
+ have hcb : parseCommentBody
+ (cs ++ '-' :: '-' :: '>' :: (printNodes ms ++ rest)) =
+ some (cs, printNodes ms ++ rest) :=
+ parseCommentBody_print cs _ hdd hde hlegal
+ have hlen : (printNodes ms).length + 1 ≤ f' := by
+ have h2 : (printNode (.comment cs)).length = cs.length + 7 := by
+ simp [printNode, List.length_append]
+ have h1 : (printNodes (.comment cs :: ms)).length =
+ (printNode (.comment cs)).length +
+ (printNodes ms).length := by
+ simp [printNodes, List.length_append]
+ omega
+ have hih := ih rest hoktail hstop f' hlen
+ simp [parseMisc, printNodes, printNode, skipWs, hcb, hih,
+ skipWs_cons (show isWs '<' = false by decide),
+ List.append_assoc]
+ · cases hwf with
+ | pi hname hres hq hlegal hstart =>
+ have hpi : parsePI
+ (t ++ (if d = [] then ['?', '>']
+ else ' ' :: d ++ ['?', '>']) ++
+ (printNodes ms ++ rest)) =
+ some (.pi t d, printNodes ms ++ rest) :=
+ parsePI_print t d _ hname hres hq hlegal hstart
+ have hlen : (printNodes ms).length + 1 ≤ f' := by
+ have h2 : 2 ≤ (printNode (.pi t d)).length := by
+ simp only [printNode, List.length_cons]
+ omega
+ have h1 : (printNodes (.pi t d :: ms)).length =
+ (printNode (.pi t d)).length +
+ (printNodes ms).length := by
+ simp [printNodes, List.length_append]
+ omega
+ have hpi2 := hpi
+ rw [List.append_assoc] at hpi2
+ simp only [List.cons_append, List.nil_append] at hpi2
+ have hih := ih rest hoktail hstop f' hlen
+ simp [parseMisc, printNodes, printNode, skipWs, hpi2, hih,
+ skipWs_cons (show isWs '<' = false by decide),
+ List.append_assoc]
+
+/-- The plan's claim: printing a well formed document and parsing
+ the print gives the same document back. -/
+theorem parse_print (d : Document) (hwf : WF d) :
+ parse (print d) = some d := by
+ obtain ⟨hpro, hroot, ⟨name, attrs, content, hreq⟩, hepi⟩ := hwf
+ rw [hreq] at hroot
+ cases hroot with
+ | elem hname hattrs hcontent =>
+ obtain ⟨c0, ds, hq0, hc0, hds⟩ := hname
+ have harg : printNode d.root ++ printNodes d.epilog =
+ '<' :: c0 :: (ds ++ (printAttrs attrs ++
+ (if content = [] then ['/', '>']
+ else '>' :: (printNodes content ++ '<' :: '/' ::
+ ((c0 :: ds) ++ ['>'])))) ++ printNodes d.epilog) := by
+ rw [hreq, hq0]
+ simp [printNode, printElem, List.append_assoc, List.cons_append]
+ have hsw : skipWs (printNode d.root ++ printNodes d.epilog) =
+ printNode d.root ++ printNodes d.epilog := by
+ rw [harg]
+ exact skipWs_cons (by decide)
+ have hstoproot : ∀ f, 1 ≤ f →
+ parseMisc f (printNode d.root ++ printNodes d.epilog) =
+ some ([], skipWs (printNode d.root ++ printNodes d.epilog)) := by
+ intro f hf
+ cases f with
+ | zero => omega
+ | succ f' =>
+ rw [harg, skipWs_cons (show isWs '<' = false by decide)]
+ exact parseMisc_stop hc0
+ have hstopnil : ∀ f, 1 ≤ f →
+ parseMisc f [] = some ([], skipWs []) := by
+ intro f hf
+ cases f with
+ | zero => omega
+ | succ f' => simp [parseMisc, skipWs]
+ have hpro' : parseMisc ((printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)).length + 1)
+ (printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)) =
+ some (d.prolog, skipWs (printNode d.root ++
+ printNodes d.epilog)) :=
+ parseMisc_print d.prolog _ hpro hstoproot _ (by
+ simp only [List.length_append]; omega)
+ have hroot' : parseElement ((printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)).length + 1)
+ (printNode d.root ++ printNodes d.epilog) =
+ some (d.root, printNodes d.epilog) := by
+ rw [hreq]
+ exact (elem_nodes (printNode (.elem name attrs content)).length).1
+ _ (by
+ have e : (printNode d.root).length =
+ (printNode (.elem name attrs content)).length := by
+ rw [hreq]
+ simp only [List.length_append]; omega)
+ name attrs content (printNodes d.epilog)
+ (WFNode.elem ⟨c0, ds, hq0, hc0, hds⟩ hattrs hcontent) rfl
+ have hepi' : parseMisc ((printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)).length + 1) (printNodes d.epilog) =
+ some (d.epilog, skipWs []) := by
+ have h := parseMisc_print d.epilog [] hepi hstopnil
+ ((printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)).length + 1) (by
+ simp only [List.length_append]; omega)
+ simpa using h
+ have hPd : print d = printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog) := by
+ simp [print, List.append_assoc]
+ have hdoc : parseDocument ((printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)).length + 1)
+ (printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)) = some d := by
+ simp only [parseDocument, hpro', hsw, hroot', hepi', skipWs,
+ List.nil_append]
+ rfl
+ have hparse : parse (print d) = parseDocument ((printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)).length + 1) (print d) := by
+ have e : (printNodes d.prolog ++
+ (printNode d.root ++ printNodes d.epilog)).length + 1 = (print d).length + 1 := by
+ simp only [print, List.length_append]; omega
+ rw [e]
+ rfl
+ rw [hparse, hPd]
+ exact hdoc
+
+end Xml.Proofs
diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean
new file mode 100644
index 0000000..f7af403
--- /dev/null
+++ b/Proofs/SpecProofs.lean
@@ -0,0 +1,102 @@
+/-
+ Proofs of the statements in `Spec.lean`, under the same names.
+
+ The round trip is the theorem proved in
+ `Proofs/RoundTrip.lean`. The refusals are single inputs,
+ checked in the kernel with `decide +kernel`: no native
+ evaluation, which the bar forbids.
+-/
+
+import Xml
+import Proofs.RoundTrip
+
+open Xml
+
+theorem Spec.parse_print (d : Document) (hwf : WF d) :
+ parse (print d) = some d :=
+ Xml.Proofs.parse_print d hwf
+
+
+theorem Spec.rejects_doctype :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_undefined_entity :
+ (parse " ".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_mismatched_tags :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_unclosed_element :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_second_root :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_text_before_root :
+ (parse "x".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_duplicate_attribute :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_lt_in_attribute :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_cdata_close_in_text :
+ (parse "x]]>y".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_dashdash_in_comment :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_surrogate_ref :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_zero_ref :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_colon_in_name :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_digit_name :
+ (parse "<1a/>".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_unquoted_attribute :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_cdata_in_prolog :
+ (parse "".toList).isSome = false := by
+ decide +kernel
+
+
+theorem Spec.rejects_xml_declaration :
+ (parse "".toList).isSome = false := by
+ decide +kernel
diff --git a/README.md b/README.md
index 2a9ab1d..4e1958d 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,179 @@
-# xml
+# xml: an XML safe-subset parser and printer, with a proved round trip
-A Lean parser for a safe subset of XML. This is a Proven Commons project: it rebuilds a widely used format in Lean and proves the new code follows its written specification. It is not a full XML parser. It parses only the subset below.
+Status: in-progress
-Status: not verified. No code has landed yet. Plan issue 1 holds the first slice, and it is unclaimed. The specification has not been approved by a reviewer, so do not call this verified.
+A parser and printer in Lean for a safe subset of XML 1.0:
+elements, attributes, text, comments, processing
+instructions, character references, and CDATA sections,
+with one root element. There is no document type
+declaration, no entity declarations, no namespaces, and
+no XML declaration in the subset; a document with any of
+those is rejected, not parsed leniently. The definitions
+are in `Xml/Value.lean` (the tree type, the printer, and
+the well-formedness rules) and `Xml/Parse.lean` (the
+parser). The proofs are in `Proofs/RoundTrip.lean`. The
+full subset rules are in `SPEC.md`.
-## First slice
+The statements a reviewer approves are in `Spec.lean`,
+with their proofs in `Proofs/SpecProofs.lean`. No reviewer
+has approved them yet, so this project is not called
+verified.
-Elements, attributes, text, comments, processing instructions, character references, and CDATA sections. One root element. Attribute values are quoted. No document type declaration, no entity declarations, and no namespaces in this slice. A document containing a document type declaration is rejected, not parsed leniently.
+## How to run it
-## Main claim
+Needs Linux and [elan](https://github.com/leanprover/elan).
+`lake build` builds the library, the proofs, and the
+command line tool. The tool reads one request per line:
+the word `parse`, a space, and the text as
+comma-separated decimal character codes. It answers
+`ok:` and the character codes of the canonical print
+when the text parses, `none` when it is rejected, and
+`error` when the request itself is malformed.
-Printing a parsed document gives a document that parses to the same tree. A document with a document type declaration, an undefined entity reference, or mismatched tags is rejected. The parser never expands an entity, because no entities exist in the subset.
+```
+echo "parse 60,97,47,62" | .lake/build/bin/xml
+```
-## Test oracles
+That request is the text ``, and the answer is
+`ok:60,97,47,62`. See `SPEC.md` for the full protocol.
-- Python ElementTree, Go encoding/xml, and Rust quick-xml, each in a strict subset mode. These parsers disagree outside the subset, on document type and entity handling. The differential harness counts out of subset cases separately, not as failures.
+`python3 tests/run_tests.py` rebuilds the tool if needed,
+runs the fixed cases, and runs the differential tests
+against Python's `xml.etree.ElementTree` in subset mode
+with a fixed seed, writing the counts to
+`tests/TEST-RESULTS.md`.
-## Score
+## What is proved
-14 out of 18, first pass score in mission issue 19, the round 4 sheet. Created at the org owner's direction on that sheet, on the judgement that the slice, claim, and oracles are sound and that it duplicates no existing repo.
+Each claim below names the theorem behind it. The
+theorems are stated in `Spec.lean` (the challenge file,
+where they end in `sorry`) and proved in
+`Proofs/SpecProofs.lean`, under the same names.
+`comparator.json` lists all of them.
-## What is not in this slice
+| Claim | Theorem |
+|-------|---------|
+| Parsing the printed form of a well formed document gives the document back. | `Spec.parse_print` |
+| A document carrying a document type declaration is refused. | `Spec.rejects_doctype` |
+| A reference to an entity the subset does not define is refused. | `Spec.rejects_undefined_entity` |
+| An end tag that does not match the start tag is refused. | `Spec.rejects_mismatched_tags` |
+| An element with no end tag is refused. | `Spec.rejects_unclosed_element` |
+| A second root element is refused. | `Spec.rejects_second_root` |
+| Character data before the root element is refused. | `Spec.rejects_text_before_root` |
+| An attribute name standing twice on one element is refused. | `Spec.rejects_duplicate_attribute` |
+| A raw less sign inside an attribute value is refused. | `Spec.rejects_lt_in_attribute` |
+| The run `]]>` inside text is refused. | `Spec.rejects_cdata_close_in_text` |
+| Two dashes in a row inside a comment body are refused. | `Spec.rejects_dashdash_in_comment` |
+| A character reference naming a surrogate value is refused. | `Spec.rejects_surrogate_ref` |
+| A character reference naming zero is refused. | `Spec.rejects_zero_ref` |
+| A colon in a name is refused. | `Spec.rejects_colon_in_name` |
+| A name starting with a digit is refused. | `Spec.rejects_digit_name` |
+| An attribute value with no quotes is refused. | `Spec.rejects_unquoted_attribute` |
+| A CDATA section in the prolog is refused. | `Spec.rejects_cdata_in_prolog` |
+| The XML declaration form is refused. | `Spec.rejects_xml_declaration` |
-- Document type declarations.
-- Entities.
-- Namespaces.
-- Full XML 1.0.
+A document is well formed when its names match the
+subset's name rule, attribute names are unique per
+element, text holds only legal XML characters and no two
+text runs stand next to each other, comment bodies
+contain no `--` and do not end with `-`, processing
+instruction targets are valid and not the reserved word
+`xml`, and the prolog and epilog hold only comments and
+processing instructions. `SPEC.md` states the rules in
+full.
-## Layout, when the first slice lands
+The round trip theorem rests on the proof in
+`Proofs/RoundTrip.lean`, where it is
+`Xml.Proofs.parse_print`. It uses no axiom but the three
+standard ones (`propext`, `Classical.choice`,
+`Quot.sound`): `comparator.json` names those three as the
+permitted axioms, and `lake comparator` checks each proof
+in `Proofs/SpecProofs.lean` against its statement in
+`Spec.lean` and against that list. The refusal theorems
+are single inputs checked in the kernel with
+`decide +kernel`.
-The layout follows the Proven Commons template in the mission repo, under bar/template. Spec.lean holds the statements a reviewer approves. Proofs.lean holds the proofs under the same names. Main.lean holds the command line tool used by the tests. The bar check runs in CI on every change.
+The command line tool in `Main.lean` calls `Xml.parse`
+and `Xml.print` directly, the same functions the theorems
+are about.
+
+## What is not proved
+
+- That the parser accepts every text the subset allows,
+ and rejects every text it forbids. The round trip does
+ not need either, and the refusal theorems pin single
+ inputs only, so neither holds in general as a proof.
+ The differential tests check that behaviour instead,
+ and the results file records the outcome, including any
+ failure.
+- Anything about documents outside the subset: documents
+ with a document type declaration, entities beyond the
+ five predefined ones, namespaces, an XML declaration,
+ or non-ASCII names. The parser rejects them; whether a
+ full XML parser would accept them is not a claim here.
+- Reading requests and printing answers in `Main.lean`:
+ splitting lines, the decimal parser, and the check that
+ a code is a Unicode scalar value. The tests cover them,
+ malformed requests included.
+- Speed. The code works on lists of characters, which
+ suits proofs, not large data. The parser's fuel is the
+ length of the input text, which very deep inputs can
+ reach.
+
+Three deliberate choices, also stated in `SPEC.md`:
+
+- CDATA sections are a spelling, not a tree difference.
+ CDATA content becomes ordinary character data in the
+ same text node stream, and the printer never writes a
+ CDATA section. The claim is at tree level, so this is
+ sound.
+- There is no line ending normalization in text,
+ comments, processing instruction data, or CDATA: a
+ literal carriage return reads as itself. Full XML
+ normalizes it to a line feed. Inside attribute values
+ both readings agree (either form reads as a space), and
+ the printer always writes a carriage return as a
+ character reference.
+- The XML declaration is not in the model. A processing
+ instruction target of `xml` in any letter case rejects
+ everywhere, which keeps the declaration out.
+
+## What you must trust
+
+- The Lean kernel (Lean 4.35.0, pinned in
+ `lean-toolchain`), which checks every proof.
+- The Lean compiler, which turns the proved definitions
+ into the program that runs.
+- The Lean runtime, which runs that program.
+- The hardware the program runs on.
+- Every dependency in `lake-manifest.json`: the manifest
+ lists no packages, so there are no library dependencies
+ to trust beyond the Lean toolchain itself.
+- That `SPEC.md` states the reading of XML 1.0 you
+ intend. The proofs are about the parser and printer in
+ this repo following each other exactly; the fidelity of
+ the spec to XML 1.0 is a reading, pinned in SPEC.md
+ where the standard leaves a choice open.
+- Python's standard library, as the oracle in the
+ differential tests. The results file names the version
+ used. The plan also names Go's encoding/xml and Rust's
+ quick-xml as oracles; neither is wired in yet, and the
+ harness says so rather than counting them.
+- There are no exceptions to item 4 of the bar: the
+ project uses none of the features it forbids.
+
+## Re-check it
+
+1. `lake comparator --paranoid`: builds the challenge
+ file `Spec.lean` and the proofs in a sandbox and
+ checks that each theorem named in `comparator.json`
+ proves exactly the statement in the challenge file,
+ uses no axiom but the three standard ones, and is
+ accepted by Lean's kernel and every other kernel
+ shipped with Lean.
+2. `python3 tests/run_tests.py`: the fixed cases, the
+ named rejections, and the differential run against
+ Python's `xml.etree.ElementTree`, with counts and any
+ failures written to `tests/TEST-RESULTS.md`.
+3. `lake build`: the library, the proofs, and the tool
+ build clean.
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 0000000..e3f5956
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,261 @@
+# Specification: xml
+
+A parser and printer in Lean for a safe subset of XML 1.0 (Fifth
+Edition), narrowed as stated here. A reviewer who did not write
+this specification approves it, together with the statement file
+`Spec.lean`. No reviewer has approved them yet, so nothing here
+counts toward the verified bar yet.
+
+Text is `List Char`. A Lean `Char` is one Unicode scalar value.
+
+## The subset in one paragraph
+
+A document is a prolog of comments and processing instructions,
+one root element, and an epilog of comments and processing
+instructions. There is no XML declaration, no document type
+declaration, no entity declarations, and no namespaces. The only
+entity references are the five predefined ones, plus character
+references. Anything outside the rules below is rejected, never
+parsed leniently.
+
+## Documents
+
+- The prolog and the epilog hold only comments and processing
+ instructions, with whitespace allowed between them and around
+ the root. Any other text at the top level (character data, a
+ second element, a document type declaration, a CDATA section)
+ rejects.
+- There is exactly one root element.
+- The XML declaration is not part of this subset. A processing
+ instruction whose target is `xml`, in any letter case, rejects
+ everywhere, which keeps the declaration form (``)
+ out. A producer that writes a declaration can be asked for the
+ same document without it. Other targets that merely start with
+ the letters xml, such as `xml-stylesheet`, are ordinary
+ processing instructions.
+
+## Names
+
+- A name starts with an ASCII letter (A to Z, a to z) or an
+ underscore.
+- Later characters may also be ASCII digits, a hyphen, or a dot.
+- A colon is never allowed in a name in this subset. In XML a
+ colon signals namespace use, and namespaces are out.
+- Non-ASCII names are out of the subset. XML 1.0 allows far
+ more; that is later work, not this slice.
+
+## Elements and attributes
+
+- An element is a name, attributes, and either an empty element
+ tag (``) or content and a matching end tag (`...`).
+ Mismatched tags reject.
+- An attribute is a name, an equals sign, and a quoted value.
+ Single and double quotes are both read; the printer writes
+ double quotes.
+- A duplicate attribute name on one element rejects.
+- Whitespace may appear between attributes, around the equals
+ sign, and before the closing bracket of a start tag or an end
+ tag.
+- In an attribute value: character references and the five
+ predefined entities are expanded on read. A raw `<` rejects.
+ A raw `&` that does not begin a valid reference rejects.
+- Attribute value normalization: a literal tab or line feed in
+ a value reads as one space, and a literal carriage return or
+ carriage return with line feed reads as one space. This is
+ XML's rule for CDATA type attributes, and every attribute here
+ is CDATA type because there is no document type declaration
+ to say otherwise. A tab or line feed spelled as a character
+ reference is not normalized; it reads as itself. The model
+ holds the normalized value, so printing and reparsing is
+ stable.
+
+## Text
+
+- Character data runs between markup. A raw `<` begins markup,
+ never text. A raw `&` must begin a valid reference.
+- The five predefined entities decode: `<`, `>`, `&`,
+ `'`, `"`. Any other entity reference rejects,
+ because no entities exist in the subset.
+- A character reference (`A` decimal, `A` hex; the x is
+ lowercase only) decodes to its character. The value must be a
+ legal XML character: tab, line feed, carriage return, 0x20 to
+ 0xD7FF, 0xE000 to 0xFFFD, or 0x10000 to 0x10FFFF. Anything
+ else (zero, a control character, a surrogate value, a value
+ past 0x10FFFF) rejects.
+- A raw character in text, a comment, a processing instruction,
+ a CDATA section, or an attribute value must also be a legal
+ XML character, or the text rejects.
+- The sequence `]]>` inside text rejects, as XML requires
+ outside a CDATA section.
+- Adjacent character data merges into one text node in the
+ model: plain text, character references, and CDATA sections
+ between two markup items form a single run. The printer
+ writes one run, and the round trip claim is about this merged
+ tree.
+
+## CDATA sections
+
+- A CDATA section (``) may appear inside
+ element content only, never in the prolog or the epilog.
+- The text inside is literal. In the model it becomes ordinary
+ character data in the same text node stream as its neighbours.
+ CDATA is a spelling, not a tree difference, exactly as the
+ choice of quote in an attribute is a spelling. The printer
+ never writes a CDATA section.
+
+## Comments
+
+- A comment is ``. The body must not
+ contain `--` and must not end with `-`. Both are XML's rules,
+ and they keep the closing marks unambiguous.
+- The body is carried as the characters between the marks.
+- A comment may appear in the prolog, in content, or in the
+ epilog, and is a node in place.
+
+## Processing instructions
+
+- A processing instruction is ``, a target name (the name
+ rule above), and then either the closing marks `?>` at once,
+ or whitespace and data up to `?>`.
+- The target `xml` in any letter case is reserved and rejects
+ (see Documents).
+- The data is carried as text. It may be empty. It must not
+ contain `?>`, and in the model it never starts with
+ whitespace: the whitespace after the target is a separator,
+ not part of the data.
+
+## Document type declarations and other ``. An element read from `` has no content
+ and also prints as ``: same tree, different spelling.
+- In text: `&` as `&`, `<` as `<`, `>` as `>` (always
+ escaping `>` keeps `]]>` from ever forming in printed text),
+ and carriage return as `
`. Every other character prints
+ as itself.
+- In attribute values: `&` as `&`, `<` as `<`, `"` as
+ `"`, and tab, line feed, and carriage return as
+ ` `, `
`, `
`. A literal tab in a printed value
+ would read back as a space under normalization, so character
+ references are how those characters are kept.
+- A processing instruction with no data prints as ``;
+ with data, as `` with one space.
+- A document prints as its prolog, its root, and its epilog,
+ with nothing between them.
+
+## Well formed trees
+
+The round trip is claimed for trees that satisfy a well
+formedness predicate (`WF` in `Xml/Value.lean`):
+
+- every name matches the name rule above;
+- attribute names are unique per element, and attribute values
+ hold only legal XML characters;
+- text nodes are not empty, hold only legal XML characters,
+ and two text nodes never stand next to each other (the
+ parser merges them, so an unmerged tree could not come
+ back);
+- comment bodies hold only legal characters, contain no `--`,
+ and do not end with `-`;
+- processing instruction targets are valid names and not the
+ reserved word, their data holds only legal characters, never
+ contains `?>`, and never starts with whitespace;
+- the root is an element, and the prolog and epilog hold only
+ comments and processing instructions.
+
+## Properties and their theorems
+
+Each property a reader can check in the page (`README.md`)
+names its theorem. The theorems are stated in `Spec.lean`
+and proved in `Proofs/SpecProofs.lean`. The refusal
+theorems are single inputs; the tests in `tests/` cover
+each rule on many more inputs.
+
+| Property | Theorem |
+|----------|---------|
+| Parsing the printed form of a well formed document gives the document back | `Spec.parse_print` |
+| A document carrying a document type declaration is refused | `Spec.rejects_doctype` |
+| A reference to an entity the subset does not define is refused | `Spec.rejects_undefined_entity` |
+| An end tag that does not match the start tag is refused | `Spec.rejects_mismatched_tags` |
+| An element with no end tag is refused | `Spec.rejects_unclosed_element` |
+| A second root element is refused | `Spec.rejects_second_root` |
+| Character data before the root element is refused | `Spec.rejects_text_before_root` |
+| An attribute name standing twice on one element is refused | `Spec.rejects_duplicate_attribute` |
+| A raw less sign inside an attribute value is refused | `Spec.rejects_lt_in_attribute` |
+| The run `]]>` inside text is refused | `Spec.rejects_cdata_close_in_text` |
+| Two dashes in a row inside a comment body are refused | `Spec.rejects_dashdash_in_comment` |
+| A character reference naming a surrogate value is refused | `Spec.rejects_surrogate_ref` |
+| A character reference naming zero is refused | `Spec.rejects_zero_ref` |
+| A colon in a name is refused | `Spec.rejects_colon_in_name` |
+| A name starting with a digit is refused | `Spec.rejects_digit_name` |
+| An attribute value with no quotes is refused | `Spec.rejects_unquoted_attribute` |
+| A CDATA section in the prolog is refused | `Spec.rejects_cdata_in_prolog` |
+| The XML declaration form is refused | `Spec.rejects_xml_declaration` |
+
+The round trip behind `Spec.parse_print` is
+`Xml.Proofs.parse_print` in `Proofs/RoundTrip.lean`. Its
+proofs rest on helper round trips proved in the same file:
+one element and a list of content nodes together
+(`Xml.Proofs.elem_nodes`), a prolog or epilog list
+(`Xml.Proofs.parseMisc_print`), a printed comment body
+(`Xml.Proofs.parseCommentBody_print`), a printed
+processing instruction (`Xml.Proofs.parsePI_print`), and
+escaped text and attribute values read back
+(`Xml.Proofs.parseCharData_escapeText`,
+`Xml.Proofs.parseAttrValue_escapeAttr`). Each proof in
+`Proofs/SpecProofs.lean` uses no axiom but the three
+standard ones (`propext`, `Classical.choice`,
+`Quot.sound`): `comparator.json` names those three as the
+permitted axioms, and `lake comparator` checks the proofs
+against that list.
+
+## Boundaries and named differences
+
+- No line ending normalization in text, comments, processing
+ instruction data, or CDATA sections: a literal carriage
+ return there reads as itself. Full XML normalizes carriage
+ return and carriage return with line feed to a line feed
+ before parsing, and Python's ElementTree does so. That is a
+ difference between this subset and full XML, stated plainly:
+ a text a full XML parser reads one way can read with a line
+ feed where this parser keeps a carriage return. Inside
+ attribute values both readings agree, because normalization
+ there sends either form to a space, and in printed form a
+ carriage return is always a character reference.
+- No document type declaration, no entities beyond the five,
+ no namespaces, no XML declaration, ASCII names only. A full
+ XML parser may accept documents this slice rejects for
+ exactly those reasons; the test harness counts those as out
+ of subset disagreements, not failures.
+- ElementTree, the one oracle wired into the tests, drops the
+ prolog and epilog when it reads a document, so the harness
+ compares ElementTree's tree for the root element and checks
+ the prolog and epilog through the canonical print. The plan
+ also names Go's encoding/xml and Rust's quick-xml as oracles;
+ neither is wired in yet, and the harness says so rather than
+ counting them.
+
+## The request protocol
+
+The command line tool in `Main.lean` reads one request per
+line and prints one answer per line. A request is the operation
+`parse`, then one space, then the input as comma-separated
+decimal character codes. The answer is `ok:` and the character
+codes of the canonical print, `none` when the text is
+rejected, and `error` when the request itself is malformed (an
+unknown operation, a number that is not plain decimal digits,
+or a code that is not a Unicode scalar value). A request with
+no codes is the empty input, which answers `none`. A line may
+end in LF or CR LF.
diff --git a/Spec.lean b/Spec.lean
new file mode 100644
index 0000000..86070d4
--- /dev/null
+++ b/Spec.lean
@@ -0,0 +1,115 @@
+/-
+ The specification as Lean statements: the challenge file.
+
+ A reviewer who did not write this file approves it, and `bar.toml`
+ records the SHA-256 it had then. The proofs live in `Proofs`
+ (`Proofs/SpecProofs.lean`); `lake comparator` checks that each one
+ proves exactly the statement written here. The `sorry` below is
+ expected in this file only.
+
+ The statements use only the definitions in `Xml/Value.lean` and
+ `Xml/Parse.lean`, the code under proof named in `bar.toml`. No
+ definition of their own is needed, so this project has no
+ `Spec/Defs.lean`.
+-/
+
+import Xml
+
+open Xml
+
+/-! ## The round trip
+
+The statement the slice stands on. It is the statement of the
+proved theorem of the same shape in `Proofs/RoundTrip.lean`, over
+the same well formedness predicate, `Xml.WF`. -/
+
+/-- Parsing the printed form of a well formed document gives the
+ document back. So a printer that wrote nothing, or wrote
+ something shorter, fails this statement. -/
+theorem Spec.parse_print (d : Document) (hwf : WF d) :
+ parse (print d) = some d := sorry
+
+/-! ## Refused inputs
+
+Single inputs the parser refuses, one for each headline rule of
+`SPEC.md`. Each input is one of the named rejection cases in the
+test harness. The differential tests cover these rules on many
+more inputs; these statements pin them so a reader of the
+specification can check each rule against one concrete case. -/
+
+/-- A document carrying a document type declaration is refused,
+ not read leniently. -/
+theorem Spec.rejects_doctype :
+ (parse "".toList).isSome = false := sorry
+
+/-- A reference to an entity the subset does not define is
+ refused: no entity is ever expanded, because none exist. -/
+theorem Spec.rejects_undefined_entity :
+ (parse " ".toList).isSome = false := sorry
+
+/-- An end tag that does not match the start tag is refused. -/
+theorem Spec.rejects_mismatched_tags :
+ (parse "".toList).isSome = false := sorry
+
+/-- An element with no end tag is refused. -/
+theorem Spec.rejects_unclosed_element :
+ (parse "".toList).isSome = false := sorry
+
+/-- A document has one root element; a second one is refused. -/
+theorem Spec.rejects_second_root :
+ (parse "".toList).isSome = false := sorry
+
+/-- Character data before the root element is refused. -/
+theorem Spec.rejects_text_before_root :
+ (parse "x".toList).isSome = false := sorry
+
+/-- An attribute name standing twice on one element is refused. -/
+theorem Spec.rejects_duplicate_attribute :
+ (parse "".toList).isSome = false := sorry
+
+/-- A raw less sign inside an attribute value is refused. -/
+theorem Spec.rejects_lt_in_attribute :
+ (parse "".toList).isSome = false := sorry
+
+/-- The run `]]>` inside text is refused, as XML requires
+ outside a CDATA section. -/
+theorem Spec.rejects_cdata_close_in_text :
+ (parse "x]]>y".toList).isSome = false := sorry
+
+/-- Two dashes in a row inside a comment body are refused. -/
+theorem Spec.rejects_dashdash_in_comment :
+ (parse "".toList).isSome = false := sorry
+
+/-- A character reference naming a surrogate value is refused:
+ a surrogate is not a legal XML character. -/
+theorem Spec.rejects_surrogate_ref :
+ (parse "".toList).isSome = false := sorry
+
+/-- A character reference naming zero is refused: zero is not a
+ legal XML character. -/
+theorem Spec.rejects_zero_ref :
+ (parse "".toList).isSome = false := sorry
+
+/-- A colon in a name is refused: in XML a colon signals
+ namespace use, and namespaces are out of this subset. -/
+theorem Spec.rejects_colon_in_name :
+ (parse "".toList).isSome = false := sorry
+
+/-- A name starting with a digit is refused. -/
+theorem Spec.rejects_digit_name :
+ (parse "<1a/>".toList).isSome = false := sorry
+
+/-- An attribute value with no quotes is refused. -/
+theorem Spec.rejects_unquoted_attribute :
+ (parse "".toList).isSome = false := sorry
+
+/-- A CDATA section in the prolog is refused: CDATA stands in
+ element content only. -/
+theorem Spec.rejects_cdata_in_prolog :
+ (parse "".toList).isSome = false := sorry
+
+/-- The XML declaration form is refused: the declaration is not
+ part of this subset, and a target of `xml` rejects
+ everywhere. -/
+theorem Spec.rejects_xml_declaration :
+ (parse "".toList).isSome = false := sorry
diff --git a/Xml.lean b/Xml.lean
new file mode 100644
index 0000000..d2357ca
--- /dev/null
+++ b/Xml.lean
@@ -0,0 +1,2 @@
+import Xml.Value
+import Xml.Parse
diff --git a/Xml/Parse.lean b/Xml/Parse.lean
new file mode 100644
index 0000000..b8f8834
--- /dev/null
+++ b/Xml/Parse.lean
@@ -0,0 +1,423 @@
+/-
+ The XML parser for the safe subset. It reads a list of characters
+ and returns a document, or `none` when the text sits outside the
+ subset grammar `SPEC.md` states.
+
+ Rules the parser follows:
+
+ - Any ` []
+ | c :: cs => if isWs c then skipWs cs else c :: cs
+
+/-- A digit, as a test. -/
+def isDigit (c : Char) : Bool :=
+ decide ('0' ≤ c ∧ c ≤ '9')
+
+/-- The value of one hex digit, in either case. -/
+def hexVal (c : Char) : Option Nat :=
+ if '0' ≤ c ∧ c ≤ '9' then some (c.toNat - 48)
+ else if 'a' ≤ c ∧ c ≤ 'f' then some (c.toNat - 87)
+ else if 'A' ≤ c ∧ c ≤ 'F' then some (c.toNat - 55)
+ else none
+
+/-- A hex digit, as a test. -/
+def isHexDigit (c : Char) : Bool :=
+ (hexVal c).isSome
+
+/-- The longest run of decimal digits at the head of the text, and
+ the text after it. -/
+def takeDigits : List Char → List Char × List Char
+ | [] => ([], [])
+ | c :: rest =>
+ if isDigit c then
+ let (ds, r) := takeDigits rest
+ (c :: ds, r)
+ else ([], c :: rest)
+
+/-- The longest run of hex digits at the head of the text, and the
+ text after it. -/
+def takeHexDigits : List Char → List Char × List Char
+ | [] => ([], [])
+ | c :: rest =>
+ if isHexDigit c then
+ let (ds, r) := takeHexDigits rest
+ (c :: ds, r)
+ else ([], c :: rest)
+
+/-- The value of a run of digits in the given base, where `dv`
+ gives the value of one digit. An empty run has value zero,
+ which the character reference check then rejects, since zero
+ names no legal character. -/
+def digitsValue (base : Nat) (dv : Char → Nat) (ds : List Char) : Nat :=
+ ds.foldl (fun acc d => acc * base + dv d) 0
+
+/-- The character a character reference value names, when the
+ value is a legal XML character; otherwise rejection. -/
+def charRefChar (n : Nat) : Option Char :=
+ if n = 9 ∨ n = 10 ∨ n = 13 ∨ (0x20 ≤ n ∧ n ≤ 0xD7FF) ∨
+ (0xE000 ≤ n ∧ n ≤ 0xFFFD) ∨ (0x10000 ≤ n ∧ n ≤ 0x10FFFF)
+ then some (Char.ofNat n)
+ else none
+
+/-- Read a reference, at the text after the ampersand: one of the
+ five predefined entities, or a character reference. Returns
+ the character the reference stands for and the text after the
+ semicolon. -/
+def parseRef : List Char → Option (Char × List Char)
+ | 'l' :: 't' :: ';' :: rest => some ('<', rest)
+ | 'g' :: 't' :: ';' :: rest => some ('>', rest)
+ | 'a' :: 'm' :: 'p' :: ';' :: rest => some ('&', rest)
+ | 'a' :: 'p' :: 'o' :: 's' :: ';' :: rest => some ('\'', rest)
+ | 'q' :: 'u' :: 'o' :: 't' :: ';' :: rest => some ('"', rest)
+ | '#' :: rest =>
+ if rest.head? = some 'x' then
+ let (ds, r) := takeHexDigits rest.tail
+ match r with
+ | ';' :: r2 => (charRefChar (digitsValue 16 (fun d => (hexVal d).getD 0) ds)).map
+ fun ch => (ch, r2)
+ | _ => none
+ else
+ let (ds, r) := takeDigits rest
+ match r with
+ | ';' :: r2 => (charRefChar (digitsValue 10 (fun d => d.toNat - 48) ds)).map
+ fun ch => (ch, r2)
+ | _ => none
+ | _ => none
+
+/-- The longest run of name characters at the head of the text,
+ and the text after it. -/
+def takeNameChars : List Char → List Char × List Char
+ | [] => ([], [])
+ | c :: rest =>
+ if isNameChar c then
+ let (ds, r) := takeNameChars rest
+ (c :: ds, r)
+ else ([], c :: rest)
+
+/-- Read a name at the head of the text: a name start character,
+ then name characters. -/
+def parseName : List Char → Option (List Char × List Char)
+ | c :: rest =>
+ if isNameStart c then
+ let (ds, r) := takeNameChars rest
+ some (c :: ds, r)
+ else none
+ | [] => none
+
+/-- Read character data up to the next less sign or the end of the
+ text: plain characters that are legal XML characters, and
+ references. The run `]]>` is rejected, as XML requires. The
+ fuel bounds the number of characters and references read,
+ since a reference can stand for one character while taking
+ several. -/
+def parseTextRun : Nat → List Char → Option (List Char × List Char)
+ | 0, _ => none
+ | _ + 1, [] => some ([], [])
+ | fuel + 1, c :: rest =>
+ if c = '<' then some ([], c :: rest)
+ else if c = '&' then
+ match parseRef rest with
+ | some (ch, r) => (parseTextRun fuel r).map fun (t, r2) => (ch :: t, r2)
+ | none => none
+ else if c = ']' then
+ if rest.head? = some ']' ∧ (rest.tail).head? = some '>' then none
+ else (parseTextRun fuel rest).map fun (t, r) => (c :: t, r)
+ else if isLegalChar c then
+ (parseTextRun fuel rest).map fun (t, r) => (c :: t, r)
+ else none
+
+/-- Read the body of a CDATA section, up to the closing marks. The
+ characters are literal: no reference is read, and the run `]]>`
+ ends the section wherever it stands. -/
+def parseCDATABody : List Char → Option (List Char × List Char)
+ | [] => none
+ | ']' :: ']' :: '>' :: rest => some ([], rest)
+ | c :: rest =>
+ if isLegalChar c then
+ (parseCDATABody rest).map fun (t, r) => (c :: t, r)
+ else none
+
+/-- Read the body of a comment, up to the closing marks. Two
+ dashes in a row inside the body are rejected, as XML requires,
+ so the first closing marks end the comment. -/
+def parseCommentBody : List Char → Option (List Char × List Char)
+ | [] => none
+ | c :: rest =>
+ if c = '-' then
+ match rest with
+ | '-' :: '>' :: r => some ([], r)
+ | '-' :: _ => none
+ | _ =>
+ (parseCommentBody rest).map fun (b, r) => (c :: b, r)
+ else if isLegalChar c then
+ (parseCommentBody rest).map fun (b, r) => (c :: b, r)
+ else none
+
+/-- Read the data of a processing instruction, up to the closing
+ marks. The data is literal. -/
+def parsePIData : List Char → Option (List Char × List Char)
+ | [] => none
+ | c :: rest =>
+ if c = '?' then
+ match rest with
+ | '>' :: r => some ([], r)
+ | _ =>
+ (parsePIData rest).map fun (d, r) => (c :: d, r)
+ else if isLegalChar c then
+ (parsePIData rest).map fun (d, r) => (c :: d, r)
+ else none
+
+/-- Read an attribute value, at the text after the opening quote
+ `q`. Returns the normalized value and the text after the
+ closing quote. A literal less sign is rejected; a literal tab
+ or line feed reads as a space; a literal carriage return reads
+ as one space, with a line feed right after it read as part of
+ the same return. References read as the characters they name,
+ with no normalization. The fuel bounds the number of
+ characters and references read, as in `parseTextRun`. -/
+def parseAttrValue (q : Char) : Nat → List Char → Option (List Char × List Char)
+ | 0, _ => none
+ | _ + 1, [] => none
+ | fuel + 1, c :: rest =>
+ if c = q then some ([], rest)
+ else if c = '<' then none
+ else if c = '&' then
+ match parseRef rest with
+ | some (ch, r) => (parseAttrValue q fuel r).map fun (v, r2) => (ch :: v, r2)
+ | none => none
+ else if c = '\t' ∨ c = '\n' then
+ (parseAttrValue q fuel rest).map fun (v, r) => (' ' :: v, r)
+ else if c = '\r' then
+ match rest with
+ | '\n' :: rest2 =>
+ (parseAttrValue q fuel rest2).map fun (v, r) => (' ' :: v, r)
+ | _ =>
+ (parseAttrValue q fuel rest).map fun (v, r) => (' ' :: v, r)
+ else if isLegalChar c then
+ (parseAttrValue q fuel rest).map fun (v, r) => (c :: v, r)
+ else none
+
+/-- Read one processing instruction, at the text after ``:
+ the target name, then either the closing marks or whitespace
+ and the data. The target `xml`, in any case, is rejected: it is
+ the XML declaration form, and the reservation holds everywhere
+ in this subset. -/
+def parsePI (cs : List Char) : Option (Node × List Char) :=
+ match parseName cs with
+ | some (target, r1) =>
+ if target.map Char.toLower = ['x', 'm', 'l'] then none
+ else
+ match r1 with
+ | '?' :: '>' :: r2 => some (.pi target [], r2)
+ | c :: _ =>
+ if isWs c then
+ match parsePIData (skipWs r1) with
+ | some (d, r2) => some (.pi target d, r2)
+ | none => none
+ else none
+ | [] => none
+ | none => none
+
+/-- Read character data at the head of the text: plain runs,
+ references, and CDATA sections, merged into one list of
+ characters. Stops before a less sign that does not open a CDATA
+ section, and at the end of the text. -/
+def parseCharData : Nat → List Char → Option (List Char × List Char)
+ | 0, _ => none
+ | _ + 1, [] => some ([], [])
+ | fuel + 1, '<' :: '!' :: '[' :: 'C' :: 'D' :: 'A' :: 'T' :: 'A' :: '[' :: rest =>
+ match parseCDATABody rest with
+ | some (t, r) => (parseCharData fuel r).map fun (t2, r2) => (t ++ t2, r2)
+ | none => none
+ | fuel + 1, '<' :: rest => some ([], '<' :: rest)
+ | fuel + 1, cs =>
+ match parseTextRun fuel cs with
+ | some (t, r) => (parseCharData fuel r).map fun (t2, r2) => (t ++ t2, r2)
+ | none => none
+
+/-- Read the attributes of a start tag, and how the tag ends.
+ Attributes read so far are in `acc`, in order. Returns the
+ attributes, the text after the tag, and whether the tag was
+ the empty form. A repeated attribute name is rejected. -/
+def parseAttrs : Nat → List (List Char × List Char) → List Char →
+ Option (List (List Char × List Char) × List Char × Bool)
+ | 0, _, _ => none
+ | fuel + 1, acc, cs =>
+ match skipWs cs with
+ | '/' :: '>' :: rest => some (acc, rest, true)
+ | '>' :: rest => some (acc, rest, false)
+ | c :: _ =>
+ if isNameStart c then
+ match parseName (skipWs cs) with
+ | some (aname, r1) =>
+ match skipWs r1 with
+ | '=' :: r2 =>
+ match skipWs r2 with
+ | '"' :: r3 =>
+ match parseAttrValue '"' fuel r3 with
+ | some (v, r4) =>
+ if acc.any (fun kv => kv.1 = aname) then none
+ else parseAttrs fuel (acc ++ [(aname, v)]) r4
+ | none => none
+ | '\'' :: r3 =>
+ match parseAttrValue '\'' fuel r3 with
+ | some (v, r4) =>
+ if acc.any (fun kv => kv.1 = aname) then none
+ else parseAttrs fuel (acc ++ [(aname, v)]) r4
+ | none => none
+ | _ => none
+ | _ => none
+ | none => none
+ else none
+ | [] => none
+
+mutual
+/-- Read one element, at the less sign of its start tag. -/
+def parseElement : Nat → List Char → Option (Node × List Char)
+ | 0, _ => none
+ | fuel + 1, cs =>
+ match cs with
+ | '<' :: rest =>
+ match parseName rest with
+ | some (name, r1) =>
+ match parseAttrs fuel [] r1 with
+ | some (attrs, r2, true) => some (.elem name attrs [], r2)
+ | some (attrs, r2, false) =>
+ match parseNodes fuel r2 with
+ | some (content, r3) =>
+ match r3 with
+ | '<' :: '/' :: r4 =>
+ match parseName r4 with
+ | some (ename, r5) =>
+ match skipWs r5 with
+ | '>' :: r6 =>
+ if ename = name then some (.elem name attrs content, r6)
+ else none
+ | _ => none
+ | none => none
+ | _ => none
+ | none => none
+ | none => none
+ | none => none
+ | _ => none
+
+/-- Read the content of an element: nodes up to the end tag, which
+ is not consumed. Also returns at the end of the text. -/
+def parseNodes : Nat → List Char → Option (List Node × List Char)
+ | 0, _ => none
+ | fuel + 1, cs =>
+ match cs with
+ | [] => some ([], [])
+ | '<' :: '/' :: _ => some ([], cs)
+ | '<' :: '!' :: '-' :: '-' :: rest =>
+ match parseCommentBody rest with
+ | some (body, r) =>
+ (parseNodes fuel r).map fun (ns, r2) => (.comment body :: ns, r2)
+ | none => none
+ | '<' :: '!' :: '[' :: 'C' :: 'D' :: 'A' :: 'T' :: 'A' :: '[' :: _ =>
+ match parseCharData fuel cs with
+ | some (t, r) =>
+ if t = [] then parseNodes fuel r
+ else (parseNodes fuel r).map fun (ns, r2) => (.text t :: ns, r2)
+ | none => none
+ | '<' :: '?' :: rest =>
+ match parsePI rest with
+ | some (n, r) =>
+ (parseNodes fuel r).map fun (ns, r2) => (n :: ns, r2)
+ | none => none
+ | '<' :: c :: _ =>
+ if isNameStart c then
+ match parseElement fuel cs with
+ | some (n, r) =>
+ (parseNodes fuel r).map fun (ns, r2) => (n :: ns, r2)
+ | none => none
+ else none
+ | '<' :: [] => none
+ | _ =>
+ match parseCharData fuel cs with
+ | some (t, r) =>
+ if t = [] then parseNodes fuel r
+ else (parseNodes fuel r).map fun (ns, r2) => (.text t :: ns, r2)
+ | none => none
+end
+
+/-- Read the miscellany of a prolog or an epilog: comments and
+ processing instructions, with whitespace around them. Returns
+ the nodes and the text that follows, which starts at the first
+ character that is neither whitespace nor a comment or a
+ processing instruction. -/
+def parseMisc : Nat → List Char → Option (List Node × List Char)
+ | 0, _ => none
+ | fuel + 1, cs =>
+ let s := skipWs cs
+ match s with
+ | '<' :: '!' :: '-' :: '-' :: rest =>
+ match parseCommentBody rest with
+ | some (body, r) =>
+ (parseMisc fuel r).map fun (ns, r2) => (.comment body :: ns, r2)
+ | none => none
+ | '<' :: '?' :: rest =>
+ match parsePI rest with
+ | some (n, r) =>
+ (parseMisc fuel r).map fun (ns, r2) => (n :: ns, r2)
+ | none => none
+ | _ => some ([], s)
+
+/-- Parse a whole text: a prolog, one root element, an epilog, and
+ nothing else. -/
+def parseDocument (fuel : Nat) (cs : List Char) : Option Document :=
+ match parseMisc fuel cs with
+ | some (prolog, r1) =>
+ match parseElement fuel r1 with
+ | some (root, r2) =>
+ match parseMisc fuel r2 with
+ | some (epilog, r3) => if skipWs r3 = [] then some ⟨prolog, root, epilog⟩ else none
+ | none => none
+ | none => none
+ | none => none
+
+/-- Parse a whole text, with the fuel the length of the text
+ allows. -/
+def parse (cs : List Char) : Option Document :=
+ parseDocument (cs.length + 1) cs
+
+end Xml
diff --git a/Xml/Value.lean b/Xml/Value.lean
new file mode 100644
index 0000000..650702b
--- /dev/null
+++ b/Xml/Value.lean
@@ -0,0 +1,271 @@
+/-
+ The XML tree type for the safe subset, the canonical printer, and
+ the well formed predicate that the round trip proof in `Proofs`
+ uses.
+
+ The subset is XML 1.0 narrowed as `SPEC.md` states: elements,
+ attributes, text, comments, and processing instructions, with
+ character references and CDATA sections read as character data.
+ There is no document type declaration, no entity beyond the five
+ predefined ones, no namespace, and no XML declaration.
+
+ A node is one of:
+
+ - `text`: a run of character data, held as a list of characters.
+ Text written as plain characters, as character references, and
+ as CDATA sections all lands here as one run: the three are
+ spellings of the same character data, not different trees.
+ - `elem`: an element: a name, a list of attribute name and value
+ pairs in the order held, and a list of content nodes.
+ - `comment`: a comment, held as the characters between the marks.
+ - `pi`: a processing instruction: a target name and the data
+ after it, held as characters.
+
+ A document is a list of prolog nodes (comments and processing
+ instructions only), one root element, and a list of epilog nodes.
+
+ Attribute values are held normalized, the way the parser reads
+ them: a literal tab or newline in the source is a space in the
+ value, and the five predefined entities and the character
+ references are the characters they stand for.
+-/
+
+namespace Xml
+
+/-- A node in a document or in element content. -/
+inductive Node where
+ | text (cs : List Char)
+ | elem (name : List Char) (attrs : List (List Char × List Char))
+ (content : List Node)
+ | comment (cs : List Char)
+ | pi (target : List Char) (data : List Char)
+
+/-- A document: prolog, one root element, epilog. -/
+structure Document where
+ prolog : List Node
+ root : Node
+ epilog : List Node
+
+/-! ## Characters and names -/
+
+/-- A legal XML character, as a proposition, in code points: tab,
+ line feed, carriage return, then the ranges of the XML 1.0
+ character production. A Lean `Char` is a Unicode scalar value,
+ so no character here can be half of a surrogate pair; the
+ surrogate range is excluded all the same, since a character
+ reference can name it. -/
+def LegalChar (c : Char) : Prop :=
+ c.toNat = 9 ∨ c.toNat = 10 ∨ c.toNat = 13 ∨
+ (0x20 ≤ c.toNat ∧ c.toNat ≤ 0xD7FF) ∨
+ (0xE000 ≤ c.toNat ∧ c.toNat ≤ 0xFFFD) ∨
+ (0x10000 ≤ c.toNat ∧ c.toNat ≤ 0x10FFFF)
+
+/-- The legal character test the parser runs. -/
+def isLegalChar (c : Char) : Bool :=
+ decide (c.toNat = 9 ∨ c.toNat = 10 ∨ c.toNat = 13 ∨
+ (0x20 ≤ c.toNat ∧ c.toNat ≤ 0xD7FF) ∨
+ (0xE000 ≤ c.toNat ∧ c.toNat ≤ 0xFFFD) ∨
+ (0x10000 ≤ c.toNat ∧ c.toNat ≤ 0x10FFFF))
+
+theorem isLegalChar_iff {c : Char} : isLegalChar c = true ↔ LegalChar c := by
+ simp only [isLegalChar, LegalChar, decide_eq_true_eq]
+
+/-- The first character of a name in the subset: an ASCII letter or
+ an underscore. The colon is not a name character in this
+ subset, since namespaces are out. -/
+def IsNameStart (c : Char) : Prop :=
+ ('A' ≤ c ∧ c ≤ 'Z') ∨ ('a' ≤ c ∧ c ≤ 'z') ∨ c = '_'
+
+/-- A later character of a name: a name start character, a digit, a
+ hyphen, or a point. -/
+def IsNameChar (c : Char) : Prop :=
+ IsNameStart c ∨ ('0' ≤ c ∧ c ≤ '9') ∨ c = '-' ∨ c = '.'
+
+/-- The name start test the parser runs. -/
+def isNameStart (c : Char) : Bool :=
+ decide (('A' ≤ c ∧ c ≤ 'Z') ∨ ('a' ≤ c ∧ c ≤ 'z') ∨ c = '_')
+
+theorem isNameStart_iff {c : Char} : isNameStart c = true ↔ IsNameStart c := by
+ simp only [isNameStart, IsNameStart, decide_eq_true_eq]
+
+/-- The name character test the parser runs. -/
+def isNameChar (c : Char) : Bool :=
+ decide ((('A' ≤ c ∧ c ≤ 'Z') ∨ ('a' ≤ c ∧ c ≤ 'z') ∨ c = '_') ∨
+ ('0' ≤ c ∧ c ≤ '9') ∨ c = '-' ∨ c = '.')
+
+theorem isNameChar_iff {c : Char} : isNameChar c = true ↔ IsNameChar c := by
+ simp only [isNameChar, IsNameChar, IsNameStart, decide_eq_true_eq]
+
+/-- A name that follows the subset rule: a name start character,
+ then name characters. -/
+def ValidName (cs : List Char) : Prop :=
+ ∃ c ds, cs = c :: ds ∧ IsNameStart c ∧ ∀ x ∈ ds, IsNameChar x
+
+/-- Whitespace in XML: space, tab, line feed, carriage return, as a
+ proposition. -/
+def IsWs (c : Char) : Prop :=
+ c = ' ' ∨ c = '\t' ∨ c = '\n' ∨ c = '\r'
+
+/-- No two dashes in a row anywhere in the list, stated as: the
+ list is never a part, then two dashes, then a part. The body of
+ a comment must satisfy this. -/
+def NoDashDash (cs : List Char) : Prop :=
+ ∀ pre suf, cs ≠ pre ++ '-' :: '-' :: suf
+
+/-- The list does not end with a dash. The body of a comment must
+ also satisfy this, or the closing marks run into the body. -/
+def NotDashEnd (cs : List Char) : Prop :=
+ ∀ pre, cs ≠ pre ++ ['-']
+
+/-- No `?>` anywhere in the list, stated as: the list is never a
+ part, then a question mark and a greater sign, then a part.
+ The data of a processing instruction must satisfy this. -/
+def NoQClose (cs : List Char) : Prop :=
+ ∀ pre suf, cs ≠ pre ++ '?' :: '>' :: suf
+
+/-- A processing instruction target is reserved when it is the word
+ `xml`, in any case. A document whose first processing
+ instruction target is `xml` is the XML declaration form, which
+ this subset does not hold. -/
+def IsXmlTarget (t : List Char) : Prop :=
+ t.map Char.toLower = ['x', 'm', 'l']
+
+/-! ## The printer -/
+
+/-- The printed form of one character of character data. The
+ ampersand, the less sign, and the greater sign are written as
+ the predefined entities, so a printed text can never hold the
+ forbidden `]]>` run. A carriage return is written as a
+ character reference, so it reads back as itself. Every other
+ character is written as it is. -/
+def escapeTextChar (c : Char) : List Char :=
+ if c = '&' then ['&', 'a', 'm', 'p', ';']
+ else if c = '<' then ['&', 'l', 't', ';']
+ else if c = '>' then ['&', 'g', 't', ';']
+ else if c = '\r' then ['&', '#', '1', '3', ';']
+ else [c]
+
+/-- The printed form of a run of character data. -/
+def escapeText (cs : List Char) : List Char :=
+ cs.flatMap escapeTextChar
+
+/-- The printed form of one character of an attribute value. The
+ ampersand and the less sign are written as entities, the quote
+ as an entity, and tab, line feed, and carriage return as
+ character references, because a literal one of those three
+ would read back as a space under the parser's normalization.
+ Every other character is written as it is. -/
+def escapeAttrChar (c : Char) : List Char :=
+ if c = '&' then ['&', 'a', 'm', 'p', ';']
+ else if c = '<' then ['&', 'l', 't', ';']
+ else if c = '"' then ['&', 'q', 'u', 'o', 't', ';']
+ else if c = '\t' then ['&', '#', '9', ';']
+ else if c = '\n' then ['&', '#', '1', '0', ';']
+ else if c = '\r' then ['&', '#', '1', '3', ';']
+ else [c]
+
+/-- The printed form of an attribute value, without the quotes. -/
+def escapeAttr (cs : List Char) : List Char :=
+ cs.flatMap escapeAttrChar
+
+/-- The printed form of the attribute list: each pair preceded by
+ one space, the name, an equals sign, and the value in double
+ quotes. -/
+def printAttrs : List (List Char × List Char) → List Char
+ | [] => []
+ | (k, v) :: rest =>
+ ' ' :: k ++ ('=' :: '"' :: (escapeAttr v ++ '"' :: printAttrs rest))
+
+mutual
+/-- The canonical printed form of a node. An element with no
+ content prints in the short form. A processing instruction with
+ no data prints with no space before the closing marks. -/
+def printNode : Node → List Char
+ | .text cs => escapeText cs
+ | .elem name attrs content => printElem name attrs content
+ | .comment cs => '<' :: '!' :: '-' :: '-' :: (cs ++ '-' :: '-' :: '>' :: [])
+ | .pi t d =>
+ '<' :: '?' ::
+ (t ++ (if d = [] then ['?', '>'] else ' ' :: d ++ ['?', '>']))
+
+/-- An element in its printed form: the start tag, then the
+ content and the end tag, or the short form when the content is
+ empty. -/
+def printElem (name : List Char) (attrs : List (List Char × List Char))
+ (content : List Node) : List Char :=
+ '<' :: (name ++ printAttrs attrs ++
+ (if content = [] then ['/', '>']
+ else '>' :: (printNodes content ++ '<' :: '/' :: (name ++ ['>']))))
+
+/-- A list of nodes in its printed form, one after another. -/
+def printNodes : List Node → List Char
+ | [] => []
+ | n :: ns => printNode n ++ printNodes ns
+end
+
+/-- A document in its printed form. -/
+def print (d : Document) : List Char :=
+ printNodes d.prolog ++ printNode d.root ++ printNodes d.epilog
+
+/-! ## Well formed trees -/
+
+/-- Attribute lists the printer can write and the parser reads
+ back: each name is a valid name, each value holds only legal
+ characters, no name appears twice, and the rest of the list is
+ in order. -/
+def AttrsOk : List (List Char × List Char) → Prop
+ | [] => True
+ | kv :: rest =>
+ ValidName kv.1 ∧ (∀ c ∈ kv.2, LegalChar c) ∧
+ (∀ kv2 ∈ rest, kv2.1 ≠ kv.1) ∧ AttrsOk rest
+
+mutual
+/-- A node the printer can write and the parser reads back as the
+ same node. A text run is not empty and holds only legal
+ characters. A comment body has no two dashes in a row, does not
+ end with a dash, and holds only legal characters. A processing
+ instruction has a valid target that is not the reserved word,
+ and data that holds no `?>`, holds only legal characters, and
+ does not start with whitespace (the printer writes one space
+ before the data, and the parser skips whitespace there, so a
+ data value starting with whitespace would lose it). -/
+inductive WFNode : Node → Prop where
+ | text {cs : List Char} (hne : cs ≠ []) (hlegal : ∀ c ∈ cs, LegalChar c) :
+ WFNode (.text cs)
+ | elem {name : List Char} {attrs : List (List Char × List Char)}
+ {content : List Node} (hname : ValidName name) (hattrs : AttrsOk attrs)
+ (hcontent : WFNodes content) : WFNode (.elem name attrs content)
+ | comment {cs : List Char} (hdd : NoDashDash cs) (hde : NotDashEnd cs)
+ (hlegal : ∀ c ∈ cs, LegalChar c) : WFNode (.comment cs)
+ | pi {t d : List Char} (hname : ValidName t) (hres : ¬ IsXmlTarget t)
+ (hq : NoQClose d) (hlegal : ∀ c ∈ d, LegalChar c)
+ (hstart : d = [] ∨ ∀ c cs, d = c :: cs → ¬ IsWs c) :
+ WFNode (.pi t d)
+
+/-- A content list whose nodes are all well formed, with no two
+ text runs next to each other: the parser merges the character
+ data between two marks into one run, so a tree with two text
+ runs side by side would print to a text the parser reads back
+ as one run. -/
+inductive WFNodes : List Node → Prop where
+ | nil : WFNodes []
+ | cons {n : Node} {ns : List Node} (hn : WFNode n) (hns : WFNodes ns)
+ (hadj : (∃ cs, n = .text cs) → ∀ cs, ns.head? ≠ some (.text cs)) :
+ WFNodes (n :: ns)
+end
+
+/-- A node in a prolog or an epilog: a comment or a processing
+ instruction, and well formed. -/
+def MiscOk (ns : List Node) : Prop :=
+ ∀ n ∈ ns, WFNode n ∧
+ ((∃ cs, n = .comment cs) ∨ (∃ t d, n = .pi t d))
+
+/-- A document the printer can write and the parser reads back:
+ prolog and epilog hold only comments and processing
+ instructions, and the root is an element. -/
+def WF (d : Document) : Prop :=
+ MiscOk d.prolog ∧ WFNode d.root ∧
+ (∃ name attrs content, d.root = .elem name attrs content) ∧
+ MiscOk d.epilog
+
+end Xml
diff --git a/bar.toml b/bar.toml
new file mode 100644
index 0000000..16aab56
--- /dev/null
+++ b/bar.toml
@@ -0,0 +1,52 @@
+# Read by bar/check.py in Proven-Commons/mission.
+# See playbook/verified-bar.md there for what each field means.
+
+[project]
+name = "xml"
+# "in-progress" until every bar item passes, then "verified".
+status = "in-progress"
+page = "README.md"
+# GitHub handles of everyone who wrote the specification.
+spec_authors = ["abu-aska"]
+# The program people run. A change to it voids the approval.
+entry_points = ["Main.lean"]
+
+[spec]
+files = ["SPEC.md", "Spec.lean"]
+# The code under proof: the functions the statements are about.
+code = [
+ # The data types the statements are about, and their constructors.
+ "Xml.Node", "Xml.Node.text", "Xml.Node.elem", "Xml.Node.comment",
+ "Xml.Node.pi", "Xml.Document",
+ # Characters, names, and the conditions a well formed tree meets.
+ "Xml.LegalChar", "Xml.isLegalChar", "Xml.IsNameStart", "Xml.IsNameChar",
+ "Xml.isNameStart", "Xml.isNameChar", "Xml.ValidName", "Xml.IsWs",
+ "Xml.NoDashDash", "Xml.NotDashEnd", "Xml.NoQClose", "Xml.IsXmlTarget",
+ "Xml.AttrsOk", "Xml.WFNode", "Xml.WFNode.text", "Xml.WFNode.elem",
+ "Xml.WFNode.comment", "Xml.WFNode.pi", "Xml.WFNodes", "Xml.WFNodes.nil",
+ "Xml.WFNodes.cons", "Xml.MiscOk", "Xml.WF",
+ # The printer.
+ "Xml.escapeTextChar", "Xml.escapeText", "Xml.escapeAttrChar",
+ "Xml.escapeAttr", "Xml.printAttrs", "Xml.printNode", "Xml.printElem",
+ "Xml.printNodes", "Xml.print",
+ # The parser helpers and the parser.
+ "Xml.isWs", "Xml.skipWs", "Xml.isDigit", "Xml.hexVal", "Xml.isHexDigit",
+ "Xml.takeDigits", "Xml.takeHexDigits", "Xml.digitsValue",
+ "Xml.charRefChar", "Xml.parseRef", "Xml.takeNameChars", "Xml.parseName",
+ "Xml.parseTextRun", "Xml.parseCDATABody", "Xml.parseCommentBody",
+ "Xml.parsePIData", "Xml.parseAttrValue", "Xml.parsePI",
+ "Xml.parseCharData", "Xml.parseAttrs", "Xml.parseElement",
+ "Xml.parseNodes", "Xml.parseMisc", "Xml.parseDocument", "Xml.parse",
+]
+
+# One entry per approval, added in a pull request the reviewer approves.
+# [[spec.approval]]
+# reviewer = "github-handle"
+# date = "2026-11-02"
+# sha256 = "..."
+# record = "https://github.com/Proven-Commons/xml/pull/"
+
+[tests]
+results = "tests/TEST-RESULTS.md"
+command = "python3 tests/run_tests.py"
+files = ["tests/run_tests.py"]
diff --git a/comparator.json b/comparator.json
new file mode 100644
index 0000000..3837303
--- /dev/null
+++ b/comparator.json
@@ -0,0 +1 @@
+{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.rejects_doctype","Spec.rejects_undefined_entity","Spec.rejects_mismatched_tags","Spec.rejects_unclosed_element","Spec.rejects_second_root","Spec.rejects_text_before_root","Spec.rejects_duplicate_attribute","Spec.rejects_lt_in_attribute","Spec.rejects_cdata_close_in_text","Spec.rejects_dashdash_in_comment","Spec.rejects_surrogate_ref","Spec.rejects_zero_ref","Spec.rejects_colon_in_name","Spec.rejects_digit_name","Spec.rejects_unquoted_attribute","Spec.rejects_cdata_in_prolog","Spec.rejects_xml_declaration"]}
\ No newline at end of file
diff --git a/lake-manifest.json b/lake-manifest.json
new file mode 100644
index 0000000..5b8a3ba
--- /dev/null
+++ b/lake-manifest.json
@@ -0,0 +1,6 @@
+{"version": "1.3.0",
+ "packagesDir": ".lake/packages",
+ "packages": [],
+ "name": "xml",
+ "lakeDir": ".lake",
+ "fixedToolchain": false}
diff --git a/lakefile.toml b/lakefile.toml
new file mode 100644
index 0000000..66452b8
--- /dev/null
+++ b/lakefile.toml
@@ -0,0 +1,16 @@
+name = "xml"
+version = "0.1.0"
+defaultTargets = ["Xml", "Proofs", "xml"]
+
+[[lean_lib]]
+name = "Xml"
+
+[[lean_lib]]
+name = "Proofs"
+
+[[lean_lib]]
+name = "Spec"
+
+[[lean_exe]]
+name = "xml"
+root = "Main"
diff --git a/lean-toolchain b/lean-toolchain
new file mode 100644
index 0000000..a6aff06
--- /dev/null
+++ b/lean-toolchain
@@ -0,0 +1 @@
+leanprover/lean4:v4.35.0
diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md
new file mode 100644
index 0000000..cfe0ecd
--- /dev/null
+++ b/tests/TEST-RESULTS.md
@@ -0,0 +1,20 @@
+# Test results
+
+Written by `tests/run_tests.py`. Rerun it to check these numbers.
+
+- Oracle: Python 3.12.3 `xml.etree.ElementTree` (expat), subset mode as described in the harness
+- Lean tool: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release)
+- Seed: 20261026
+- Fixed cases passed: 35 of 35
+- Generated in-subset documents: 3200
+- Generated documents where the tool's canonical print matches the tree and ElementTree agrees on the tree: 3200
+- ElementTree tree agreements on generated documents: 3200
+- Named rejections rejected by the tool: 43 of 43
+- Out of subset disagreements (ElementTree accepts, this slice rejects, by design): 4
+ - document type declaration
+ - document type declaration with internal subset
+ - colon in an attribute name
+ - the XML declaration form
+- Malformed requests answered error: 10 of 10
+- Bare parse requests answered none: yes
+- Failures: 0
diff --git a/tests/run_tests.py b/tests/run_tests.py
new file mode 100644
index 0000000..001121f
--- /dev/null
+++ b/tests/run_tests.py
@@ -0,0 +1,745 @@
+#!/usr/bin/env python3
+"""Tests for the Lean XML safe-subset parser and printer.
+
+Three parts.
+
+1. Fixed cases: hand written documents covering each feature of
+ the subset (nesting, both quote forms, empty elements in both
+ spellings, attribute references and tab normalization, text
+ with the five entities and numeric references in both bases,
+ CDATA holding markup-like text, comments in the prolog, the
+ content, and the epilog, processing instructions, mixed
+ content). For each, the Lean answer must be the canonical
+ print written here.
+
+2. A differential run against Python's `xml.etree.ElementTree`,
+ with a recorded seed. Random documents inside the subset are
+ built as trees in Python, then spelled in XML with random
+ choices that do not change the tree: quote choice, CDATA
+ spellings of text, entity and character reference spellings,
+ the two empty element spellings, literal whitespace in
+ attribute values (which the subset normalizes to a space).
+ For every document:
+
+ - the Lean tool must accept it, and its canonical print must
+ equal the canonical print computed in Python from the tree;
+ - ElementTree must accept it, and the tree ElementTree reads
+ (for the root element; ElementTree does not keep the prolog
+ and epilog) must equal the tree that was generated.
+
+ Named differences, counted apart and not failures:
+
+ - ElementTree normalizes a literal carriage return in text,
+ comments, processing instruction data, and CDATA to a line
+ feed (XML line ending normalization). This slice keeps the
+ carriage return as it is; it is a stated boundary in
+ SPEC.md. The generator therefore never writes a literal
+ carriage return in those positions (a carriage return in a
+ tree is always spelled as a character reference there).
+ Literal carriage returns and tabs in attribute values are
+ fine: both sides read them as a space.
+
+3. Named rejections: one input per rejection rule in SPEC.md.
+ The Lean tool must reject every one. ElementTree agrees with
+ most of them; where ElementTree accepts (a document type
+ declaration, a colon in a name, the XML declaration form),
+ that is an out of subset disagreement, counted apart, because
+ the subset is narrower than XML by design.
+
+Malformed request lines must be answered `error`, as in the
+other slices' harnesses.
+
+ python3 tests/run_tests.py [--seed N] [--count N]
+
+The tool is built first if needed, driven in batches of request
+lines, and the results are written to tests/TEST-RESULTS.md.
+The exit code is non-zero if any check fails. Python 3.11+.
+"""
+
+import argparse
+import random
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+EXE = ROOT / ".lake" / "build" / "bin" / "xml"
+RESULTS = ROOT / "tests" / "TEST-RESULTS.md"
+TIMEOUT = 900
+BUILD_TIMEOUT = 3600
+CHUNK = 2000
+
+SEED_DEFAULT = 20261026
+COUNT_DEFAULT = 3200
+
+
+# ----------------------------------------------------------------
+# The tree model, mirroring Xml/Value.lean.
+#
+# A node is one of:
+# ("text", s)
+# ("elem", name, [(attr, value), ...], [node, ...])
+# ("comment", s)
+# ("pi", target, data)
+# A document is (prolog, root, epilog) with prolog and epilog
+# lists of comment and pi nodes.
+
+
+def escape_text(s):
+ out = []
+ for c in s:
+ if c == "&":
+ out.append("&")
+ elif c == "<":
+ out.append("<")
+ elif c == ">":
+ out.append(">")
+ elif c == "\r":
+ out.append("
")
+ else:
+ out.append(c)
+ return "".join(out)
+
+
+def escape_attr(s):
+ out = []
+ for c in s:
+ if c == "&":
+ out.append("&")
+ elif c == "<":
+ out.append("<")
+ elif c == '"':
+ out.append(""")
+ elif c == "\t":
+ out.append(" ")
+ elif c == "\n":
+ out.append("
")
+ elif c == "\r":
+ out.append("
")
+ else:
+ out.append(c)
+ return "".join(out)
+
+
+def print_node(n):
+ kind = n[0]
+ if kind == "text":
+ return escape_text(n[1])
+ if kind == "comment":
+ return ""
+ if kind == "pi":
+ if n[2] == "":
+ return "" + n[1] + "?>"
+ return "" + n[1] + " " + n[2] + "?>"
+ _, name, attrs, content = n
+ parts = ["<" + name]
+ for k, v in attrs:
+ parts.append(" " + k + '="' + escape_attr(v) + '"')
+ if not content:
+ parts.append("/>")
+ else:
+ parts.append(">")
+ for c in content:
+ parts.append(print_node(c))
+ parts.append("" + name + ">")
+ return "".join(parts)
+
+
+def print_doc(doc):
+ prolog, root, epilog = doc
+ return ("".join(print_node(n) for n in prolog)
+ + print_node(root)
+ + "".join(print_node(n) for n in epilog))
+
+
+# ----------------------------------------------------------------
+# ElementTree reading, converted to the model. Only the root
+# element is compared: ElementTree drops the prolog and epilog.
+
+
+def et_to_model(elem):
+ if elem.tag is ET.Comment:
+ return ("comment", elem.text or "")
+ if elem.tag is ET.ProcessingInstruction:
+ text = elem.text or ""
+ if " " in text:
+ target, data = text.split(" ", 1)
+ else:
+ target, data = text, ""
+ return ("pi", target, data)
+ content = []
+ if elem.text:
+ content.append(("text", elem.text))
+ for child in elem:
+ content.append(et_to_model(child))
+ if child.tail:
+ content.append(("text", child.tail))
+ return ("elem", elem.tag, list(elem.attrib.items()), content)
+
+
+def et_parse(text):
+ builder = ET.TreeBuilder(insert_comments=True, insert_pis=True)
+ return ET.fromstring(text, parser=ET.XMLParser(target=builder))
+
+
+# ----------------------------------------------------------------
+# The tool driver: one request per line, character codes.
+
+
+def codes(text):
+ return ",".join(str(ord(c)) for c in text)
+
+
+def request(text):
+ return "parse " + codes(text)
+
+
+def run_tool(lines, timeout=TIMEOUT):
+ proc = subprocess.Popen(
+ [str(EXE)], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ try:
+ out, _ = proc.communicate(
+ ("\n".join(lines) + "\n").encode(), timeout=timeout)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ raise
+ return out.decode().splitlines()
+
+
+def run_requests(texts):
+ lines = [request(t) for t in texts]
+ answers = []
+ for i in range(0, len(lines), CHUNK):
+ answers.extend(run_tool(lines[i:i + CHUNK]))
+ return answers
+
+
+# ----------------------------------------------------------------
+# Fixed cases: (input, expected canonical print).
+
+FIXED = [
+ ("", ""),
+ ("", ""),
+ ("x", "x"),
+ ("deep", "deep"),
+ ("t", 't'),
+ ("x", 'x'),
+ ("x", "x"),
+ # literal tab and newline in a value read as a space; a
+ # character reference to a tab stays a tab
+ ("", ''),
+ ("", ''),
+ ("",
+ ''),
+ ("<>&'"", "<>&'\""),
+ ("ABc", "ABc"),
+ ("éé", "éé"),
+ ("1 > 0 and 2 < 3", "1 > 0 and 2 < 3"),
+ (" & z]]>", "x <y> & z"),
+ ("t1t3", "t1t2t3"),
+ ("]]>", "]>"),
+ ("", ""),
+ ("", ""),
+ ("", ""),
+ ("", ""),
+ ("", ""),
+ ("", ""),
+ ("", ""),
+ ("xyzw", "xyzw"),
+ ("", ''),
+ ("x", "x"),
+ ("
", "
"),
+ ("", ''),
+ ("text with ] and ]] but no close",
+ "text with ] and ]] but no close"),
+ ("<_x.y-z_9/>", "<_x.y-z_9/>"),
+ ("", ''),
+ ("\n spaced\n", "\n spaced\n"),
+ ("", ""),
+ ("t",
+ "t"),
+]
+
+# Named rejections: (rule, input). ElementTree's verdict is
+# recorded for each; where it accepts, the case is an out of
+# subset disagreement, not a failure.
+REJECTIONS = [
+ ("document type declaration", ""),
+ ("document type declaration with internal subset",
+ "]>&e;"),
+ ("entity declaration door (any other "),
+ ("undefined entity", " "),
+ ("undefined entity in an attribute", ""),
+ ("mismatched tags", ""),
+ ("mismatched tags, nested", ""),
+ ("unclosed element", ""),
+ ("unclosed element after content", ""),
+ ("a second root element", ""),
+ ("text before the root", "x"),
+ ("text after the root", "x"),
+ ("duplicate attribute", ""),
+ ("raw < in an attribute value", ""),
+ ("]]> in text", "x]]>y"),
+ ("-- inside a comment", ""),
+ ("comment ending in a dash", ""),
+ ("surrogate character reference", ""),
+ ("zero character reference", ""),
+ ("character reference past the last character",
+ ""),
+ ("colon in a name", ""),
+ ("colon in an attribute name", ""),
+ ("name starting with a digit", "<1a/>"),
+ ("attribute name starting with a digit", ""),
+ ("unquoted attribute value", ""),
+ ("missing equals", ""),
+ ("CDATA section in the prolog", ""),
+ ("CDATA section in the epilog", ""),
+ ("the XML declaration form", ""),
+ ("the XML declaration, upper case target",
+ ""),
+ ("bare ampersand in text", "a & b"),
+ ("reference without a semicolon", "&"),
+ ("empty reference", "&;"),
+ ("hex reference with no digits", ""),
+ ("control character reference", ""),
+ ("raw control character in text", "x\x0by"),
+ ("unterminated comment", ""),
+]
+
+MALFORMED = [
+ "parse x",
+ "parse 60,,62",
+ "parse 60, 62",
+ "parse -1",
+ "parse 1114112",
+ "parse 55296",
+ "print 60",
+ "PARSE 60",
+ "parse 60 62",
+ "",
+]
+
+# parse with no codes is a plain empty input, not malformed:
+# it must answer none (the empty text is not a document).
+
+
+# ----------------------------------------------------------------
+# The generator: random trees in the subset, spelled with random
+# choices that do not change the tree. Each generated case is
+# (spelling, model document). The model is built alongside the
+# spelling, token by token, so that the two always agree: a
+# literal tab spelled in an attribute value is a space in the
+# model, a character reference to a tab is a tab in the model.
+
+NAME_START = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"
+NAME_CHARS = NAME_START + "0123456789.-"
+TEXT_PLAIN = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789 .,;:!?()[]{}+-*/=_%$#@~^|")
+COMMENT_WORDS = ["note", "a", "draft-1", "x", "see above", "ok",
+ "todo: fix", "caf\u00e9", "na\u00efve", "123"]
+
+
+def gen_name(rng):
+ n = rng.randint(1, 7)
+ return rng.choice(NAME_START) + "".join(
+ rng.choice(NAME_CHARS) for _ in range(n - 1))
+
+
+def spell_text_char(rng, c, emitted):
+ """One spelling of a text character, and it is never a raw
+ less sign, a raw ampersand, or a raw greater sign right after
+ two right brackets."""
+ if c == "&":
+ return "&"
+ if c == "<":
+ return "<"
+ if c == ">":
+ if rng.random() < 0.5:
+ return ">"
+ return ">"
+ if c == "\r":
+ return rng.choice(["
", "
"])
+ if c == "\n" or c == "\t":
+ return c
+ r = rng.random()
+ if r < 0.06:
+ return "%d;" % ord(c)
+ if r < 0.10:
+ return "%X;" % ord(c)
+ return c
+
+
+def gen_text_run(rng):
+ """A text run: returns (spelling, model text). The spelling
+ may split the run into plain pieces and CDATA sections; the
+ model is one merged run."""
+ n = rng.randint(1, 14)
+ chars = []
+ for _ in range(n):
+ r = rng.random()
+ if r < 0.72:
+ chars.append(rng.choice(TEXT_PLAIN))
+ elif r < 0.78:
+ chars.append("&")
+ elif r < 0.84:
+ chars.append("<")
+ elif r < 0.90:
+ chars.append(">")
+ elif r < 0.94:
+ chars.append("\r")
+ elif r < 0.97:
+ chars.append("'")
+ else:
+ chars.append('"')
+ model = "".join(chars)
+ # Spell it: choose up to two cut points where a CDATA section
+ # starts or ends. Inside CDATA everything is literal except
+ # the model holds no ]]> (it cannot: > after ]] is possible,
+ # so keep CDATA pieces free of a ] ] > run by construction:
+ # a CDATA piece may not end with ]] when the next model char
+ # is >, and may not contain ]]> at all. The generator simply
+ # avoids CDATA around ] characters.
+ pieces = []
+ i = 0
+ emitted = ""
+ while i < len(chars):
+ if (rng.random() < 0.3 and chars[i] != "]"
+ and (i + 1 >= len(chars) or chars[i + 1] != "]")):
+ j = i
+ buf = []
+ while (j < len(chars) and chars[j] not in "<&"
+ and chars[j] != "\r"
+ and not (chars[j] == "]" and j + 1 < len(chars)
+ and chars[j + 1] == "]")):
+ buf.append(chars[j])
+ j += 1
+ if buf and buf[-1] != "]":
+ pieces.append("")
+ emitted += "".join(buf)
+ i = j
+ continue
+ sp = spell_text_char(rng, chars[i], emitted)
+ pieces.append(sp)
+ emitted += chars[i]
+ i += 1
+ return "".join(pieces), model
+
+
+def gen_attr_value(rng):
+ """Returns (spelling inside the quotes, model value, quote).
+ The quote is chosen so a literal quote of the other kind may
+ appear."""
+ quote = rng.choice(['"', "'"])
+ toks = []
+ model = []
+ for _ in range(rng.randint(0, 10)):
+ r = rng.random()
+ if r < 0.55:
+ c = rng.choice(TEXT_PLAIN)
+ toks.append(c)
+ model.append(c)
+ elif r < 0.62:
+ toks.append("\t") # literal tab: model space
+ model.append(" ")
+ elif r < 0.68:
+ # literal newline: a model space, unless it completes
+ # a literal CRLF pair, which reads as one space
+ if not (toks and toks[-1] == "\r"):
+ model.append(" ")
+ toks.append("\n")
+ elif r < 0.72:
+ toks.append("\r") # literal CR: model space
+ model.append(" ")
+ elif r < 0.78:
+ toks.append(" ")
+ model.append("\t")
+ elif r < 0.82:
+ toks.append("
")
+ model.append("\n")
+ elif r < 0.86:
+ toks.append("
")
+ model.append("\r")
+ elif r < 0.90:
+ toks.append("<")
+ model.append("<")
+ elif r < 0.93:
+ toks.append("&")
+ model.append("&")
+ elif r < 0.96:
+ other = "'" if quote == '"' else '"'
+ toks.append(other)
+ model.append(other)
+ else:
+ toks.append(""" if quote == '"' else "'")
+ model.append(quote)
+ return "".join(toks), "".join(model), quote
+
+
+def gen_comment(rng):
+ words = [rng.choice(COMMENT_WORDS)
+ for _ in range(rng.randint(0, 4))]
+ body = " " + " ".join(words) + " " if words else ""
+ if rng.random() < 0.3:
+ body = body + "- x "
+ return "", ("comment", body)
+
+
+def gen_pi(rng):
+ target = gen_name(rng)
+ while target.lower() == "xml":
+ target = gen_name(rng)
+ if rng.random() < 0.35:
+ return "" + target + "?>", ("pi", target, "")
+ words = [rng.choice(COMMENT_WORDS)
+ for _ in range(rng.randint(1, 3))]
+ data = " ".join(words)
+ return ("" + target + " " + data + "?>",
+ ("pi", target, data))
+
+
+def gen_element(rng, depth):
+ name = gen_name(rng)
+ used = set()
+ attrs = []
+ attr_spell = []
+ for _ in range(rng.randint(0, 3)):
+ an = gen_name(rng)
+ if an in used:
+ continue
+ used.add(an)
+ sp, val, q = gen_attr_value(rng)
+ attrs.append((an, val))
+ eq = rng.choice(["=", " = ", "= ", " ="])
+ attr_spell.append(" " + an + eq + q + sp + q)
+ content = []
+ content_spell = []
+ if depth < 4:
+ for _ in range(rng.randint(0, 4)):
+ r = rng.random()
+ if r < 0.38:
+ sub_sp, sub = gen_element(rng, depth + 1)
+ content.append(sub)
+ content_spell.append(sub_sp)
+ elif r < 0.52:
+ sp, node = gen_comment(rng)
+ content.append(node)
+ content_spell.append(sp)
+ elif r < 0.62:
+ sp, node = gen_pi(rng)
+ content.append(node)
+ content_spell.append(sp)
+ else:
+ sp, model = gen_text_run(rng)
+ if content and content[-1][0] == "text":
+ content[-1] = ("text", content[-1][1] + model)
+ else:
+ content.append(("text", model))
+ content_spell.append(sp)
+ model = ("elem", name, attrs, content)
+ if not content:
+ if rng.random() < 0.5:
+ return "<" + name + "".join(attr_spell) + "/>", model
+ return ("<" + name + "".join(attr_spell) + ">" + name + ">",
+ model)
+ return ("<" + name + "".join(attr_spell) + ">"
+ + "".join(content_spell) + "" + name + ">", model)
+
+
+def gen_document(rng):
+ prolog = []
+ prolog_spell = []
+ for _ in range(rng.randint(0, 2)):
+ if rng.random() < 0.5:
+ sp, node = gen_comment(rng)
+ else:
+ sp, node = gen_pi(rng)
+ prolog.append(node)
+ prolog_spell.append(sp)
+ root_sp, root = gen_element(rng, 0)
+ epilog = []
+ epilog_spell = []
+ for _ in range(rng.randint(0, 2)):
+ if rng.random() < 0.5:
+ sp, node = gen_comment(rng)
+ else:
+ sp, node = gen_pi(rng)
+ epilog.append(node)
+ epilog_spell.append(sp)
+ text = "".join(prolog_spell) + root_sp + "".join(epilog_spell)
+ return text, (prolog, root, epilog)
+
+
+# ----------------------------------------------------------------
+
+
+def build_exe():
+ if EXE.exists():
+ return
+ subprocess.run(["lake", "build"], cwd=ROOT, check=True,
+ timeout=BUILD_TIMEOUT)
+
+
+def lean_version():
+ out = subprocess.run(["lean", "--version"], capture_output=True,
+ text=True, timeout=60,
+ env={**__import__("os").environ,
+ "PATH": str(Path.home() / ".elan" / "bin")
+ + ":" + __import__("os").environ["PATH"]})
+ return out.stdout.strip() or "unknown"
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--seed", type=int, default=SEED_DEFAULT)
+ ap.add_argument("--count", type=int, default=COUNT_DEFAULT)
+ args = ap.parse_args()
+ rng = random.Random(args.seed)
+
+ build_exe()
+ failures = []
+
+ # Part 1: fixed cases.
+ inputs = [t for t, _ in FIXED]
+ answers = run_requests(inputs)
+ fixed_ok = 0
+ if len(answers) != len(inputs):
+ failures.append("fixed: tool gave %d answers for %d requests"
+ % (len(answers), len(inputs)))
+ answers += [""] * (len(inputs) - len(answers))
+ for (text, expected), ans in zip(FIXED, answers):
+ want = "ok:" + codes(expected)
+ if ans == want:
+ fixed_ok += 1
+ else:
+ failures.append("fixed case %r: got %r, want %r"
+ % (text, ans, want))
+
+ # Part 2: generated differential.
+ gen_inputs = []
+ gen_docs = []
+ for _ in range(args.count):
+ text, doc = gen_document(rng)
+ gen_inputs.append(text)
+ gen_docs.append(doc)
+ answers = run_requests(gen_inputs)
+ gen_ok = 0
+ et_ok = 0
+ gen_fail = 0
+ for text, doc, ans in zip(gen_inputs, gen_docs, answers):
+ want = "ok:" + codes(print_doc(doc))
+ good = True
+ if ans != want:
+ good = False
+ if gen_fail < 10:
+ failures.append(
+ "generated: tool answer differs for %r: got %r..."
+ % (text, ans[:120]))
+ try:
+ et_root = et_parse(text)
+ if et_to_model(et_root) != doc[1]:
+ good = False
+ if gen_fail < 10:
+ failures.append(
+ "generated: ElementTree tree differs for %r"
+ % (text,))
+ else:
+ et_ok += 1
+ et_canon = et_parse(print_doc(doc))
+ if et_to_model(et_canon) != doc[1]:
+ good = False
+ if gen_fail < 10:
+ failures.append(
+ "generated: ElementTree tree of the print "
+ "differs for %r" % (text,))
+ except ET.ParseError:
+ good = False
+ if gen_fail < 10:
+ failures.append(
+ "generated: ElementTree rejected in-subset %r"
+ % (text,))
+ if good:
+ gen_ok += 1
+ else:
+ gen_fail += 1
+
+ # Part 3: named rejections. Also record ElementTree's verdict
+ # on each: accepted means an out of subset disagreement.
+ rej_inputs = [t for _, t in REJECTIONS]
+ answers = run_requests(rej_inputs)
+ rej_ok = 0
+ oos = []
+ for (rule, text), ans in zip(REJECTIONS, answers):
+ if ans == "none":
+ rej_ok += 1
+ else:
+ failures.append("rejection %s: tool answered %r for %r"
+ % (rule, ans, text))
+ try:
+ et_parse(text)
+ oos.append(rule)
+ except ET.ParseError:
+ pass
+
+ # Malformed requests.
+ bad_answers = run_tool(MALFORMED)
+ bad_ok = 0
+ for line, ans in zip(MALFORMED, bad_answers):
+ if ans == "error":
+ bad_ok += 1
+ else:
+ failures.append("malformed %r: got %r, want error"
+ % (line, ans))
+ empty_ans = run_tool(["parse", "parse "])
+ if empty_ans != ["none", "none"]:
+ failures.append("bare 'parse' answered %r, want two nones"
+ % (empty_ans,))
+
+ lines = []
+ lines.append("# Test results")
+ lines.append("")
+ lines.append("Written by `tests/run_tests.py`. Rerun it to "
+ "check these numbers.")
+ lines.append("")
+ lines.append("- Oracle: Python %s `xml.etree.ElementTree` "
+ "(expat), subset mode as described in the harness"
+ % sys.version.split()[0])
+ lines.append("- Lean tool: %s" % lean_version())
+ lines.append("- Seed: %d" % args.seed)
+ lines.append("- Fixed cases passed: %d of %d"
+ % (fixed_ok, len(FIXED)))
+ lines.append("- Generated in-subset documents: %d" % len(gen_inputs))
+ lines.append("- Generated documents where the tool's canonical "
+ "print matches the tree and ElementTree agrees on "
+ "the tree: %d" % gen_ok)
+ lines.append("- ElementTree tree agreements on generated "
+ "documents: %d" % et_ok)
+ lines.append("- Named rejections rejected by the tool: %d of %d"
+ % (rej_ok, len(REJECTIONS)))
+ lines.append("- Out of subset disagreements (ElementTree "
+ "accepts, this slice rejects, by design): %d"
+ % len(oos))
+ for rule in oos:
+ lines.append(" - %s" % rule)
+ lines.append("- Malformed requests answered error: %d of %d"
+ % (bad_ok, len(MALFORMED)))
+ lines.append("- Bare parse requests answered none: %s"
+ % ("yes" if empty_ans == ["none", "none"] else "no"))
+ lines.append("- Failures: %d" % len(failures))
+ for f in failures[:40]:
+ lines.append(" - %s" % f)
+ RESULTS.write_text("\n".join(lines) + "\n")
+ print("\n".join(lines))
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())