diff --git a/dune-project b/dune-project index 4dd3442bc4..847d7de77d 100644 --- a/dune-project +++ b/dune-project @@ -40,6 +40,7 @@ unionFind ocamlformat (junit_alcotest :with-test) - ocaml-lsp-server)) ; After upgrading to opam 2.2 use with-dev https://opam.ocaml.org/blog/opam-2-2-0/ + ocaml-lsp-server + uuseg)) ; After upgrading to opam 2.2 use with-dev https://opam.ocaml.org/blog/opam-2-2-0/ ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html diff --git a/hazel.opam b/hazel.opam index 09ee887ab3..1a4a583138 100644 --- a/hazel.opam +++ b/hazel.opam @@ -14,6 +14,7 @@ depends: [ "reason" {>= "3.12.0"} "ppx_yojson_conv_lib" "ppx_yojson_conv" + "incr_dom" "bisect_ppx" "omd" {>= "2.0.0~alpha4"} "ezjs_idb" @@ -25,6 +26,7 @@ depends: [ "ocamlformat" "junit_alcotest" {with-test} "ocaml-lsp-server" + "uuseg" "odoc" {with-doc} ] build: [ diff --git a/src/haz3lcore/Unicode.re b/src/haz3lcore/Unicode.re index e869072048..0da003d0c0 100644 --- a/src/haz3lcore/Unicode.re +++ b/src/haz3lcore/Unicode.re @@ -15,22 +15,5 @@ let ellipsis = "\xE2\x80\xA6"; // copied from hazel // NOTE: 30% faster than Camomile let length = (s: string): int => { - let stop = String.length(s); - let rec distance_aux = (start: int, count: int) => - if (start + count >= stop) { - stop - count; - } else { - let n = Char.code(String.unsafe_get(s, start + count)); - if (n < 0x80) { - distance_aux(start + 1, count); - } else if (n < 0xe0) { - distance_aux(start + 1, count + 1); - } else if (n < 0xf0) { - distance_aux(start + 1, count + 2); - } else { - distance_aux(start + 1, count + 3); - }; - }; - - distance_aux(0, 0); + Util.(StringUtil.length(s)); }; diff --git a/src/haz3lcore/assistant/AssistantExpander.re b/src/haz3lcore/assistant/AssistantExpander.re index f24be63756..9c264c1248 100644 --- a/src/haz3lcore/assistant/AssistantExpander.re +++ b/src/haz3lcore/assistant/AssistantExpander.re @@ -4,7 +4,7 @@ * inserted into the syntax so will not be reflected * in the decoration metrics */ -let last = t => String.sub(t, String.length(t) - 1, 1); +let last = t => String.sub(t, Util.StringUtil.length(t) - 1, 1); let is_expander = (label: Label.t) => switch (label) { diff --git a/src/haz3lcore/assistant/TyDi.re b/src/haz3lcore/assistant/TyDi.re index 1118fc115f..7368e0a1b8 100644 --- a/src/haz3lcore/assistant/TyDi.re +++ b/src/haz3lcore/assistant/TyDi.re @@ -1,6 +1,6 @@ open Util.OptUtil.Syntax; open Suggestion; - +open Util; /* Suggest the token at the top of the backpack, if we can put it down */ let suggest_backpack = (z: Zipper.t): list(Suggestion.t) => { /* Note: Sort check unnecessary here as wouldn't be able to put down */ @@ -61,8 +61,8 @@ let suffix_of = (candidate: Token.t, current: Token.t): option(Token.t) => { let candidate_suffix = String.sub( candidate, - String.length(current), - String.length(candidate) - String.length(current), + StringUtil.length(current), + StringUtil.length(candidate) - StringUtil.length(current), ); candidate_suffix == "" ? None : Some(candidate_suffix); }; diff --git a/src/haz3lcore/dynamics/Builtins.re b/src/haz3lcore/dynamics/Builtins.re index c47617edc6..6f90c148da 100644 --- a/src/haz3lcore/dynamics/Builtins.re +++ b/src/haz3lcore/dynamics/Builtins.re @@ -1,4 +1,5 @@ open DHExp; +open Util; /* Built-in functions for Hazel. @@ -221,7 +222,7 @@ module Pervasives = { let string_length = unary(d => switch (term_of(d)) { - | String(s) => Ok(Int(String.length(s)) |> fresh) + | String(s) => Ok(Int(StringUtil.length(s)) |> fresh) | _ => Error(InvalidBoxedStringLit(d)) } ); diff --git a/src/haz3lcore/lang/Form.re b/src/haz3lcore/lang/Form.re index 1990461239..5ea0d30748 100644 --- a/src/haz3lcore/lang/Form.re +++ b/src/haz3lcore/lang/Form.re @@ -83,13 +83,13 @@ let string_delim = "\""; let empty_string = string_delim ++ string_delim; let is_string_delim = (==)(string_delim); let strip_quotes = s => - if (String.length(s) < 2) { + if (StringUtil.length(s) < 2) { s; } else if (String.sub(s, 0, 1) != "\"" - || String.sub(s, String.length(s) - 1, 1) != "\"") { + || String.sub(s, StringUtil.length(s) - 1, 1) != "\"") { s; } else { - String.sub(s, 1, String.length(s) - 2); + String.sub(s, 1, StringUtil.length(s) - 2); }; let string_quote = s => "\"" ++ s ++ "\""; @@ -116,7 +116,7 @@ let is_potential_operand = match(regexp("^[a-zA-Z0-9_'\\.?]+$")); * delimiters, string delimiters, or the instant expanding paired * delimiters: ()[]| */ let potential_operator_regexp = - regexp("^[^a-zA-Z0-9_'?\"#\n\\s\\[\\]\\(\\)]+$"); /* Multiline operators not supported */ + regexp("^[^a-zA-Z0-9_'¿?\"#\n\\s\\[\\]\\(\\)]+$"); /* Multiline operators not supported */ let is_potential_operator = match(potential_operator_regexp); let is_potential_token = t => is_potential_operand(t) @@ -202,7 +202,11 @@ let const_mono_delims = base_typs @ bools @ [undefined, wild, empty_list, empty_tuple, empty_string]; let explicit_hole = "?"; +let implicit_hole = "¿"; let is_explicit_hole = t => t == explicit_hole; +let is_implicit_hole = t => { + t == implicit_hole; +}; let bad_token_cls: string => bad_token_cls = t => switch () { @@ -222,6 +226,13 @@ let atomic_forms: list((string, (string => bool, list(Mold.t)))) = [ [mk_op(Exp, []), mk_op(Pat, []), mk_op(Typ, []), mk_op(TPat, [])], ), ), + ( + "implicit_hole", + ( + is_implicit_hole, + [mk_op(Exp, []), mk_op(Pat, []), mk_op(Typ, []), mk_op(TPat, [])], + ), + ), ("wild", (is_wild, [mk_op(Pat, [])])), ("string", (is_string, [mk_op(Exp, []), mk_op(Pat, [])])), ("int_lit", (is_int, [mk_op(Exp, []), mk_op(Pat, [])])), diff --git a/src/haz3lcore/statics/MakeTerm.re b/src/haz3lcore/statics/MakeTerm.re index d1edb9d16a..7b3054c7eb 100644 --- a/src/haz3lcore/statics/MakeTerm.re +++ b/src/haz3lcore/statics/MakeTerm.re @@ -202,7 +202,10 @@ and exp_term: unsorted => (UExp.term, list(Id.t)) = { Match(scrut, rules), ids, ) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when + t != " " + && !(Form.is_explicit_hole(t) || Form.is_implicit_hole(t)) => ret(Invalid(t)) | _ => ret(hole(tm)) } @@ -345,7 +348,10 @@ and pat_term: unsorted => (UPat.term, list(Id.t)) = { | ([t], []) when Form.is_wild(t) => Wild | ([t], []) when Form.is_ctr(t) => Constructor(t, Unknown(Internal) |> Typ.fresh) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when + t != " " + && !(Form.is_explicit_hole(t) || Form.is_implicit_hole(t)) => Invalid(t) | (["(", ")"], [Pat(body)]) => Parens(body) | (["[", "]"], [Pat(body)]) => @@ -409,7 +415,10 @@ and typ_term: unsorted => (UTyp.term, list(Id.t)) = { | ([t], []) when Form.is_typ_var(t) => Var(t) | (["(", ")"], [Typ(body)]) => Parens(body) | (["[", "]"], [Typ(body)]) => List(body) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when + t != " " + && !(Form.is_explicit_hole(t) || Form.is_implicit_hole(t)) => Unknown(Hole(Invalid(t))) | _ => hole(tm) }, @@ -477,7 +486,10 @@ and tpat_term: unsorted => TPat.term = { ret( switch (tile) { | ([t], []) when Form.is_typ_var(t) => Var(t) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when + t != " " + && !(Form.is_explicit_hole(t) || Form.is_implicit_hole(t)) => Invalid(t) | _ => hole(tm) }, diff --git a/src/haz3lcore/statics/Var.re b/src/haz3lcore/statics/Var.re index 68c3d9d1b3..8bcec767a1 100644 --- a/src/haz3lcore/statics/Var.re +++ b/src/haz3lcore/statics/Var.re @@ -5,7 +5,7 @@ type t = string; let eq = String.equal; -let length = String.length; +let length = StringUtil.length; let is_true = eq("true"); @@ -21,7 +21,7 @@ let is_wild = eq("_"); let split = (pos, name) => { let left_var = String.sub(name, 0, pos); - let right_var = String.sub(name, pos, String.length(name) - pos); + let right_var = String.sub(name, pos, StringUtil.length(name) - pos); (left_var, right_var); }; diff --git a/src/haz3lcore/tiles/Token.re b/src/haz3lcore/tiles/Token.re index 8b68de1eb3..1c32e1b156 100644 --- a/src/haz3lcore/tiles/Token.re +++ b/src/haz3lcore/tiles/Token.re @@ -8,7 +8,7 @@ module Index = { type t = int; }; -let length = String.length; +let length = StringUtil.length; let compare = String.compare; let rm_nth = Util.StringUtil.remove_nth; let rm_last = Util.StringUtil.remove_last; diff --git a/src/haz3lcore/zipper/Printer.re b/src/haz3lcore/zipper/Printer.re index e6af6911ec..99a37dec11 100644 --- a/src/haz3lcore/zipper/Printer.re +++ b/src/haz3lcore/zipper/Printer.re @@ -45,7 +45,7 @@ let to_rows = switch (caret) { | Some({row, col}) => switch (ListUtil.split_nth_opt(row, rows)) { - | Some((pre, caret_row, suf)) when col < String.length(caret_row) => + | Some((pre, caret_row, suf)) when col < StringUtil.length(caret_row) => pre @ [StringUtil.insert_nth(col, caret_str, caret_row)] @ suf | Some((pre, caret_row, suf)) => pre @ [caret_row ++ caret_str] @ suf | _ => rows @@ -78,12 +78,12 @@ let zipper_to_string = ) |> String.concat("\n"); -let to_string_selection = (editor: Editor.t): string => +let to_string_selection = (~holes=?, editor: Editor.t): string => to_rows( ~measured=measured(editor.state.zipper), ~caret=None, ~indent=" ", - ~holes=None, + ~holes, ~segment=editor.state.zipper.selection.content, ) |> String.concat("\n"); @@ -104,5 +104,6 @@ let zipper_of_string = /* This serializes the current editor to text, resets the current editor, and then deserializes. It is intended as a (tactical) nuclear option for weird backpack states */ -let reparse = z => - zipper_of_string(~zipper_init=Zipper.init(), zipper_to_string(z)); +let reparse = (~holes=?, z) => { + zipper_of_string(~zipper_init=Zipper.init(), zipper_to_string(~holes, z)); +}; diff --git a/src/haz3lcore/zipper/action/Action.re b/src/haz3lcore/zipper/action/Action.re index 85b24be4f1..4afe936579 100644 --- a/src/haz3lcore/zipper/action/Action.re +++ b/src/haz3lcore/zipper/action/Action.re @@ -84,7 +84,9 @@ type t = | RotateBackpack | MoveToBackpackTarget(planar) | Pick_up - | Put_down; + | Put_down + | RemoveAllImplicitHoles + | ReparseToExplicitGrout; module Failure = { [@deriving (show({with_path: false}), sexp, yojson)] @@ -114,6 +116,8 @@ let is_edit: t => bool = | Destruct(_) | Pick_up | Put_down + | RemoveAllImplicitHoles + | ReparseToExplicitGrout | Buffer(Accept | Clear | Set(_)) => true | Copy | Move(_) @@ -151,6 +155,8 @@ let is_historic: t => bool = | Insert(_) | Destruct(_) | Pick_up + | ReparseToExplicitGrout + | RemoveAllImplicitHoles | Put_down => true | Project(p) => switch (p) { @@ -179,7 +185,9 @@ let prevent_in_read_only_editor = (a: t) => { | Pick_up | Put_down | RotateBackpack - | MoveToBackpackTarget(_) => true + | MoveToBackpackTarget(_) + | RemoveAllImplicitHoles + | ReparseToExplicitGrout => true | Project(p) => switch (p) { | SetSyntax(_) => true diff --git a/src/haz3lcore/zipper/action/Move.re b/src/haz3lcore/zipper/action/Move.re index afc46ca152..10930bfba5 100644 --- a/src/haz3lcore/zipper/action/Move.re +++ b/src/haz3lcore/zipper/action/Move.re @@ -428,4 +428,28 @@ module Make = (M: Editor.Meta.S) => { go(Local(Left(ByToken))), Piece.not_comment_or_space, ); + + // TODO Move to a different file + let remove_if_implicit_hole = z => { + switch (Indicated.piece'(~no_ws=false, ~ign=_ => false, z)) { + | Some((Tile({label: l, _}), Left, _)) when l == ["¿"] => + Destruct.go(Left, z) |> Option.map(remold_regrout(Left)) + | _ => Some(z) + }; + }; + + let rec move_right_and_perform = (f, z) => + switch (f(z)) { + | Some(z) => + switch (primary(ByToken, Right, z)) { + | Some(z) => move_right_and_perform(f, z) + | None => Some(z) + } + | None => Some(z) + }; + + let remove_all_implicit_holes = z => { + go(Goal(Point(Point.zero)), z) + |> Option.bind(_, move_right_and_perform(remove_if_implicit_hole)); + }; }; diff --git a/src/haz3lcore/zipper/action/Perform.re b/src/haz3lcore/zipper/action/Perform.re index 5a3b90c245..a030a8c178 100644 --- a/src/haz3lcore/zipper/action/Perform.re +++ b/src/haz3lcore/zipper/action/Perform.re @@ -201,6 +201,14 @@ let go_z = | RotateBackpack => let z = {...z, backpack: Util.ListUtil.rotate(z.backpack)}; Ok(z); + | RemoveAllImplicitHoles => + Move.remove_all_implicit_holes(z) + |> Result.of_option(~error=Action.Failure.Cant_move) + | ReparseToExplicitGrout => + switch (Printer.reparse(~holes="¿", z)) { + | None => Error(CantReparse) + | Some(z) => Ok(z) + } | MoveToBackpackTarget((Left(_) | Right(_)) as d) => if (Backpack.restricted(z.backpack)) { Move.to_backpack_target(d, z) diff --git a/src/haz3lcore/zipper/projectors/InfoProj.re b/src/haz3lcore/zipper/projectors/InfoProj.re index 6b467e76b1..2956a60a28 100644 --- a/src/haz3lcore/zipper/projectors/InfoProj.re +++ b/src/haz3lcore/zipper/projectors/InfoProj.re @@ -1,7 +1,7 @@ open Virtual_dom.Vdom; open Node; open ProjectorBase; - +open Util; let mode = (info: option(Info.t)): option(Mode.t) => switch (info) { | Some(InfoExp({mode, _})) @@ -68,7 +68,7 @@ module M: Projector = { display_ty(model, info) |> totalize_ty |> Typ.pretty_print; let placeholder = (model, info) => - Inline((display(model, info.ci) |> String.length) + 5); + Inline((display(model, info.ci) |> StringUtil.length) + 5); let update = (model, a: action) => switch (a, model) { diff --git a/src/haz3lweb/Benchmark.re b/src/haz3lweb/Benchmark.re index 6361b8143e..699f12049a 100644 --- a/src/haz3lweb/Benchmark.re +++ b/src/haz3lweb/Benchmark.re @@ -42,7 +42,7 @@ let non_empty_hole : Int = true in let str_to_inserts = (str: string): list(UpdateAction.t) => List.init( - String.length(str), + StringUtil.length(str), i => { let c = String.sub(str, i, 1); UpdateAction.PerformAction(Insert(c)); diff --git a/src/haz3lweb/Keyboard.re b/src/haz3lweb/Keyboard.re index 7e4e655f26..9068c74e5b 100644 --- a/src/haz3lweb/Keyboard.re +++ b/src/haz3lweb/Keyboard.re @@ -217,6 +217,16 @@ let shortcuts = (sys: Key.sys): list(shortcut) => "Reparse Current Editor", PerformAction(Reparse), ), + mk_shortcut( + ~section="Diagnostics", + "Reparse with explicit empty grout", + PerformAction(ReparseToExplicitGrout), + ), + mk_shortcut( + ~section="Diagnostics", + "Restore implicit holes", + PerformAction(RemoveAllImplicitHoles), + ), mk_shortcut( ~mdIcon="timer", ~section="Diagnostics", @@ -266,7 +276,7 @@ let handle_key_event = (k: Key.t): option(UpdateAction.t) => { | (Down, "Home") => now(Select(Resize(Extreme(Left(ByToken))))) | (Down, "End") => now(Select(Resize(Extreme(Right(ByToken))))) | (_, "Enter") => now(Insert(Form.linebreak)) - | _ when String.length(key) == 1 => + | _ when StringUtil.length(key) == 1 => /* Note: length==1 prevent specials like * SHIFT from being captured here */ now(Insert(key)) diff --git a/src/haz3lweb/UpdateAction.re b/src/haz3lweb/UpdateAction.re index cd2f145f3e..6ccb8970bb 100644 --- a/src/haz3lweb/UpdateAction.re +++ b/src/haz3lweb/UpdateAction.re @@ -255,6 +255,8 @@ let should_scroll_to_caret = | Paste(_) | Copy | Cut + | RemoveAllImplicitHoles + | ReparseToExplicitGrout | Reparse => true | Project(_) | Unselect(_) diff --git a/src/haz3lweb/util/Unicode.re b/src/haz3lweb/util/Unicode.re index 8f02baeb5a..340785f96b 100644 --- a/src/haz3lweb/util/Unicode.re +++ b/src/haz3lweb/util/Unicode.re @@ -14,22 +14,5 @@ let ellipsis = "\xE2\x80\xA6"; // copied from hazel // NOTE: 30% faster than Camomile let length = (s: string): int => { - let stop = String.length(s); - let rec distance_aux = (start: int, count: int) => - if (start + count >= stop) { - stop - count; - } else { - let n = Char.code(String.unsafe_get(s, start + count)); - if (n < 0x80) { - distance_aux(start + 1, count); - } else if (n < 0xe0) { - distance_aux(start + 1, count + 1); - } else if (n < 0xf0) { - distance_aux(start + 1, count + 2); - } else { - distance_aux(start + 1, count + 3); - }; - }; - - distance_aux(0, 0); + Util.StringUtil.length(s); }; diff --git a/src/haz3lweb/view/Code.re b/src/haz3lweb/view/Code.re index 608c6fad09..85f7997503 100644 --- a/src/haz3lweb/view/Code.re +++ b/src/haz3lweb/view/Code.re @@ -14,6 +14,7 @@ let of_delim' = | _ when !is_consistent => "sort-inconsistent" | _ when !is_complete => "incomplete" | [s] when s == Form.explicit_hole => "explicit-hole" + | [s] when s == Form.implicit_hole => "explicit-hole" | [s] when Form.is_string(s) => "string-lit" | _ => Sort.to_string(sort) }; diff --git a/src/haz3lweb/view/Page.re b/src/haz3lweb/view/Page.re index df98ba5ee9..9e58f6178c 100644 --- a/src/haz3lweb/view/Page.re +++ b/src/haz3lweb/view/Page.re @@ -42,7 +42,7 @@ let handlers = Effect.Ignore; }), Attr.on_copy(_ => { - JsUtil.copy(Printer.to_string_selection(editor)); + JsUtil.copy(Printer.to_string_selection(~holes="¿", editor)); Effect.Ignore; }), Attr.on_cut(_ => { diff --git a/src/haz3lweb/view/dhcode/layout/DHDoc_Exp.re b/src/haz3lweb/view/dhcode/layout/DHDoc_Exp.re index ffb0eed0c5..d88221ecc4 100644 --- a/src/haz3lweb/view/dhcode/layout/DHDoc_Exp.re +++ b/src/haz3lweb/view/dhcode/layout/DHDoc_Exp.re @@ -545,7 +545,9 @@ let mk = when !settings.show_fixpoints && String.ends_with(~suffix="+", name) => - "<" ++ String.sub(name, 0, String.length(name) - 1) ++ ">" + "<" + ++ String.sub(name, 0, Util.StringUtil.length(name) - 1) + ++ ">" | Some(name) => "<" ++ name ++ ">" }, ), @@ -591,7 +593,7 @@ let mk = when !settings.show_fixpoints && String.ends_with(~suffix="+", name) => - String.sub(name, 0, String.length(name) - 1) + String.sub(name, 0, Util.StringUtil.length(name) - 1) | Some(name) => name }; annot(DHAnnot.Collapsed, text("<" ++ name ++ ">")); @@ -624,7 +626,9 @@ let mk = if (String.ends_with(~suffix="+", x)) { annot( DHAnnot.Collapsed, - text("<" ++ String.sub(x, 0, String.length(x) - 1) ++ ">"), + text( + "<" ++ String.sub(x, 0, Util.StringUtil.length(x) - 1) ++ ">", + ), ); } else { annot(DHAnnot.Collapsed, text("<" ++ x ++ ">")); diff --git a/src/pretty/LayoutOfDoc.re b/src/pretty/LayoutOfDoc.re index dd5cd04a67..87a2bea61a 100644 --- a/src/pretty/LayoutOfDoc.re +++ b/src/pretty/LayoutOfDoc.re @@ -36,7 +36,7 @@ let rec layout_of_doc' = (doc: Doc.t(unit)): Doc.m(Layout.t(unit)) => { switch (doc.doc) { | Text(string) => // TODO: cache text length in Text? - let pos' = pos + String.length(string); //Unicode.length(string); + let pos' = pos + Util.StringUtil.length(string); //Unicode.length(string); let cost = if (pos' <= width) { Cost.zero; diff --git a/src/pretty/Unicode.re b/src/pretty/Unicode.re index 3554f1f4e8..11874ff18f 100644 --- a/src/pretty/Unicode.re +++ b/src/pretty/Unicode.re @@ -2,22 +2,5 @@ let nbsp = "\xC2\xA0"; // UTF-8 encoding for U+00A0 "No-break space" // NOTE: 30% faster than Camomile let length = (s: string): int => { - let stop = String.length(s); - let rec distance_aux = (start: int, count: int) => - if (start + count >= stop) { - stop - count; - } else { - let n = Char.code(String.unsafe_get(s, start + count)); - if (n < 0x80) { - distance_aux(start + 1, count); - } else if (n < 0xe0) { - distance_aux(start + 1, count + 1); - } else if (n < 0xf0) { - distance_aux(start + 1, count + 2); - } else { - distance_aux(start + 1, count + 3); - }; - }; - - distance_aux(0, 0); + Util.StringUtil.length(s); }; diff --git a/src/util/IntUtil.re b/src/util/IntUtil.re index 3970195a07..17bee97196 100644 --- a/src/util/IntUtil.re +++ b/src/util/IntUtil.re @@ -1,4 +1,4 @@ -let num_digits = n => String.length(string_of_int(n)); +let num_digits = n => StringUtil.length(string_of_int(n)); let modulo = (x, y) => { let result = x mod y; diff --git a/src/util/StringUtil.re b/src/util/StringUtil.re index a4fedde846..0a292d9792 100644 --- a/src/util/StringUtil.re +++ b/src/util/StringUtil.re @@ -1,29 +1,45 @@ let cat = String.concat(""); - +let length = (s: string): int => { + Uuseg_string.fold_utf_8(`Grapheme_cluster, (a, _) => a + 1, 0, s); +}; let remove_nth = (n, t) => { - assert(n < String.length(t)); - String.sub(t, 0, n) ++ String.sub(t, n + 1, String.length(t) - n - 1); + assert(n < length(t)); + String.sub(t, 0, n) ++ String.sub(t, n + 1, length(t) - n - 1); }; let remove_first = remove_nth(0); -let remove_last = t => remove_nth(String.length(t) - 1, t); +let remove_last = t => remove_nth(length(t) - 1, t); let insert_nth = (n, s, t) => { assert(n < String.length(t)); - String.sub(t, 0, n) ++ s ++ String.sub(t, n, String.length(t) - n); + String.sub(t, 0, n) ++ s ++ String.sub(t, n, length(t) - n); }; let split_nth = (n, t) => { assert(n < String.length(t)); - (String.sub(t, 0, n), String.sub(t, n, String.length(t) - n)); + (String.sub(t, 0, n), String.sub(t, n, length(t) - n)); }; -let to_list = s => List.init(String.length(s), i => String.make(1, s.[i])); +let to_list = s => { + let ret = + List.rev( + Uuseg_string.fold_utf_8( + `Grapheme_cluster, + (tl, hd) => [hd] @ tl, + [], + s, + ), + ); + print_endline( + "Splitting: " ++ s ++ " into " ++ [%derive.show: list(string)](ret), + ); + ret; +}; let repeat = (n, s) => String.concat("", List.init(n, _ => s)); let abbreviate = (max_len, s) => - String.length(s) > max_len ? String.sub(s, 0, max_len) ++ "..." : s; + length(s) > max_len ? String.sub(s, 0, max_len) ++ "..." : s; type regexp = Js_of_ocaml.Regexp.regexp; @@ -39,7 +55,7 @@ let split = Js_of_ocaml.Regexp.split; let to_lines = String.split_on_char('\n'); let line_widths = (s: string): list(int) => - s |> to_lines |> List.map(String.length); + s |> to_lines |> List.map(length); let max_line_width = (s: string): int => s |> line_widths |> List.fold_left(max, 0); diff --git a/src/util/Web.re b/src/util/Web.re index 358b1327d3..3a2e1410f8 100644 --- a/src/util/Web.re +++ b/src/util/Web.re @@ -67,7 +67,7 @@ module TextArea = { switch (lines) { | [] => {row, col} | [line, ...rest] => - let line_length = String.length(line); + let line_length = StringUtil.length(line); if (cur_pos <= line_length) { {row, col: cur_pos}; } else { @@ -99,7 +99,7 @@ module TextArea = { let full_row = List.nth(lines, row); { rows: rel(row, List.length(lines) - 1), - cols: rel(col, String.length(full_row)), + cols: rel(col, StringUtil.length(full_row)), }; }; @@ -129,7 +129,7 @@ module TextArea = { let set_caret_to_end = (textarea: t): unit => { textarea##focus; - let content_length = String.length(content(textarea)); + let content_length = StringUtil.length(content(textarea)); textarea##.selectionStart := content_length; textarea##.selectionEnd := content_length; }; diff --git a/src/util/dune b/src/util/dune index f50e6ac0f7..8413c785e1 100644 --- a/src/util/dune +++ b/src/util/dune @@ -1,6 +1,6 @@ (library (name util) - (libraries re base ptmap bonsai bonsai.web virtual_dom yojson) + (libraries re base ptmap bonsai bonsai.web virtual_dom yojson uuseg) (js_of_ocaml) (instrumentation (backend bisect_ppx)) diff --git a/test/Test_StringUtil.re b/test/Test_StringUtil.re new file mode 100644 index 0000000000..18cca32af6 --- /dev/null +++ b/test/Test_StringUtil.re @@ -0,0 +1,35 @@ +open Alcotest; +open Util; + +let tests = ( + "StringUtil", + [ + test_case( + "Unicode grapheme lengths", + `Quick, + () => { + check( + int, + "Length of upside down question mark", + 1, + StringUtil.length("¿"), + ); + check(int, "Length of alpha character", 1, StringUtil.length("a")); + check( + int, + "Length of multiple upside down question marks", + 3, + StringUtil.length("¿¿¿"), + ); + }, + ), + test_case("regex matches upside down question mark", `Quick, () => { + check( + bool, + "Match upside down question mark", + true, + StringUtil.match(StringUtil.regexp("^¿$"), "¿"), + ) + }), + ], +); diff --git a/test/haz3ltest.re b/test/haz3ltest.re index 8e4a838b0a..a19af54e59 100644 --- a/test/haz3ltest.re +++ b/test/haz3ltest.re @@ -5,6 +5,7 @@ let (suite, _) = ~and_exit=false, "HazelTests", [ + Test_StringUtil.tests, ("Elaboration", Test_Elaboration.elaboration_tests), ("Statics", Test_Statics.tests), ("Evaluator", Test_Evaluator.tests),