# bible-linkify surface (pure Bend): protect-aware Markdown walker + plain matcher import Base import ./route.bend as Route import ./protect.bend as Prot import ./parse.bend as P def char1(+c: Char) -> String: String.from_list(c <> Nil{}) def linkify_parts(+visible: String, +canonical: String) -> String: Route.markdown_route_link(visible, canonical) def linkify_parts_qv(+visible: String, +canonical: String, +translation: String) -> String: Route.markdown_route_link_qv(visible, canonical, translation) # Peek trailing " (ESV)" after a match — does not consume; code or "". type TvPh is Data: TvSkipSp{} TvSkipDec{sp: Bool} TvNeedOpen{} TvNeedOpenDec{is_open: Bool} TvCode{+acc: String, +letters: Nat, +digits: Nat} TvCls{+acc: String, +letters: Nat, +digits: Nat, is_close: Bool, ch: Char} TvAl{+acc: String, +letters: Nat, +digits: Nat, ch: Char, is_alpha: Bool, dig0: Bool} TvDg{+acc: String, +letters: Nat, +digits: Nat, ch: Char, is_digit: Bool} TvDone{+acc: String, +letters: Nat, +digits: Nat} TvCheck{+acc: String, ok: Bool} def tv_letter_ok(+letters: Nat) -> Bool: Bool.and(Nat.is_ge(letters, 2n), Nat.is_le(letters, 8n)) def tv_digit_ok(+digits: Nat) -> Bool: Nat.is_le(digits, 2n) def peek_translation_go(fuel: Nat, ph: TvPh, rest: String) -> String: match fuel: case 0n: "" case 1n+p: match ph: case TvSkipSp{}: match rest: case SNil{}: "" case SCon{+h, +t}: peek_translation_go(p, TvSkipDec{Char.is_space(h)}, SCon{h, t}) case TvSkipDec{sp}: match sp: case True{}: match rest: case SNil{}: "" case SCon{h, t}: peek_translation_go(p, TvSkipSp{}, t) case False{}: peek_translation_go(p, TvNeedOpen{}, rest) case TvNeedOpen{}: match rest: case SNil{}: "" case SCon{+h, +t}: peek_translation_go(p, TvNeedOpenDec{Char.is_eq(h, '(')}, SCon{h, t}) case TvNeedOpenDec{is_open}: match is_open: case True{}: match rest: case SNil{}: "" case SCon{h, t}: peek_translation_go(p, TvCode{"", 0n, 0n}, t) case False{}: "" case TvCode{+acc, +letters, +digits}: match rest: case SNil{}: "" case SCon{+h, +t}: peek_translation_go( p, TvCls{acc, letters, digits, Char.is_eq(h, ')'), h}, SCon{h, t} ) case TvCls{+acc, +letters, +digits, is_close, +ch}: match is_close: case True{}: peek_translation_go(p, TvDone{acc, letters, digits}, rest) case False{}: peek_translation_go( p, TvAl{acc, letters, digits, ch, Char.is_alpha(ch), Nat.is_eq(digits, 0n)}, rest ) case TvAl{+acc, +letters, +digits, +ch, is_alpha, dig0}: match is_alpha: case True{}: match dig0: case True{}: match rest: case SNil{}: "" case SCon{h, t}: peek_translation_go( p, TvCode{String.append(acc, char1(ch)), Nat.add(letters, 1n), digits}, t ) case False{}: "" case False{}: peek_translation_go(p, TvDg{acc, letters, digits, ch, Char.is_digit(ch)}, rest) case TvDg{+acc, +letters, +digits, +ch, is_digit}: match is_digit: case True{}: match rest: case SNil{}: "" case SCon{h, t}: peek_translation_go( p, TvCode{String.append(acc, char1(ch)), letters, Nat.add(digits, 1n)}, t ) case False{}: "" case TvDone{+acc, +letters, +digits}: peek_translation_go( p, TvCheck{acc, Bool.and(tv_letter_ok(letters), tv_digit_ok(digits))}, rest ) case TvCheck{+acc, ok}: match ok: case True{}: String.to_upper(acc) case False{}: "" def peek_translation(+suffix: String) -> String: peek_translation_go( Nat.add(Nat.mul(3n, String.length(suffix)), 16n), TvSkipSp{}, suffix ) def is_newline(+c: Char) -> Bool: match c: case Chr{+code}: U32.is_eq(code, 10) def is_alnum(+c: Char) -> Bool: Bool.or(Char.is_alpha(c), Char.is_digit(c)) def is_wrapper_lead(+c: Char) -> Bool: Bool.or( Char.is_eq(c, '('), Bool.or( Char.is_eq(c, '['), Bool.or( Char.is_eq(c, '{'), Bool.or( Char.is_eq(c, '"'), Bool.or(Char.is_eq(c, '\''), Char.is_eq(c, '`')) ) ) ) ) def is_wrapper_trail(+c: Char) -> Bool: Bool.or( Char.is_eq(c, ')'), Bool.or( Char.is_eq(c, ']'), Bool.or( Char.is_eq(c, '}'), Bool.or( Char.is_eq(c, '"'), Bool.or( Char.is_eq(c, '\''), Bool.or( Char.is_eq(c, '`'), Bool.or( Char.is_eq(c, ','), Bool.or( Char.is_eq(c, ';'), Bool.or( Char.is_eq(c, '.'), Bool.or(Char.is_eq(c, '!'), Char.is_eq(c, '?')) ) ) ) ) ) ) ) ) ) # --- Passage signal --- type SigPh is Data: SigScan{has_a: Bool, has_d: Bool} SigBoth{has_a: Bool, has_d: Bool, both: Bool} def has_signal_go(fuel: Nat, ph: SigPh, rest: String) -> Bool: match fuel: case 0n: match ph: case SigScan{has_a, has_d}: Bool.and(has_a, has_d) case SigBoth{has_a, has_d, both}: both case 1n+p: match ph: case SigBoth{has_a, has_d, both}: match both: case True{}: True{} case False{}: match rest: case SNil{}: False{} case SCon{+h, +t}: has_signal_go( p, SigScan{ Bool.or(has_a, Char.is_alpha(h)), Bool.or(has_d, Char.is_digit(h)) }, t ) case SigScan{+has_a, +has_d}: has_signal_go(p, SigBoth{has_a, has_d, Bool.and(has_a, has_d)}, rest) def has_passage_signal(+s: String) -> Bool: has_signal_go(Nat.add(Nat.mul(4n, String.length(s)), 8n), SigScan{False{}, False{}}, s) # --- Trim wrappers: drop lead wrappers, reverse, drop lead (ex-trail), reverse --- type TrPh is Data: TrLead{} TrLeadDec{lead: Bool} def is_wrapper_either(+c: Char) -> Bool: Bool.or(is_wrapper_lead(c), is_wrapper_trail(c)) def trim_pass_go(fuel: Nat, ph: TrPh, rest: String) -> String: match fuel: case 0n: rest case 1n+p: match ph: case TrLead{}: match rest: case SNil{}: "" case SCon{+h, +t}: trim_pass_go(p, TrLeadDec{is_wrapper_either(h)}, SCon{h, t}) case TrLeadDec{lead}: match lead: case True{}: match rest: case SNil{}: "" case SCon{h, t}: trim_pass_go(p, TrLead{}, t) case False{}: rest def trim_pass(+s: String) -> String: trim_pass_go(Nat.add(Nat.mul(3n, String.length(s)), 8n), TrLead{}, s) def trim_wrappers(+s: String) -> String: String.reverse(trim_pass(String.reverse(trim_pass(s)))) # --- Candidate take --- def is_colon_dot_dash(+c: Char) -> Bool: match c: case Chr{+code}: Bool.or(U32.is_eq(code, 58), U32.is_eq(code, 45)) def is_cand_char(+c: Char) -> Bool: Bool.or( is_alnum(c), Bool.or(Char.is_space(c), is_colon_dot_dash(c)) ) type TcPh is Data: TcCheck{+n: Nat, has_d: Bool} TcGe{+n: Nat, has_d: Bool, ge: Bool} TcOk{+n: Nat, has_d: Bool, ok: Bool} TcSp{+n: Nat, has_d: Bool, is_sp: Bool} TcSpDig{+n: Nat} TcSpNod{+n: Nat} TcPeek{+n: Nat, next_alpha: Bool} TcTake{+n: Nat, has_d: Bool} TcTakeDig{+n: Nat, has_d: Bool, dig: Bool} def pick_sp(has_d: Bool, +n: Nat) -> TcPh: match has_d: case True{}: TcSpDig{n} case False{}: TcSpNod{n} def take_cand_go(fuel: Nat, ph: TcPh, +rest: String, acc: String) -> String: match fuel: case 0n: acc case 1n+p: match ph: case TcCheck{+n, +has_d}: take_cand_go(p, TcGe{n, has_d, Nat.is_ge(n, 40n)}, rest, acc) case TcGe{+n, +has_d, ge}: match ge: case True{}: acc case False{}: match rest: case SNil{}: acc case SCon{+h, +t}: take_cand_go(p, TcOk{n, has_d, is_cand_char(h)}, SCon{h, t}, acc) case TcOk{+n, +has_d, ok}: match ok: case False{}: acc case True{}: match rest: case SNil{}: acc case SCon{+h, +t}: take_cand_go(p, TcSp{n, has_d, Char.is_space(h)}, SCon{h, t}, acc) case TcSp{+n, +has_d, is_sp}: match is_sp: case True{}: take_cand_go(p, pick_sp(has_d, n), rest, acc) case False{}: take_cand_go(p, TcTake{n, has_d}, rest, acc) case TcSpDig{+n}: match rest: case SNil{}: acc case SCon{h, +t}: match t: case SNil{}: acc case SCon{+h2, t2}: take_cand_go(p, TcPeek{n, Char.is_alpha(h2)}, rest, acc) case TcSpNod{+n}: take_cand_go(p, TcTake{n, False{}}, rest, acc) case TcPeek{+n, next_alpha}: match next_alpha: case True{}: acc case False{}: take_cand_go(p, TcTake{n, True{}}, rest, acc) case TcTake{+n, +has_d}: match rest: case SNil{}: acc case SCon{+h, +t}: take_cand_go(p, TcTakeDig{n, has_d, Char.is_digit(h)}, SCon{h, t}, acc) case TcTakeDig{+n, +has_d, dig}: match rest: case SNil{}: acc case SCon{+h, +t}: take_cand_go( p, TcCheck{Nat.add(n, 1n), Bool.or(has_d, dig)}, t, String.append(acc, char1(h)) ) def take_candidate(+s: String) -> String: take_cand_go(Nat.add(Nat.mul(6n, String.length(s)), 16n), TcCheck{0n, False{}}, s, "") type VisCan is Data: VisCan{visible: String, canonical: String} type CandGate is Data: CandNone{} CandSome{visible: String, canonical: String} def cand_pick(empty: Bool, +visible: String, +canonical: String) -> CandGate: match empty: case True{}: CandNone{} case False{}: CandSome{visible, canonical} def cand_from(+visible: String, +canonical: String) -> CandGate: cand_pick(String.is_empty(canonical), visible, canonical) def try_parse_visible_gated(g: CandGate) -> Maybe<&2, VisCan>: match g: case CandNone{}: None{} case CandSome{+visible, +canonical}: Some{VisCan{visible, canonical}} def try_parse_visible(+visible: String) -> Maybe<&2, VisCan>: try_parse_visible_gated( cand_from(String.trim(visible), P.try_canonical(String.trim(visible))) ) def drop_last_rev(rev: String) -> String: match rev: case SNil{}: "" case SCon{h, t}: t def drop_last(s: String) -> String: String.reverse(drop_last_rev(String.reverse(s))) type TwPh is Data: TwTry{s: String} TwEmpty{s: String, empty: Bool} TwShort{s: String, short: Bool} TwParsed{s: String, m: Maybe<&2, VisCan>} def try_windows_go(fuel: Nat, ph: TwPh) -> Maybe<&2, VisCan>: match fuel: case 0n: None{} case 1n+p: match ph: case TwTry{+s}: try_windows_go(p, TwEmpty{s, String.is_empty(s)}) case TwEmpty{+s, empty}: match empty: case True{}: None{} case False{}: try_windows_go(p, TwShort{s, Nat.is_lt(String.length(s), 3n)}) case TwShort{+s, short}: match short: case True{}: None{} case False{}: try_windows_go(p, TwParsed{s, try_parse_visible(trim_wrappers(s))}) case TwParsed{+s, m}: match m: case Some{vc}: Some{vc} case None{}: try_windows_go(p, TwTry{drop_last(s)}) def try_windows(+raw: String) -> Maybe<&2, VisCan>: try_windows_go(Nat.add(Nat.mul(3n, String.length(raw)), 8n), TwTry{raw}) # --- Urlish --- type ExPh is Data: ExGo{} ExDec{sp: Bool} def expand_token_left(fuel: Nat, ph: ExPh, +left_rev: String, acc: String) -> String: match fuel: case 0n: acc case 1n+p: match ph: case ExGo{}: match left_rev: case SNil{}: acc case SCon{+h, +t}: expand_token_left(p, ExDec{Char.is_space(h)}, left_rev, acc) case ExDec{sp}: match sp: case True{}: acc case False{}: match left_rev: case SNil{}: acc case SCon{+h, +t}: expand_token_left(p, ExGo{}, t, String.append(char1(h), acc)) def expand_token_right(fuel: Nat, ph: ExPh, +right: String, acc: String) -> String: match fuel: case 0n: acc case 1n+p: match ph: case ExGo{}: match right: case SNil{}: acc case SCon{+h, +t}: expand_token_right(p, ExDec{Char.is_space(h)}, right, acc) case ExDec{sp}: match sp: case True{}: acc case False{}: match right: case SNil{}: acc case SCon{+h, +t}: expand_token_right(p, ExGo{}, t, String.append(acc, char1(h))) def token_around(+prefix_rev: String, +matched: String, +suffix: String) -> String: String.append( expand_token_left(Nat.add(String.length(prefix_rev), 4n), ExGo{}, prefix_rev, ""), String.append( matched, expand_token_right(Nat.add(String.length(suffix), 4n), ExGo{}, suffix, "") ) ) def is_urlish_token(+tok: String) -> Bool: Bool.or( String.contains(String.to_lower(tok), "http://"), Bool.or( String.contains(String.to_lower(tok), "https://"), String.contains(String.to_lower(tok), "www.") ) ) def is_book_num(+c: Char) -> Bool: Bool.or( Char.is_eq(c, '1'), Bool.or(Char.is_eq(c, '2'), Char.is_eq(c, '3')) ) def looks_like_ref_start(+c: Char) -> Bool: Bool.or(Char.is_alpha(c), is_book_num(c)) type NaPh is Data: NaDec{alnum: Bool} def not_alnum_next_go(ph: NaPh) -> Bool: match ph: case NaDec{alnum}: match alnum: case True{}: False{} case False{}: True{} def not_alnum_next(next_suffix: String) -> Bool: match next_suffix: case SNil{}: True{} case SCon{+h, t}: not_alnum_next_go(NaDec{is_alnum(h)}) def standalone_from_prev(prev_alnum: Bool, +next_suffix: String) -> Bool: match prev_alnum: case True{}: False{} case False{}: not_alnum_next(next_suffix) def standalone_ok(+prev_rev: String, +next_suffix: String) -> Bool: match prev_rev: case SNil{}: standalone_from_prev(False{}, next_suffix) case SCon{+h, t}: standalone_from_prev(is_alnum(h), next_suffix) # --- Plain linkify --- type PlPh is Data: PlScan{} PlNotRef{} PlRefStart{} PlRefCont{} PlBoundOk{prev_alnum: Bool} PlTry{} PlTried{m: Maybe<&2, VisCan>} PlAfterTry{visible: String, canonical: String, empty: Bool} PlStand{visible: String, canonical: String, ok: Bool} PlUrl{visible: String, canonical: String, bad: Bool} PlEmit{ch: Char} def pick_pl_ref(at_start: Bool) -> PlPh: match at_start: case True{}: PlRefStart{} case False{}: PlRefCont{} def pick_pl(is_ref: Bool, at_start: Bool) -> PlPh: match is_ref: case False{}: PlNotRef{} case True{}: pick_pl_ref(at_start) def plain_append_link(+out: String, +visible: String, +canonical: String) -> String: String.append(out, linkify_parts(visible, canonical)) def plain_append_link_at( +out: String, +visible: String, +canonical: String, +suffix: String ) -> String: String.append(out, linkify_parts_qv(visible, canonical, peek_translation(suffix))) def linkify_plain_go( fuel: Nat, ph: PlPh, +rest: String, +prev_rev: String, out: String ) -> String: match fuel: case 0n: out case 1n+p: match ph: case PlScan{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_plain_go( p, pick_pl(looks_like_ref_start(h), String.is_empty(prev_rev)), SCon{h, t}, prev_rev, out ) case PlNotRef{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_plain_go(p, PlEmit{h}, SCon{h, t}, prev_rev, out) case PlRefStart{}: linkify_plain_go(p, PlTry{}, rest, prev_rev, out) case PlRefCont{}: match prev_rev: case SNil{}: linkify_plain_go(p, PlTry{}, rest, prev_rev, out) case SCon{+phc, pt}: linkify_plain_go(p, PlBoundOk{is_alnum(phc)}, rest, prev_rev, out) case PlBoundOk{prev_alnum}: match prev_alnum: case True{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_plain_go(p, PlEmit{h}, SCon{h, t}, prev_rev, out) case False{}: linkify_plain_go(p, PlTry{}, rest, prev_rev, out) case PlTry{}: match rest: case SNil{}: out case SCon{+h0, +t0}: linkify_plain_go( p, PlTried{try_windows(take_candidate(SCon{h0, t0}))}, SCon{h0, t0}, prev_rev, out ) case PlTried{m}: match m: case None{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_plain_go(p, PlEmit{h}, SCon{h, t}, prev_rev, out) case Some{vc}: match vc: case VisCan{+visible, +canonical}: linkify_plain_go( p, PlScan{}, Prot.str_drop(String.length(visible), rest), String.append(String.reverse(visible), prev_rev), plain_append_link_at( out, visible, canonical, Prot.str_drop(String.length(visible), rest) ) ) case PlAfterTry{+visible, +canonical, +empty}: match empty: case True{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_plain_go(p, PlEmit{h}, SCon{h, t}, prev_rev, out) case False{}: linkify_plain_go( p, PlStand{ visible, canonical, standalone_ok(prev_rev, Prot.str_drop(String.length(visible), rest)) }, rest, prev_rev, out ) case PlStand{+visible, +canonical, +ok}: match ok: case False{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_plain_go(p, PlEmit{h}, SCon{h, t}, prev_rev, out) case True{}: linkify_plain_go( p, PlUrl{ visible, canonical, is_urlish_token( token_around(prev_rev, visible, Prot.str_drop(String.length(visible), rest)) ) }, rest, prev_rev, out ) case PlUrl{+visible, +canonical, +bad}: match bad: case True{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_plain_go(p, PlEmit{h}, SCon{h, t}, prev_rev, out) case False{}: linkify_plain_go( p, PlScan{}, Prot.str_drop(String.length(visible), rest), String.append(String.reverse(visible), prev_rev), plain_append_link_at( out, visible, canonical, Prot.str_drop(String.length(visible), rest) ) ) case PlEmit{+ch}: match rest: case SNil{}: out case SCon{h, +t}: linkify_plain_go( p, PlScan{}, t, SCon{ch, prev_rev}, String.append(out, char1(ch)) ) type LpGate is Data: LpGate{ok: Bool} type FullHit2 is Data: FullMiss2{} FullExact{visible: String, canonical: String} FullPrefix{visible: String, canonical: String, rest: String} def classify_rest(exact: Bool, +visible: String, +canonical: String, +rest: String) -> FullHit2: match exact: case True{}: FullExact{visible, canonical} case False{}: FullPrefix{visible, canonical, rest} def classify_hit(m: Maybe<&2, VisCan>, +text: String) -> FullHit2: match m: case None{}: FullMiss2{} case Some{vc}: match vc: case VisCan{+visible, +canonical}: classify_rest( Nat.is_eq(String.length(visible), String.length(text)), visible, canonical, Prot.str_drop(String.length(visible), text) ) def linkify_plain_from_hit(h: FullHit2, +text: String) -> String: match h: case FullExact{+visible, +canonical}: plain_append_link("", visible, canonical) case FullPrefix{+visible, +canonical, +rest}: String.append( plain_append_link_at("", visible, canonical, rest), linkify_plain_go( Nat.add(Nat.mul(8n, String.length(rest)), 64n), PlScan{}, rest, String.reverse(visible), "" ) ) case FullMiss2{}: linkify_plain_go( Nat.add(Nat.mul(8n, String.length(text)), 64n), PlScan{}, text, "", "" ) def linkify_plain_gated(g: LpGate, +text: String) -> String: match g: case LpGate{ok}: match ok: case False{}: text case True{}: linkify_plain_from_hit(classify_hit(try_windows(take_candidate(text)), text), text) def linkify_plain(+text: String) -> String: linkify_plain_gated(LpGate{has_passage_signal(text)}, text) # --- Markdown walker --- type MdWalk is Data: MwCheck{at_bol: Bool, at_doc: Bool} MwDecide{at_bol: Bool, plen: Nat, z: Bool} MwProtect{len: Nat, at_bol: Bool} MwProtDec{len: Nat, at_bol: Bool, z: Bool, nl: Bool} MwPlainEmit{at_bol: Bool} MwFlush{at_bol: Bool, plen: Nat, z: Bool} def linkify_md_go( fuel: Nat, ph: MdWalk, +rest: String, plain: String, out: String ) -> String: match fuel: case 0n: String.append(out, linkify_plain(plain)) case 1n+p: match ph: case MwCheck{+at_bol, +at_doc}: match rest: case SNil{}: String.append(out, linkify_plain(plain)) case SCon{+h, +t}: linkify_md_go( p, MwDecide{ at_bol, Prot.frontmatter_or_protect(at_doc, at_bol, SCon{h, t}), False{} }, SCon{h, t}, plain, out ) case MwDecide{+at_bol, +plen, z}: linkify_md_go(p, MwFlush{at_bol, plen, Nat.is_eq(plen, 0n)}, rest, plain, out) case MwFlush{+at_bol, +plen, z}: match z: case True{}: linkify_md_go(p, MwPlainEmit{at_bol}, rest, plain, out) case False{}: linkify_md_go( p, MwProtect{plen, at_bol}, rest, "", String.append(out, linkify_plain(plain)) ) case MwProtect{+len, +at_bol}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_md_go( p, MwProtDec{len, at_bol, Nat.is_eq(len, 0n), is_newline(h)}, SCon{h, t}, plain, out ) case MwProtDec{+len, +at_bol, z, nl}: match z: case True{}: linkify_md_go(p, MwCheck{at_bol, False{}}, rest, plain, out) case False{}: match rest: case SNil{}: out case SCon{+h, +t}: linkify_md_go( p, MwProtect{Nat.sub(len, 1n), nl}, t, plain, String.append(out, char1(h)) ) case MwPlainEmit{+at_bol}: match rest: case SNil{}: String.append(out, linkify_plain(plain)) case SCon{+h, +t}: linkify_md_go( p, MwCheck{is_newline(h), False{}}, t, String.append(plain, char1(h)), out ) def linkify_markdown(+source: String) -> String: linkify_md_go( Nat.add(Nat.mul(10n, String.length(source)), 64n), MwCheck{True{}, True{}}, source, "", "" ) def show_links_rest(xs: List<&2, String>) -> IO(Unit): match xs: case Nil{}: IO.print("") case h <> t: do IO: IO.print(h) IO.print("\n") show_links_rest(t) def show_links(xs: List<&2, String>) -> IO(Unit): match xs: case Nil{}: IO.print("(no links)\n") case h <> t: do IO: IO.print(h) IO.print("\n") show_links_rest(t)