package main
import (
"archive/zip"
"bufio"
"encoding/xml"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"maps"
"net/http"
"os"
"path"
"slices"
"strconv"
"strings"
"unicode/utf8"
)
var (
emojiData = flag.String("emoji-data", "https://www.unicode.org/Public/16.0.0/ucd/emoji/emoji-data.txt", "URL for emoji data file")
emojiSequences = flag.String("emoji-sequences", "https://www.unicode.org/Public/emoji/16.0/emoji-sequences.txt", "URL for emoji sequences file")
emojiZWJSequences = flag.String("emoji-zwj-sequences", "https://www.unicode.org/Public/emoji/16.0/emoji-zwj-sequences.txt", "URL for emoji ZWJ sequences file")
cldr = flag.String("cldr", "https://unicode.org/Public/cldr/46/cldr-common-46.0.zip", "URL for CLDR data")
c = flag.String("c", "", "Cache dir")
)
func getCached(url, cacheDir string) (string, error) {
cachePath := cacheDir + "/" + path.Base(url)
if _, err := os.Stat(cachePath); !errors.Is(err, fs.ErrNotExist) {
return cachePath, nil
}
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("get %q: %s", url, resp.Status)
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", err
}
f, err := os.Create(cachePath)
if err != nil {
return "", err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(cachePath)
return "", fmt.Errorf("write %q: %s", cachePath, err)
}
if err := f.Close(); err != nil {
os.Remove(cachePath)
return "", err
}
return cachePath, nil
}
func parseEmojiDataLine(line string) (emojis []string, tag string, err error) {
line, _, _ = strings.Cut(line, "#")
if line == "" {
return nil, "", nil
}
parts := strings.SplitN(line, ";", 3)
if len(parts) < 2 {
return nil, "", fmt.Errorf("parse line %q: %s", line, err)
}
codepointsStr := strings.TrimSpace(parts[0])
tag = strings.TrimSpace(parts[1])
if startStr, endStr, ok := strings.Cut(codepointsStr, ".."); ok {
start, err := strconv.ParseInt(startStr, 16, 32)
if err != nil {
return nil, "", fmt.Errorf("failed to parse line %q: %s", line, err)
}
end, err := strconv.ParseInt(endStr, 16, 32)
if err != nil {
return nil, "", fmt.Errorf("failed to parse line %q: %s", line, err)
}
emojis := make([]string, 0, end-start+1)
for i := start; i <= end; i++ {
emojis = append(emojis, string([]rune{rune(i)}))
}
return emojis, tag, nil
}
codepoints := strings.Split(codepointsStr, " ")
emoji := make([]rune, len(codepoints))
for i, codepointStr := range codepoints {
codepoint, err := strconv.ParseInt(codepointStr, 16, 32)
if err != nil {
return nil, "", fmt.Errorf("failed to parse line %q: %s", line, err)
}
emoji[i] = rune(codepoint)
}
return []string{string(emoji)}, tag, nil
}
func emojiModifiers(cacheDir string) ([]string, error) {
var modifiers []string
filePath, err := getCached(*emojiData, cacheDir)
if err != nil {
return nil, err
}
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
emojis, tag, err := parseEmojiDataLine(line)
if err != nil {
return nil, err
}
if tag == "Emoji_Modifier" {
modifiers = append(modifiers, emojis...)
}
}
return modifiers, scanner.Err()
}
func emojisInFile(url string, modifiers map[string]bool, cacheDir string) ([]string, error) {
file, err := getCached(url, cacheDir)
if err != nil {
return nil, err
}
var emojis []string
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
lineEmojis, _, err := parseEmojiDataLine(line)
if err != nil {
return nil, err
}
for _, e := range lineEmojis {
if !modifiers[e] {
emojis = append(emojis, e)
}
}
}
return emojis, scanner.Err()
}
func emojis(cacheDir string) ([]string, error) {
modifiersSlice, err := emojiModifiers(cacheDir)
if err != nil {
return nil, err
}
modifiers := make(map[string]bool, len(modifiersSlice))
for _, m := range modifiersSlice {
modifiers[m] = true
}
sequences, err := emojisInFile(*emojiSequences, modifiers, cacheDir)
if err != nil {
return nil, err
}
zwjSequences, err := emojisInFile(*emojiZWJSequences, modifiers, cacheDir)
if err != nil {
return nil, err
}
return append(sequences, zwjSequences...), nil
}
func annotationsInFile(f io.Reader) (map[string]string, error) {
contents, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read CLDR data: %s", err)
}
var annotations struct {
Annotations []struct {
CP string `xml:"cp,attr"`
Type string `xml:"type,attr"`
Annotation string `xml:",chardata"`
} `xml:"annotations>annotation"`
}
if err := xml.Unmarshal(contents, &annotations); err != nil {
return nil, fmt.Errorf("parse CLDR data: %s", err)
}
type annotationData struct {
name string
annotations []string
}
emojiAnnotations := make(map[string]annotationData)
for _, annotation := range annotations.Annotations {
annotationData := emojiAnnotations[annotation.CP]
if annotation.Type == "tts" {
annotationData.name = annotation.Annotation
} else {
annotations := strings.Split(annotation.Annotation, "|")
for i, a := range annotations {
annotations[i] = strings.TrimSpace(a)
}
annotationData.annotations = annotations
}
emojiAnnotations[annotation.CP] = annotationData
}
annotationsCombined := make(map[string]string, len(emojiAnnotations))
for emoji, annotation := range emojiAnnotations {
annotation.annotations = slices.DeleteFunc(annotation.annotations, func(s string) bool { return strings.Contains(annotation.name, s) })
annotationsStr := strings.ReplaceAll(annotation.name, ": ", " ")
annotationsStr = strings.ReplaceAll(annotationsStr, ", ", " ")
if len(annotation.annotations) > 0 {
annotationsStr += " " + strings.Join(annotation.annotations, " ")
}
annotationsCombined[emoji] = annotationsStr
}
return annotationsCombined, nil
}
func annotations(cldrData *zip.Reader) (map[string]string, error) {
annotationsFile, err := cldrData.Open("common/annotations/en.xml")
if err != nil {
return nil, fmt.Errorf("read CLDR data: %s", err)
}
defer annotationsFile.Close()
annotations, err := annotationsInFile(annotationsFile)
if err != nil {
return nil, err
}
annotationsDerivedFile, err := cldrData.Open("common/annotationsDerived/en.xml")
if err != nil {
return nil, fmt.Errorf("read CLDR data: %s", err)
}
defer annotationsDerivedFile.Close()
annotationsDerived, err := annotationsInFile(annotationsDerivedFile)
if err != nil {
return nil, err
}
maps.Copy(annotations, annotationsDerived)
return annotations, err
}
func removePresentationSelector(emoji string) string {
return strings.ReplaceAll(emoji, "\ufe0f", "")
}
func collationData(cldrData *zip.Reader) (func(string) int, error) {
file, err := cldrData.Open("common/collation/root.xml")
if err != nil {
return nil, fmt.Errorf("read CLDR data: %s", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("read CLDR data: %s", err)
}
var collations struct {
Collation []struct {
Type string `xml:"type,attr"`
CR string `xml:"cr"`
} `xml:"collations>collation"`
}
if err := xml.Unmarshal(content, &collations); err != nil {
return nil, fmt.Errorf("parse CLDR data %q: %s", file, err)
}
var emojiCollation string
for _, collation := range collations.Collation {
if collation.Type == "emoji" {
emojiCollation = collation.CR
break
}
}
if emojiCollation == "" {
return nil, fmt.Errorf("no emoji collation found in %q", file)
}
// 🫤
ignorable := make(map[rune]bool)
collation := make(map[string]int)
count := 1
for _, line := range strings.Split(emojiCollation, "\n") {
if line, ok := strings.CutPrefix(line, "& [last primary ignorable]<<*"); ok {
for _, rune := range line {
ignorable[rune] = true
}
}
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "&") {
continue
}
if strings.HasPrefix(line, "<*") {
for _, emoji := range line[2:] {
collation[string([]rune{emoji})] = count
count++
}
} else if strings.HasPrefix(line, "<") {
for _, emoji := range strings.FieldsFunc(line[1:], func(r rune) bool { return r == ' ' || r == '<' || r == '=' || r == '\'' }) {
collation[emoji] = count
count++
}
} else {
return nil, fmt.Errorf("unexpected line format %q", line)
}
}
return func(emoji string) int {
// First try the minimally qualified version, with one trailing
// modifier removed.
minimallyQualified := removePresentationSelector(emoji)
lastRune, size := utf8.DecodeLastRuneInString(minimallyQualified)
if size < len(minimallyQualified) && ignorable[lastRune] {
minimallyQualified = minimallyQualified[:len(minimallyQualified)-size]
minimallyQualified = strings.TrimSuffix(minimallyQualified, "\u200d") // ZWJ
}
if n, ok := collation[minimallyQualified]; ok {
return n
}
// If that's not found, try the fully unqualified version,
// where we remove all modifiers.
unqualified := minimallyQualified
var unqualifiedRunes []rune
for _, r := range unqualified {
if !ignorable[r] {
unqualifiedRunes = append(unqualifiedRunes, r)
}
}
unqualified = string(unqualifiedRunes)
unqualified = strings.TrimRight(unqualified, "\u200d") // ZWJ
if n, ok := collation[unqualified]; ok {
return n
}
// Finally, fall back to checking the first codepoint.
firstRune, _ := utf8.DecodeRuneInString(emoji)
if n, ok := collation[string([]rune{firstRune})]; ok {
return n
}
fmt.Fprintf(os.Stderr, "Unable to classify emoji %s", emoji)
os.Exit(1)
panic("unreachable")
}, nil
}
func generate() error {
cacheDir := *c
if cacheDir == "" {
var err error
cacheDir, err = os.MkdirTemp("", "")
if err != nil {
return err
}
defer os.RemoveAll(cacheDir)
}
emojis, err := emojis(cacheDir)
if err != nil {
return err
}
cldrFile, err := getCached(*cldr, cacheDir)
if err != nil {
return err
}
cldrData, err := zip.OpenReader(cldrFile)
if err != nil {
return err
}
defer cldrData.Close()
annotations, err := annotations(&cldrData.Reader)
if err != nil {
return err
}
collate, err := collationData(&cldrData.Reader)
if err != nil {
return err
}
slices.SortFunc(emojis, func(e, f string) int {
if n := collate(e) - collate(f); n != 0 {
return n
}
if n := strings.Count(e, "\u200d") - strings.Count(f, "\u200d"); n != 0 {
return n
}
return slices.Compare([]rune(e), []rune(f))
})
for _, emoji := range emojis {
// From CLDR: "Warnings: All cp values have U+FE0F characters removed."
// So we have to remove all fe0f characters for some reason.
annotation, ok := annotations[removePresentationSelector(emoji)]
if !ok {
return fmt.Errorf("emoji %q has no annotation", emoji)
}
fmt.Println(emoji, annotation)
}
return nil
}
func main() {
flag.Parse()
if err := generate(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}