#!/usr/bin/env python3 """Regenerate src/emoji.zig from Unicode and CLDR data. tools/gen-emoji.py # fetch the data, write src/emoji.zig tools/gen-emoji.py --check # fail if the checked-in file is stale tools/gen-emoji.py --emoji-test PATH --annotations PATH --derived PATH The table it writes is committed, so this runs by hand rather than as part of the build: a build that needs the network to compile is a build that fails on a train. Re-run it when a new Unicode version lands, or when you want to add a synonym to SYNONYMS below. Three inputs, all canonical: emoji-test.txt Unicode's own ordering, grouped and subgrouped, with the fully-qualified form of every RGI emoji. This is what every emoji keyboard is laid out from. annotations/en.xml CLDR's English keywords per emoji β€” the synonyms that make search work ("grin" finding the grinning face). annotationsDerived/en.xml The same for sequences CLDR derives rather than names outright, which is most of the ZWJ ones. Skin-tone variants are left out. Unicode lists 3944 fully-qualified emoji and just over half of those are the same gesture five more times; a grid of them is harder to look through, not more complete. Every base glyph is present, which is the same choice GTK's own emoji chooser, iOS and Slack all make. So is anything newer than MAX_VERSION. Colour emoji fonts trail Unicode by a year or two, and a glyph the font has never heard of draws as a hex-digit box β€” which in a picker reads as a bug rather than as a font that needs updating. At the time of writing, Noto Color Emoji could draw all but 7 of the E17.0 additions and everything older. Raise MAX_VERSION when fonts have caught up; `--max-version 99` turns the cutoff off entirely. """ import argparse import collections import os import re import sys import urllib.request # "latest" rather than a pinned version: the per-version directories under # /Public/emoji/ stop being published once a version ships, so a pinned URL is # one that 404s later. What version "latest" turned out to be is recorded in the # generated file's header, and MAX_VERSION below is what actually decides the # contents. EMOJI_TEST = "https://unicode.org/Public/emoji/latest/emoji-test.txt" ANNOTATIONS = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotations/en.xml" DERIVED = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotationsDerived/en.xml" # Terms no data file will ever give you: what a glyph means to someone labelling # a terminal tab. CLDR knows a rocket is a "space ship"; it does not know it is # what you reach for when the tab is a deploy. SYNONYMS = { "πŸš€": "deploy ship launch release", "πŸ“¦": "release bundle package ship artifact", "🏷️": "version tag release", "πŸŽ‰": "ship shipped release celebrate tada", "πŸ›": "bug issue defect regression", "🩹": "hotfix patch bandaid", "πŸ”₯": "hot lit onfire urgent", "πŸ’₯": "crash boom broke", "πŸ’€": "dead deprecated killed", "πŸ¦–": "legacy ancient", "⚑": "zap fast perf quick", "⏱️": "benchmark perf timing latency", "πŸ§ͺ": "test experiment trial", "🧫": "test lab", "πŸ”¬": "inspect investigate research", "πŸ”": "search find grep lookup", "πŸ‘€": "review look watch eyes", "βœ…": "pass passing green done ok", "❌": "fail failing red broken", "⚠️": "warn warning caution", "🚧": "wip work in progress unfinished", "πŸ—οΈ": "wip building scaffolding", "🧹": "cleanup refactor tidy sweep", "♻️": "refactor reuse recycle", "πŸ”„": "sync retry refresh reload", "πŸ”€": "shuffle random merge", "βš™οΈ": "settings config gear options", "πŸ”’": "secure private locked", "πŸ”“": "public unlocked open", "πŸ”‘": "auth key password access token secret", "πŸ›‘οΈ": "security hardening defense", "πŸ“ˆ": "metrics growth up analytics", "πŸ“‰": "metrics down regression analytics", "πŸ“Š": "metrics analytics stats dashboard", "πŸ””": "alert notification ping", "πŸ”•": "mute silence snooze", "πŸ’»": "dev code local laptop", "πŸ–₯️": "server desktop box host", "πŸ—„οΈ": "database storage archive", "🐳": "docker container whale", "🐧": "linux tux", "πŸ¦€": "rust cargo", "🐍": "python", "🐫": "perl camel", "β˜•": "java coffee jvm", "πŸ’Ž": "ruby gem", "🐘": "postgres php elephant memory", "🍎": "apple mac macos", "πŸͺŸ": "windows", "πŸ€–": "bot agent ai automation claude", "πŸ§‘β€πŸ’»": "dev developer engineer coding", "πŸ“": "todo note notes scratch", "πŸ—‘οΈ": "delete trash remove drop", "🌈": "pride rainbow", "🎯": "goal target focus", "🧭": "navigate direction bearings", "🚦": "ci status pipeline signal", "🏁": "done finished race", "🧊": "freeze frozen cold pinned", "πŸ•ΈοΈ": "stale abandoned cobweb", "🧠": "think smart reasoning", } # Nothing in a keyword list should be a word you cannot type. Everything else is # kept, including the non-ASCII names of places, because a term only has to # match to be worth carrying. STRIP = re.compile(r"[\"\\|,:;()\[\]{}!?β€œβ€]+") SKIN_TONES = range(0x1F3FB, 0x1F400) # Newest emoji version to include. See the note at the top of this file. MAX_VERSION = "16.0" def version_tuple(text): """"E16.0" or "16" as something comparable. Unknown sorts newest.""" try: return tuple(int(p) for p in text.lstrip("Ee").split(".")) except ValueError: return (999,) def read(source): """Contents of a URL or a path, whichever `source` looks like.""" if source.startswith(("http://", "https://")): with urllib.request.urlopen(source, timeout=60) as response: return response.read().decode("utf-8") with open(source, encoding="utf-8") as handle: return handle.read() def parse_emoji_test(text, max_version): """Unicode's list, in Unicode's order: (glyph, group, subgroup, name).""" out = [] skipped = [] group = subgroup = "" version = "unknown" for line in text.splitlines(): if line.startswith("# Version:"): version = line.split(":", 1)[1].strip() continue if line.startswith("# group:"): group = line.split(":", 1)[1].strip() continue if line.startswith("# subgroup:"): subgroup = line.split(":", 1)[1].strip() continue if not line.strip() or line.startswith("#"): continue codepoints, rest = line.split(";", 1) status, _, comment = rest.partition("#") if status.strip() != "fully-qualified": continue points = [int(c, 16) for c in codepoints.split()] if any(p in SKIN_TONES for p in points): continue # The comment is " E ": the version each sequence # was introduced in, which is what the font-coverage cutoff reads. parts = comment.strip().split(" ", 2) introduced = parts[1] if len(parts) > 1 else "E0" name = parts[2] if len(parts) > 2 else "" if version_tuple(introduced) > version_tuple(max_version): skipped.append(introduced) continue out.append(("".join(chr(p) for p in points), group, subgroup, name)) return version, out, collections.Counter(skipped) def parse_annotations(text): """CLDR's keywords and short name per emoji, merged into one dict.""" keywords = {} names = {} for match in re.finditer( r' type="tts")?>(.*?)', text, re.DOTALL, ): cp, tts, body = match.group(1), match.group("tts"), match.group(3) body = ( body.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace(""", '"') ) if tts: names[cp] = body.strip() else: keywords.setdefault(cp, []).extend(p.strip() for p in body.split("|")) return keywords, names def escape(text): """`text` as a Zig string literal body. Only the display name needs this. Keywords go through `tokenize`, which drops every character that would have to be escaped in the first place. """ return text.replace("\\", "\\\\").replace('"', '\\"') def tokenize(*phrases): """Lowercase words from `phrases`, deduplicated, in first-seen order.""" seen = [] for phrase in phrases: if not phrase: continue cleaned = STRIP.sub(" ", phrase.replace("-", " ").replace("_", " ")) for word in cleaned.lower().split(): # A bare "&" survives group names like "Smileys & Emotion". if word == "&": continue if word not in seen: seen.append(word) return seen def build(emoji_test, annotations, derived, max_version): version, entries, skipped = parse_emoji_test(emoji_test, max_version) keywords, names = parse_annotations(annotations) derived_keywords, derived_names = parse_annotations(derived) for source, into in ((derived_keywords, keywords), (derived_names, names)): for cp, value in source.items(): if cp not in into: into[cp] = value rows = [] for glyph, group, subgroup, name in entries: # CLDR keys on the emoji without its presentation selector as often as # with it, so try both before giving up and using Unicode's own name. bare = glyph.replace("\ufe0f", "") short = names.get(glyph) or names.get(bare) or name words = tokenize( short, " ".join(keywords.get(glyph, keywords.get(bare, []))), # The group and subgroup make whole shelves reachable by name: # "flags", "fruit", "arrow", "zodiac". subgroup, group, SYNONYMS.get(glyph, ""), ) rows.append((glyph, group, escape(short), " ".join(words))) return version, rows, skipped def render(version, rows, max_version): out = [] out.append( HEADER % {"version": version, "count": len(rows), "max_version": max_version} ) group = None for glyph, row_group, short, words in rows: if row_group != group: group = row_group if out[-1].endswith("},\n"): out.append("\n") out.append(" // ---- %s %s\n" % (group, "-" * max(3, 60 - len(group)))) out.append( ' .{ .glyph = "%s", .name = "%s", .keywords = "%s" },\n' % (glyph, short, words) ) out.append("};\n") out.append(TESTS) return "".join(out) HEADER = '''//! Every emoji a tab can wear in place of its icon, and the search that finds //! them. //! //! Generated β€” run `tools/gen-emoji.py` rather than editing this file. It reads //! Unicode's `emoji-test.txt` (currently version %(version)s) for the set and its //! ordering, and CLDR's English annotations for the keywords, so the picker is //! laid out and searchable the same way every other emoji keyboard is. //! //! %(count)d entries: the RGI set up to emoji %(max_version)s, minus skin-tone variants. //! Unicode lists nearly twice this many fully-qualified sequences and the //! difference is almost entirely the same gesture in five tones, which makes a //! grid longer to look through without making it more complete. The version //! cutoff is there because colour emoji fonts trail Unicode, and a glyph the //! font has never heard of draws as a hex-digit box; the generator's header //! explains how to raise it. //! //! Keywords are CLDR's, plus the group and subgroup a glyph belongs to β€” so //! "fruit", "arrow" and "flags" each bring back a whole shelf β€” plus a table of //! synonyms in the generator for the words a terminal user would actually type: //! a rocket answers to "deploy", a bandage to "hotfix", a whale to "docker". const std = @import("std"); pub const Emoji = struct { /// The glyph itself, NUL-terminated so it can go straight into a label. glyph: [:0]const u8, /// CLDR's short name β€” "red apple", "flag: Kenya" β€” as the picker shows it /// in a tooltip. NUL-terminated for the same reason as the glyph, and kept /// apart from `keywords` because a name is one phrase and a keyword list is /// twenty words: readable in a tooltip, and unreadable in one. name: [:0]const u8, /// Space-separated search terms, lowercase, the name's own words among them. keywords: []const u8, }; /// Whether `emoji` should show for `query`. /// /// Every whitespace-separated term has to match somewhere, which is what makes /// "red circle" and "circle red" both land on the same glyph while "red" alone /// still brings back the whole family. An empty query matches everything, so /// the unfiltered grid falls out of the same path as a filtered one. pub fn matches(emoji: Emoji, query: []const u8) bool { var terms = std.mem.tokenizeAny(u8, query, " \\t"); while (terms.next()) |term| { if (std.ascii.indexOfIgnoreCase(emoji.keywords, term) == null) return false; } return true; } /// Every emoji the picker offers, in Unicode's order. pub const table = [_]Emoji{ ''' TESTS = ''' // ------------------------------------------------------------------------- // Tests // // The generator is what keeps the table right; these are the properties the // picker depends on it having. They run against whatever is checked in, so a // bad regeneration fails here rather than in the dialog. test "every entry is usable" { for (table) |entry| { try std.testing.expect(entry.glyph.len > 0); try std.testing.expect(std.unicode.utf8ValidateSlice(entry.glyph)); // Long enough for the longest RGI sequence, short enough that nothing // here is quietly a whole word. try std.testing.expect(entry.glyph.len <= 40); try std.testing.expect(entry.name.len > 0); try std.testing.expect(std.unicode.utf8ValidateSlice(entry.name)); try std.testing.expect(entry.keywords.len > 0); for (entry.keywords) |c| try std.testing.expect(!std.ascii.isUpper(c)); } } test "glyphs are distinct" { // A child's position in the picker's grid is how the dialog names the entry // it shows, and `indexOf` maps the other way by comparing glyphs. Both stop // being true if a glyph appears twice. for (table, 0..) |entry, i| { for (table[i + 1 ..]) |other| { try std.testing.expect(!std.mem.eql(u8, entry.glyph, other.glyph)); } } } test "the whole set is here" { // Unicode 17 has 1914 RGI emoji once skin-tone variants are folded away. // A table that has drifted far from that has lost a group. try std.testing.expect(table.len > 1800); } /// The one entry a search test leans on, looked up rather than indexed so that /// regenerating the table doesn't rewrite the tests. fn find(glyph: []const u8) Emoji { for (table) |entry| { if (std.mem.eql(u8, entry.glyph, glyph)) return entry; } unreachable; } test "an empty query matches everything" { for (table) |entry| { try std.testing.expect(matches(entry, "")); try std.testing.expect(matches(entry, " ")); } } test "a term matches part of a keyword, in any case" { const rocket = find("\\u{1F680}"); try std.testing.expect(matches(rocket, "rocket")); try std.testing.expect(matches(rocket, "ROCKET")); try std.testing.expect(matches(rocket, "Rock")); try std.testing.expect(!matches(rocket, "banana")); } test "every term has to match, in any order" { const green = find("\\u{1F7E2}"); try std.testing.expect(matches(green, "green circle")); try std.testing.expect(matches(green, "circle green")); try std.testing.expect(matches(green, " green circle ")); // The second term is what rules the other circles out. const red = find("\\u{1F534}"); try std.testing.expect(matches(red, "circle")); try std.testing.expect(!matches(red, "green circle")); // And a term matching nothing rules out a glyph the rest of the query hit. try std.testing.expect(!matches(green, "green circle sideways")); } test "names are the phrase, not the keyword list" { // What separates the two fields: the tooltip stays short enough to read. for (table) |entry| try std.testing.expect(entry.name.len <= 64); try std.testing.expectEqualStrings("red apple", find("\\u{1F34E}").name); try std.testing.expectEqualStrings("rocket", find("\\u{1F680}").name); } test "CLDR keywords reach a glyph its name would not" { try std.testing.expect(matches(find("\\u{1F600}"), "grin")); try std.testing.expect(matches(find("\\u{1F60A}"), "blush")); try std.testing.expect(matches(find("\\u{1F4A9}"), "poop")); } test "a group or subgroup brings back its whole shelf" { try std.testing.expect(matches(find("\\u{1F34E}"), "fruit")); try std.testing.expect(matches(find("\\u{1F1FA}\\u{1F1F8}"), "flags")); try std.testing.expect(matches(find("\\u{2B06}\\u{FE0F}"), "arrow")); } test "synonyms reach the glyph the data would not" { try std.testing.expect(matches(find("\\u{1F680}"), "deploy")); try std.testing.expect(matches(find("\\u{1FA79}"), "hotfix")); try std.testing.expect(matches(find("\\u{1F433}"), "docker")); try std.testing.expect(matches(find("\\u{1F427}"), "linux")); try std.testing.expect(matches(find("\\u{1F980}"), "rust")); try std.testing.expect(matches(find("\\u{2705}"), "pass")); } ''' def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--emoji-test", default=EMOJI_TEST) parser.add_argument("--annotations", default=ANNOTATIONS) parser.add_argument("--derived", default=DERIVED) parser.add_argument("--out", default=None) parser.add_argument( "--max-version", default=MAX_VERSION, help="newest emoji version to include (default %s)" % MAX_VERSION, ) parser.add_argument( "--check", action="store_true", help="exit non-zero if the file on disk is not what we would write", ) args = parser.parse_args() root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) out = args.out or os.path.join(root, "src", "emoji.zig") version, rows, skipped = build( read(args.emoji_test), read(args.annotations), read(args.derived), args.max_version, ) text = render(version, rows, args.max_version) if args.check: current = open(out, encoding="utf-8").read() if os.path.exists(out) else "" if current != text: print("%s is stale; re-run tools/gen-emoji.py" % out, file=sys.stderr) return 1 print("%s is up to date (%d emoji)" % (out, len(rows))) return 0 with open(out, "w", encoding="utf-8") as handle: handle.write(text) print("wrote %s: %d emoji from Unicode %s" % (out, len(rows), version)) for introduced, count in sorted(skipped.items()): print(" held back %d from %s (newer than %s)" % (count, introduced, args.max_version)) return 0 if __name__ == "__main__": sys.exit(main())