Add emoji prefix.
This commit is contained in:
Executable
+509
@@ -0,0 +1,509 @@
|
||||
#!/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 "<glyph> E<version> <name>": 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'<annotation cp="([^"]*)"(?P<tts> type="tts")?>(.*?)</annotation>',
|
||||
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())
|
||||
Reference in New Issue
Block a user