Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

Optimise single character matching on JavaScript

author
Gears
committer
Louis Pilfold
date (Mar 26, 2026, 12:29 PM UTC) commit 489081e1 parent 27309fb4 change-id llzlrsvt
+223 -13
+16
CHANGELOG.md
··· 49 49 50 50 ([Giacomo Cavalieri](https://github.com/giacomocavalieri)) 51 51 52 + - The compiler now emits more efficient code when matching on single-character 53 + string prefixes on the JavaScript target. For example, the `glance` package 54 + is now nearly 30% faster on the JavaScript target: 55 + 56 + ``` 57 + # before: 58 + min: 10.8ms, max: 365.82ms, median: 14.74ms, mean: 14.76ms 59 + warmup: 100/1.5s, total post-warmup: 1000/14.76s 60 + 61 + # after: 62 + min: 8.96ms, max: 143.76ms, median: 10.72ms, mean: 11.06ms 63 + warmup: 100/1.24s, total post-warmup: 1000/11.06s 64 + ``` 65 + 66 + ([Surya Rose](https://github.com/GearsDatapacks)) 67 + 52 68 ### Build tool 53 69 54 70 - The `gleam hex owner add` command has been added, which allows adding
+78 -1
compiler-core/src/javascript/decision.rs
··· 408 408 assignments.push(let_doc(name, value.to_doc())) 409 409 }; 410 410 411 + // Variable storing the character code for the first character of a string. 412 + // This is only declared if multiple patterns match on just the first 413 + // character, as it allows us to avoid calling `.startsWith` multiple times 414 + // and just call `.charCodeAt(0)` once, which is much faster. 415 + let first_character_variable = if multiple_single_character_prefix_checks(choices) { 416 + let name = self.variables.next_local_var(&ASSIGNMENT_VAR.into()); 417 + let string = self.variables.get_value(var); 418 + let first_character = docvec![string, ".charCodeAt(0)"]; 419 + assignments.push(let_doc(name.clone(), first_character)); 420 + Some(name) 421 + } else { 422 + None 423 + }; 424 + 411 425 let mut if_ = CaseBody::Statements(nil()); 412 426 for (i, (check, decision)) in choices.iter().enumerate() { 413 427 self.variables.record_check_assignments(var, check); ··· 419 433 // referenced by this check 420 434 let (check_doc, body, mut segment_assignments) = self.inside_new_scope(|this| { 421 435 let segment_assignments = this.variables.bit_array_segment_assignments(check); 422 - let check_doc = this.variables.runtime_check(var, check); 436 + 437 + // If the pattern matches on a single character, use the character 438 + // code instead of `.startsWith`. 439 + let check_doc = if let Some(code) = single_character_prefix_code(check) { 440 + let first_character = if let Some(variable) = &first_character_variable { 441 + variable.to_doc() 442 + } else { 443 + // This is the only single-character match in this `case` 444 + // expression, so we don't bind it to a variable and just 445 + // call `.charCodeAt` inline. This is still faster than 446 + // `.startsWith`. 447 + let string = this.variables.get_value(var); 448 + docvec![string, ".charCodeAt(0)"] 449 + }; 450 + 451 + docvec![first_character, " === ", code] 452 + } else { 453 + this.variables.runtime_check(var, check) 454 + }; 455 + 423 456 let body = this.decision(decision); 424 457 (check_doc, body, segment_assignments) 425 458 }); ··· 700 733 ], 701 734 ) 702 735 } 736 + } 737 + 738 + /// Returns the character code for the character being matched for patterns matching 739 + /// on single-character string prefixes. 740 + fn single_character_prefix_code(check: &RuntimeCheck) -> Option<u32> { 741 + match check { 742 + // On JavaScript, a single "character" is one that can be represented as 743 + // a single UTF-16 codepoint. 744 + RuntimeCheck::StringPrefix { prefix, rest } if utf16_no_escape_len(prefix) == 1 => { 745 + convert_string_escape_chars(prefix) 746 + .chars() 747 + .next() 748 + .map(|first| first as u32) 749 + } 750 + RuntimeCheck::Int { .. } 751 + | RuntimeCheck::Float { .. } 752 + | RuntimeCheck::String { .. } 753 + | RuntimeCheck::StringPrefix { .. } 754 + | RuntimeCheck::Tuple { .. } 755 + | RuntimeCheck::BitArray { .. } 756 + | RuntimeCheck::Variant { .. } 757 + | RuntimeCheck::NonEmptyList { .. } 758 + | RuntimeCheck::EmptyList => None, 759 + } 760 + } 761 + 762 + /// Returns whether a `case` expression contains multiple patterns matching on 763 + /// the first character of a string. 764 + fn multiple_single_character_prefix_checks(choices: &[(RuntimeCheck, Decision)]) -> bool { 765 + let mut encountered_check = false; 766 + 767 + for (check, _) in choices.iter() { 768 + if let RuntimeCheck::StringPrefix { prefix, .. } = check 769 + && utf16_no_escape_len(prefix) == 1 770 + { 771 + if encountered_check { 772 + return true; 773 + } else { 774 + encountered_check = true; 775 + } 776 + } 777 + } 778 + 779 + false 703 780 } 704 781 705 782 pub fn let_<'a>(
+25
compiler-core/src/javascript/tests/case.rs
··· 943 943 }"# 944 944 ) 945 945 } 946 + 947 + // https://github.com/gleam-lang/gleam/issues/5400 948 + #[test] 949 + fn case_matching_single_character_prefixes() { 950 + assert_js!( 951 + r#" 952 + pub fn parse_digit(s: String) -> Int { 953 + case s { 954 + "0" <> s -> 0 955 + "1" <> s -> 1 956 + "2" <> s -> 2 957 + "3" <> s -> 3 958 + "4" <> s -> 4 959 + "5" <> s -> 5 960 + "6" <> s -> 6 961 + "7" <> s -> 7 962 + "8" <> s -> 8 963 + "9" <> s -> 9 964 + "" -> todo 965 + _ -> panic 966 + } 967 + } 968 + "# 969 + ); 970 + }
+1 -1
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__assignments__let_assert_nested_string_prefix.snap
··· 31 31 let prefix; 32 32 let rest; 33 33 let $1 = $.wibble; 34 - if ($1.startsWith("w")) { 34 + if ($1.charCodeAt(0) === 119) { 35 35 prefix = "w"; 36 36 rest = $1.slice(1); 37 37 } else {
+1 -1
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__case__case_building_matched_string_1.snap
··· 17 17 import * as $gleam from "../gleam.mjs"; 18 18 19 19 export function go(x) { 20 - if (x.startsWith("a")) { 20 + if (x.charCodeAt(0) === 97) { 21 21 return x; 22 22 } else { 23 23 return "";
+1 -1
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__case__case_building_matched_string_2.snap
··· 17 17 import * as $gleam from "../gleam.mjs"; 18 18 19 19 export function go(x) { 20 - if (x.startsWith("a")) { 20 + if (x.charCodeAt(0) === 97) { 21 21 return x; 22 22 } else { 23 23 return "";
+83
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__case__case_matching_single_character_prefixes.snap
··· 1 + --- 2 + source: compiler-core/src/javascript/tests/case.rs 3 + expression: "\npub fn parse_digit(s: String) -> Int {\n case s {\n \"0\" <> s -> 0\n \"1\" <> s -> 1\n \"2\" <> s -> 2\n \"3\" <> s -> 3\n \"4\" <> s -> 4\n \"5\" <> s -> 5\n \"6\" <> s -> 6\n \"7\" <> s -> 7\n \"8\" <> s -> 8\n \"9\" <> s -> 9\n \"\" -> todo\n _ -> panic\n }\n}\n" 4 + --- 5 + ----- SOURCE CODE 6 + 7 + pub fn parse_digit(s: String) -> Int { 8 + case s { 9 + "0" <> s -> 0 10 + "1" <> s -> 1 11 + "2" <> s -> 2 12 + "3" <> s -> 3 13 + "4" <> s -> 4 14 + "5" <> s -> 5 15 + "6" <> s -> 6 16 + "7" <> s -> 7 17 + "8" <> s -> 8 18 + "9" <> s -> 9 19 + "" -> todo 20 + _ -> panic 21 + } 22 + } 23 + 24 + 25 + ----- COMPILED JAVASCRIPT 26 + import { makeError } from "../gleam.mjs"; 27 + 28 + const FILEPATH = "src/module.gleam"; 29 + 30 + export function parse_digit(s) { 31 + let $ = s.charCodeAt(0); 32 + if ($ === 48) { 33 + let s$1 = s.slice(1); 34 + return 0; 35 + } else if ($ === 49) { 36 + let s$1 = s.slice(1); 37 + return 1; 38 + } else if ($ === 50) { 39 + let s$1 = s.slice(1); 40 + return 2; 41 + } else if ($ === 51) { 42 + let s$1 = s.slice(1); 43 + return 3; 44 + } else if ($ === 52) { 45 + let s$1 = s.slice(1); 46 + return 4; 47 + } else if ($ === 53) { 48 + let s$1 = s.slice(1); 49 + return 5; 50 + } else if ($ === 54) { 51 + let s$1 = s.slice(1); 52 + return 6; 53 + } else if ($ === 55) { 54 + let s$1 = s.slice(1); 55 + return 7; 56 + } else if ($ === 56) { 57 + let s$1 = s.slice(1); 58 + return 8; 59 + } else if ($ === 57) { 60 + let s$1 = s.slice(1); 61 + return 9; 62 + } else if (s === "") { 63 + throw makeError( 64 + "todo", 65 + FILEPATH, 66 + "my/mod", 67 + 14, 68 + "parse_digit", 69 + "`todo` expression evaluated. This code has not yet been implemented.", 70 + {} 71 + ) 72 + } else { 73 + throw makeError( 74 + "panic", 75 + FILEPATH, 76 + "my/mod", 77 + 15, 78 + "parse_digit", 79 + "`panic` expression evaluated.", 80 + {} 81 + ) 82 + } 83 + }
+1 -1
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__case__deeply_nested_string_prefix_assignment.snap
··· 52 52 export function main() { 53 53 let tmp = new Wibble(new Wobble(new Wabble([42, "wibble"]))); 54 54 let $ = tmp[0].wabble.tuple[1]; 55 - if ($.startsWith("w")) { 55 + if ($.charCodeAt(0) === 119) { 56 56 let wibble = "w"; 57 57 let rest = $.slice(1); 58 58 return wibble + rest;
+1 -1
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__case__nested_string_prefix_assignment.snap
··· 32 32 export function main() { 33 33 let tmp = new Wibble("wibble"); 34 34 let $ = tmp.wobble; 35 - if ($.startsWith("w")) { 35 + if ($.charCodeAt(0) === 119) { 36 36 let wibble = "w"; 37 37 let rest = $.slice(1); 38 38 return wibble + rest;
+1 -1
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__case__slicing_is_handled_properly_with_multiple_branches.snap
··· 16 16 ----- COMPILED JAVASCRIPT 17 17 export function main() { 18 18 let $ = "12345"; 19 - if ($.startsWith("0")) { 19 + if ($.charCodeAt(0) === 48) { 20 20 let rest = $.slice(1); 21 21 return rest; 22 22 } else if ($.startsWith("123")) {
-2
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__strings__case.snap
··· 1 1 --- 2 2 source: compiler-core/src/javascript/tests/strings.rs 3 - assertion_line: 84 4 3 expression: "\npub fn go(a) {\n case a {\n \"\" -> 0\n \"one\" -> 1\n \"two\" -> 2\n _ -> 3\n }\n}\n" 5 - snapshot_kind: text 6 4 --- 7 5 ----- SOURCE CODE 8 6
+1 -1
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__strings__string_prefix_assignment_with_multiple_subjects.snap
··· 14 14 15 15 ----- COMPILED JAVASCRIPT 16 16 export function go(x) { 17 - if (x.startsWith("1")) { 17 + if (x.charCodeAt(0) === 49) { 18 18 let prefix = "1"; 19 19 return prefix; 20 20 } else {
+3 -3
compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__strings__string_prefix_utf16.snap
··· 31 31 ----- COMPILED JAVASCRIPT 32 32 export function go(x) { 33 33 let $ = "Θ wibble wobble"; 34 - if ($.startsWith("Θ")) { 34 + if ($.charCodeAt(0) === 920) { 35 35 let rest = $.slice(1); 36 36 rest 37 37 } else { ··· 52 52 "" 53 53 } 54 54 let $3 = "\" is a an escaped quote"; 55 - if ($3.startsWith("\"")) { 55 + if ($3.charCodeAt(0) === 34) { 56 56 let rest = $3.slice(1); 57 57 rest 58 58 } else { 59 59 "" 60 60 } 61 61 let $4 = "\\ is a an escaped backslash"; 62 - if ($4.startsWith("\\")) { 62 + if ($4.charCodeAt(0) === 92) { 63 63 let rest = $4.slice(1); 64 64 return rest; 65 65 } else {
+11
test/language/test/language/string_pattern_matching_test.gleam
··· 61 61 } 62 62 assert " is a newline that escaped" == string_2 63 63 } 64 + 65 + pub fn multiple_single_character_matches() { 66 + let string = "1234" 67 + let string_2 = case string { 68 + "0" <> _ -> panic 69 + "1" <> rest -> rest 70 + "2" <> _ -> panic 71 + _ -> panic 72 + } 73 + assert "234" == string_2 74 + }