package geo import ( _ "embed" "regexp" "strconv" "strings" "sync" ) //go:embed data/zone.tab var zoneTab string //go:embed data/links.txt var linksTxt string //go:embed data/VERSION var tzVersion string // TZDataVersion is the IANA release the embedded tables came from. func TZDataVersion() string { return strings.TrimSpace(tzVersion) } var ( tzOnce sync.Once zonePoints map[string]Point // lowercased zone name -> point zoneLinks map[string]string countryOne map[string]Point // countries with exactly one row rowsPerCC map[string]int ) // zoneShape is spec §6.2 step 1: at most 64 bytes, one to three // "/"-separated segments of [A-Za-z0-9_+-]. var zoneShape = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`) // notAPlace names an offset or a machine, not a place (R3). var notAPlace = map[string]bool{ "": true, "local": true, "localtime": true, "posixrules": true, "utc": true, "uct": true, "gmt": true, "universal": true, "zulu": true, "factory": true, } func loadTZ() { zonePoints = map[string]Point{} countryOne = map[string]Point{} rowsPerCC = map[string]int{} for _, line := range strings.Split(zoneTab, "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } f := strings.Split(line, "\t") if len(f) < 3 { continue } p, ok := parseISO6709(f[1]) if !ok { continue } zonePoints[strings.ToLower(f[2])] = p cc := strings.ToUpper(f[0]) rowsPerCC[cc]++ countryOne[cc] = p } zoneLinks = map[string]string{} for _, line := range strings.Split(linksTxt, "\n") { f := strings.Fields(line) if len(f) == 2 { zoneLinks[strings.ToLower(f[1])] = strings.ToLower(f[0]) } } } // ZonePoint maps an IANA zone name to its principal location, following // tzdata links (at most 4 hops). It refuses anything that is not a place. func ZonePoint(name string) (Point, bool) { tzOnce.Do(loadTZ) name = strings.TrimSpace(name) if len(name) > 64 || !zoneShape.MatchString(name) { return Point{}, false } low := strings.ToLower(name) if notAPlace[low] || strings.HasPrefix(low, "etc/") { return Point{}, false } return resolveLinks(low, zoneLinks, zonePoints) } func resolveLinks(name string, links map[string]string, points map[string]Point) (Point, bool) { for hop := 0; hop <= 4; hop++ { if p, ok := points[name]; ok { return p, true } next, ok := links[name] if !ok { return Point{}, false } name = next } return Point{}, false } // countryOverride names the principal zone of every country with more // than one zone.tab row (tzdata 2026b). TestCountryOverridesCoverEvery // MultiZoneCountry fails when a refresh adds a multi-row country. var countryOverride = map[string]string{ "AQ": "Antarctica/McMurdo", "AR": "America/Argentina/Buenos_Aires", "AU": "Australia/Sydney", "BR": "America/Sao_Paulo", "CA": "America/Toronto", "CD": "Africa/Kinshasa", "CL": "America/Santiago", "CN": "Asia/Shanghai", "CY": "Asia/Nicosia", "DE": "Europe/Berlin", "EC": "America/Guayaquil", "ES": "Europe/Madrid", "FM": "Pacific/Pohnpei", "GL": "America/Nuuk", "ID": "Asia/Jakarta", "KI": "Pacific/Tarawa", "KZ": "Asia/Almaty", "MH": "Pacific/Majuro", "MN": "Asia/Ulaanbaatar", "MX": "America/Mexico_City", "MY": "Asia/Kuala_Lumpur", "NZ": "Pacific/Auckland", "PF": "Pacific/Tahiti", "PG": "Pacific/Port_Moresby", "PS": "Asia/Gaza", "PT": "Europe/Lisbon", "RU": "Europe/Moscow", "UA": "Europe/Kyiv", "UM": "Pacific/Wake", "US": "America/New_York", "UZ": "Asia/Tashkent", } // CountryPoint maps an ISO 3166-1 alpha-2 code to a point: its only // zone.tab row, or its override's zone. func CountryPoint(iso2 string) (Point, bool) { tzOnce.Do(loadTZ) cc := strings.ToUpper(strings.TrimSpace(iso2)) if zone, ok := countryOverride[cc]; ok { return ZonePoint(zone) } if rowsPerCC[cc] == 1 { return countryOne[cc], true } return Point{}, false } func zoneRowsPerCountry() map[string]int { tzOnce.Do(loadTZ); return rowsPerCC } // parseISO6709 reads zone.tab's ±DDMM±DDDMM or ±DDMMSS±DDDMMSS. func parseISO6709(s string) (Point, bool) { i := strings.IndexAny(s[1:], "+-") + 1 if i <= 0 { return Point{}, false } lat, ok1 := dms(s[:i], 2) lon, ok2 := dms(s[i:], 3) return Point{lat, lon}, ok1 && ok2 } func dms(s string, degDigits int) (float64, bool) { sign := 1.0 if s[0] == '-' { sign = -1 } d := s[1:] if len(d) != degDigits+2 && len(d) != degDigits+4 { return 0, false } deg, err1 := strconv.Atoi(d[:degDigits]) min, err2 := strconv.Atoi(d[degDigits : degDigits+2]) sec := 0 var err3 error if len(d) == degDigits+4 { sec, err3 = strconv.Atoi(d[degDigits+2:]) } if err1 != nil || err2 != nil || err3 != nil { return 0, false } return sign * (float64(deg) + float64(min)/60 + float64(sec)/3600), true }