Bug 1386146 - Add support for hourCycle to Intl.DateTimeFormat. r?anba draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Thu, 05 Oct 2017 17:16:03 -0700
changeset 693064 ec6b4123a2ee1383af9feebdf22aeb93ac3a8d02
parent 693006 66f496680fae6e7d8f02bc17ff58b9234ee07c70
child 738939 68de77d4a7cbf40c8f288c60cbff6b196d04cc15
push id87696
push userbmo:gandalf@aviary.pl
push dateSat, 04 Nov 2017 01:27:24 +0000
reviewersanba
bugs1386146
milestone58.0a1
Bug 1386146 - Add support for hourCycle to Intl.DateTimeFormat. r?anba MozReview-Commit-ID: 8nwk3kyE3co
js/src/builtin/Intl.js
js/src/tests/Intl/DateTimeFormat/hourCycle.js
--- a/js/src/builtin/Intl.js
+++ b/js/src/builtin/Intl.js
@@ -2441,16 +2441,18 @@ function resolveDateTimeFormatInternals(
     //   {
     //     requestedLocales: List of locales,
     //
     //     localeOpt: // *first* opt computed in InitializeDateTimeFormat
     //       {
     //         localeMatcher: "lookup" / "best fit",
     //
     //         hour12: true / false,  // optional
+    //
+    //         hourCycle: "h11" / "h12" / "h23" / "h24", // optional
     //       }
     //
     //     timeZone: IANA time zone name,
     //
     //     formatOpt: // *second* opt computed in InitializeDateTimeFormat
     //       {
     //         // all the properties/values listed in Table 3
     //         // (weekday, era, year, month, day, &c.)
@@ -2501,16 +2503,22 @@ function resolveDateTimeFormatInternals(
     var dataLocale = r.dataLocale;
 
     // Steps 15-17.
     internalProps.timeZone = lazyDateTimeFormatData.timeZone;
 
     // Step 18.
     var formatOpt = lazyDateTimeFormatData.formatOpt;
 
+    // Copy the hourCycle setting, if present, to the format options. But
+    // only do this if no hour12 option is present, because the latter takes
+    // precedence over hourCycle.
+    if (r.hc !== null && formatOpt.hour12 === undefined)
+        formatOpt.hourCycle = r.hc;
+
     // Steps 27-28, more or less - see comment after this function.
     var pattern;
     if (lazyDateTimeFormatData.mozExtensions) {
         if (lazyDateTimeFormatData.patternOption !== undefined) {
             pattern = lazyDateTimeFormatData.patternOption;
 
             internalProps.patternOption = lazyDateTimeFormatData.patternOption;
         } else if (lazyDateTimeFormatData.dateStyle || lazyDateTimeFormatData.timeStyle) {
@@ -2523,29 +2531,75 @@ function resolveDateTimeFormatInternals(
         } else {
             pattern = toBestICUPattern(dataLocale, formatOpt);
         }
         internalProps.mozExtensions = true;
     } else {
       pattern = toBestICUPattern(dataLocale, formatOpt);
     }
 
+    // If the hourCycle option was set, adjust the resolved pattern to use the
+    // requested hour cycle representation.
+    if (formatOpt.hourCycle !== undefined)
+        pattern = replaceHourRepresentation(pattern, formatOpt.hourCycle);
+
     // Step 29.
     internalProps.pattern = pattern;
 
     // Step 30.
     internalProps.boundFormat = undefined;
 
     // The caller is responsible for associating |internalProps| with the right
     // object using |setInternalProperties|.
     return internalProps;
 }
 
 
 /**
+ * Replaces all hour pattern characters in |pattern| to use the matching hour
+ * representation for |hourCycle|.
+ */
+function replaceHourRepresentation(pattern, hourCycle) {
+    var hour;
+    switch (hourCycle) {
+      case "h11":
+        hour = "K";
+        break;
+      case "h12":
+        hour = "h";
+        break;
+      case "h23":
+        hour = "H";
+        break;
+      case "h24":
+        hour = "k";
+        break;
+    }
+    assert(hour !== undefined, "Unexpected hourCycle requested: " + hourCycle);
+
+    // Parse the pattern according to the format specified in
+    // https://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns
+    // and replace all hour symbols with |hour|.
+    var resultPattern = "";
+    var inQuote = false;
+    for (var i = 0; i < pattern.length; i++) {
+        var ch = pattern[i];
+        if (ch === "'") {
+            inQuote = !inQuote;
+        } else if (!inQuote && (ch === "h" || ch === "H" || ch === "k" || ch === "K")) {
+            ch = hour;
+        }
+        resultPattern += ch;
+    }
+
+    return resultPattern;
+}
+
+
+/**
  * Returns an object containing the DateTimeFormat internal properties of |obj|.
  */
 function getDateTimeFormatInternals(obj) {
     assert(IsObject(obj), "getDateTimeFormatInternals called with non-object");
     assert(IsDateTimeFormat(obj), "getDateTimeFormatInternals called with non-DateTimeFormat");
 
     var internals = getIntlObjectInternals(obj);
     assert(internals.type === "DateTimeFormat", "bad type escaped getIntlObjectInternals");
@@ -2612,46 +2666,51 @@ function InitializeDateTimeFormat(dateTi
     //
     //     timeZone: IANA time zone name,
     //
     //     formatOpt: // *second* opt computed in InitializeDateTimeFormat
     //       {
     //         // all the properties/values listed in Table 3
     //         // (weekday, era, year, month, day, &c.)
     //
-    //         hour12: true / false  // optional
+    //         hour12: true / false,  // optional
+    //         hourCycle: "h11" / "h12" / "h23" / "h24", // optional
     //       }
     //
     //     formatMatcher: "basic" / "best fit",
     //   }
     //
     // Note that lazy data is only installed as a final step of initialization,
     // so every DateTimeFormat lazy data object has *all* these properties,
     // never a subset of them.
     var lazyDateTimeFormatData = std_Object_create(null);
 
-    // Step 3.
+    // Step 1.
     var requestedLocales = CanonicalizeLocaleList(locales);
     lazyDateTimeFormatData.requestedLocales = requestedLocales;
 
-    // Step 4.
+    // Step 2.
     options = ToDateTimeOptions(options, "any", "date");
 
     // Compute options that impact interpretation of locale.
-    // Step 5.
+    // Step 3.
     var localeOpt = new Record();
     lazyDateTimeFormatData.localeOpt = localeOpt;
 
-    // Steps 6-7.
+    // Steps 4-5.
     var localeMatcher =
         GetOption(options, "localeMatcher", "string", ["lookup", "best fit"],
                   "best fit");
     localeOpt.localeMatcher = localeMatcher;
 
-    // Steps 15-17.
+    // Step 6.
+    var hc = GetOption(options, "hourCycle", "string", ["h11", "h12", "h23", "h24"], undefined);
+    localeOpt.hc = hc;
+
+    // Steps 15-18.
     var tz = options.timeZone;
     if (tz !== undefined) {
         // Step 15.a.
         tz = ToString(tz);
 
         // Step 15.b.
         var timeZone = intl_IsValidTimeZoneName(tz);
         if (timeZone === null)
@@ -2660,63 +2719,63 @@ function InitializeDateTimeFormat(dateTi
         // Step 15.c.
         tz = CanonicalizeTimeZoneName(timeZone);
     } else {
         // Step 16.
         tz = DefaultTimeZone();
     }
     lazyDateTimeFormatData.timeZone = tz;
 
-    // Step 18.
+    // Step 19.
     var formatOpt = new Record();
     lazyDateTimeFormatData.formatOpt = formatOpt;
 
     lazyDateTimeFormatData.mozExtensions = mozExtensions;
 
     if (mozExtensions) {
         let pattern = GetOption(options, "pattern", "string", undefined, undefined);
         lazyDateTimeFormatData.patternOption = pattern;
 
         let dateStyle = GetOption(options, "dateStyle", "string", ["full", "long", "medium", "short"], undefined);
         lazyDateTimeFormatData.dateStyle = dateStyle;
         let timeStyle = GetOption(options, "timeStyle", "string", ["full", "long", "medium", "short"], undefined);
         lazyDateTimeFormatData.timeStyle = timeStyle;
     }
 
-    // Step 19.
+    // Step 20.
     // 12.1, Table 4: Components of date and time formats.
     formatOpt.weekday = GetOption(options, "weekday", "string", ["narrow", "short", "long"],
                                   undefined);
     formatOpt.era = GetOption(options, "era", "string", ["narrow", "short", "long"], undefined);
     formatOpt.year = GetOption(options, "year", "string", ["2-digit", "numeric"], undefined);
     formatOpt.month = GetOption(options, "month", "string",
                                 ["2-digit", "numeric", "narrow", "short", "long"], undefined);
     formatOpt.day = GetOption(options, "day", "string", ["2-digit", "numeric"], undefined);
     formatOpt.hour = GetOption(options, "hour", "string", ["2-digit", "numeric"], undefined);
     formatOpt.minute = GetOption(options, "minute", "string", ["2-digit", "numeric"], undefined);
     formatOpt.second = GetOption(options, "second", "string", ["2-digit", "numeric"], undefined);
     formatOpt.timeZoneName = GetOption(options, "timeZoneName", "string", ["short", "long"],
                                        undefined);
 
-    // Steps 20-21 provided by ICU - see comment after this function.
-
-    // Step 22.
+    // Steps 21-22 provided by ICU - see comment after this function.
+
+    // Step 23.
     //
     // For some reason (ICU not exposing enough interface?) we drop the
     // requested format matcher on the floor after this.  In any case, even if
     // doing so is justified, we have to do this work here in case it triggers
     // getters or similar. (bug 852837)
     var formatMatcher =
         GetOption(options, "formatMatcher", "string", ["basic", "best fit"],
                   "best fit");
     void formatMatcher;
 
-    // Steps 23-25 provided by ICU, more or less - see comment after this function.
-
-    // Step 26.
+    // Steps 24-26 provided by ICU, more or less - see comment after this function.
+
+    // Step 27.
     var hr12  = GetOption(options, "hour12", "boolean", undefined, undefined);
 
     // Pass hr12 on to ICU.
     if (hr12 !== undefined)
         formatOpt.hour12 = hr12;
 
     // Step 31.
     //
@@ -2790,16 +2849,17 @@ function InitializeDateTimeFormat(dateTi
 // in the format method.
 //
 // An ICU pattern represents the information of the following DateTimeFormat
 // internal properties described in the specification, which therefore don't
 // exist separately in the implementation:
 // - [[weekday]], [[era]], [[year]], [[month]], [[day]], [[hour]], [[minute]],
 //   [[second]], [[timeZoneName]]
 // - [[hour12]]
+// - [[hourCycle]]
 // - [[hourNo0]]
 // When needed for the resolvedOptions method, the resolveICUPattern function
 // maps the instance's ICU pattern back to the specified properties of the
 // object returned by resolvedOptions.
 //
 // ICU date-time skeletons and patterns aren't fully documented in the ICU
 // documentation (see http://bugs.icu-project.org/trac/ticket/9627). The best
 // documentation at this point is in UTR 35:
@@ -2863,22 +2923,34 @@ function toBestICUPattern(locale, option
     switch (options.day) {
     case "2-digit":
         skeleton += "dd";
         break;
     case "numeric":
         skeleton += "d";
         break;
     }
+    // If hour12 and hourCycle are both present, hour12 takes precedence.
     var hourSkeletonChar = "j";
     if (options.hour12 !== undefined) {
         if (options.hour12)
             hourSkeletonChar = "h";
         else
             hourSkeletonChar = "H";
+    } else {
+        switch (options.hourCycle) {
+        case "h11":
+        case "h12":
+            hourSkeletonChar = "h";
+            break;
+        case "h23":
+        case "h24":
+            hourSkeletonChar = "H";
+            break;
+        }
     }
     switch (options.hour) {
     case "2-digit":
         skeleton += hourSkeletonChar + hourSkeletonChar;
         break;
     case "numeric":
         skeleton += hourSkeletonChar;
         break;
@@ -3005,27 +3077,33 @@ var dateTimeFormatInternalProperties = {
         var locales = this._availableLocales;
         if (locales)
             return locales;
 
         locales = intl_DateTimeFormat_availableLocales();
         addSpecialMissingLanguageTags(locales);
         return (this._availableLocales = locales);
     },
-    relevantExtensionKeys: ["ca", "nu"]
+    relevantExtensionKeys: ["ca", "nu", "hc"]
 };
 
 
 function dateTimeFormatLocaleData() {
     return {
         ca: intl_availableCalendars,
         nu: getNumberingSystems,
+        hc: () => {
+            return [null, "h11", "h12", "h23", "h24"];
+        },
         default: {
             ca: intl_defaultCalendar,
             nu: intl_numberingSystem,
+            hc: () => {
+                return null;
+            }
         }
     };
 }
 
 
 /**
  * Function to be bound and returned by Intl.DateTimeFormat.prototype.format.
  *
@@ -3218,20 +3296,34 @@ function resolveICUPattern(pattern, resu
                 else
                     value = "narrow";
                 break;
             default:
                 // skip other pattern characters and literal text
             }
             if (hasOwn(c, icuPatternCharToComponent))
                 _DefineDataProperty(result, icuPatternCharToComponent[c], value);
-            if (c === "h" || c === "K")
+            switch (c) {
+            case "h":
+                _DefineDataProperty(result, "hourCycle", "h12");
+                _DefineDataProperty(result, "hour12", true);
+                break;
+            case "K":
+                _DefineDataProperty(result, "hourCycle", "h11");
                 _DefineDataProperty(result, "hour12", true);
-            else if (c === "H" || c === "k")
+                break;
+            case "H":
+                _DefineDataProperty(result, "hourCycle", "h23");
                 _DefineDataProperty(result, "hour12", false);
+                break;
+            case "k":
+                _DefineDataProperty(result, "hourCycle", "h24");
+                _DefineDataProperty(result, "hour12", false);
+                break;
+            }
         }
     }
 }
 
 
 /********** Intl.PluralRules **********/
 
 
new file mode 100644
--- /dev/null
+++ b/js/src/tests/Intl/DateTimeFormat/hourCycle.js
@@ -0,0 +1,145 @@
+// |reftest| skip-if(!this.hasOwnProperty("Intl"))
+
+const hourCycleToH12Map = {
+  "h11": true,
+  "h12": true,
+  "h23": false,
+  "h24": false,
+};
+
+for (const key of Object.keys(hourCycleToH12Map)) {
+  const langTag = "en-US";
+  const loc = `${langTag}-u-hc-${key}`;
+
+  const dtf = new Intl.DateTimeFormat(loc, {hour: "numeric"});
+  const dtf2 = new Intl.DateTimeFormat(langTag, {hour: "numeric", hourCycle: key});
+  assertEq(dtf.resolvedOptions().hourCycle, dtf2.resolvedOptions().hourCycle);
+}
+
+
+/* Legacy hour12 compatibility */
+
+// When constructed with hourCycle option, resolvedOptions' hour12 is correct.
+for (const key of Object.keys(hourCycleToH12Map)) {
+  const dtf = new Intl.DateTimeFormat("en-US", {hour: "numeric", hourCycle: key});
+  assertEq(dtf.resolvedOptions().hour12, hourCycleToH12Map[key]);
+}
+
+// When constructed with hour12 option, resolvedOptions' hourCycle is correct
+for (const [key, value] of Object.entries(hourCycleToH12Map)) {
+  const dtf = new Intl.DateTimeFormat("en-US", {hour: "numeric", hour12: value});
+  assertEq(hourCycleToH12Map[dtf.resolvedOptions().hourCycle], value);
+}
+
+// When constructed with both hour12 and hourCycle options that don't match
+// hour12 takes a precedence.
+for (const [key, value] of Object.entries(hourCycleToH12Map)) {
+  const dtf = new Intl.DateTimeFormat("en-US", {
+    hour: "numeric",
+    hourCycle: key,
+    hour12: !value
+  });
+  assertEq(hourCycleToH12Map[dtf.resolvedOptions().hourCycle], !value);
+  assertEq(dtf.resolvedOptions().hour12, !value);
+}
+
+// When constructed with hourCycle as extkey, resolvedOptions' hour12 is correct.
+for (const [key, value] of Object.entries(hourCycleToH12Map)) {
+  const langTag = "en-US";
+  const loc = `${langTag}-u-hc-${key}`;
+
+  const dtf = new Intl.DateTimeFormat(loc, {hour: "numeric"});
+  assertEq(dtf.resolvedOptions().hour12, value);
+}
+
+const expectedValuesENUS = {
+  h11: "0 AM",
+  h12: "12 AM",
+  h23: "00",
+  h24: "24"
+};
+
+const exampleDate = new Date(2017, 10-1, 10, 0);
+for (const [key, val] of Object.entries(expectedValuesENUS)) {
+  assertEq(
+    Intl.DateTimeFormat("en-US", {hour: "numeric", hourCycle: key}).format(exampleDate),
+    val
+  );
+}
+
+const invalidHourCycleValues = [
+  "h5",
+  "h0",
+  "h28",
+  "f28",
+  "23",
+];
+
+for (const key of invalidHourCycleValues) {
+  const langTag = "en-US";
+  const loc = `${langTag}-u-hc-${key}`;
+
+  const dtf = new Intl.DateTimeFormat(loc, {hour: "numeric"});
+  assertEq(dtf.resolvedOptions().hour12, true); // default value for en-US
+  assertEq(dtf.resolvedOptions().hourCycle, "h12"); //default value for en-US
+}
+
+{
+  // hourCycle is not present in resolvedOptions when the formatter has no hour field
+  const options = Intl.DateTimeFormat("en-US", {hourCycle:"h11"}).resolvedOptions();
+  assertEq("hourCycle" in options, false);
+  assertEq("hour12" in options, false);
+}
+
+{
+  // Make sure that hourCycle option overrides the unicode extension
+  let dtf = Intl.DateTimeFormat("en-US-u-hc-h23", {hourCycle: "h24", hour: "numeric"});
+  assertEq(
+    dtf.resolvedOptions().hourCycle,
+    "h24"
+  );
+}
+
+{
+  // Make sure that hour12 option overrides the unicode extension
+  let dtf = Intl.DateTimeFormat("en-US-u-hc-h23", {hour12: true, hour: "numeric"});
+  assertEq(
+    dtf.resolvedOptions().hourCycle,
+    "h12"
+  );
+}
+
+{
+  // Make sure that hour12 option overrides hourCycle options
+  let dtf = Intl.DateTimeFormat("en-US",
+    {hourCycle: "h12", hour12: false, hour: "numeric"});
+  assertEq(
+    dtf.resolvedOptions().hourCycle,
+    "h23"
+  );
+}
+
+{
+  // Make sure that hour12 option overrides hourCycle options
+  let dtf = Intl.DateTimeFormat("en-u-hc-h11", {hour: "numeric"});
+  assertEq(
+    dtf.resolvedOptions().locale,
+    "en-u-hc-h11"
+  );
+}
+
+{
+  // Make sure that hour12 option overrides unicode extension
+  let dtf = Intl.DateTimeFormat("en-u-hc-h11", {hour: "numeric", hourCycle: "h24"});
+  assertEq(
+    dtf.resolvedOptions().locale,
+    "en"
+  );
+  assertEq(
+    dtf.resolvedOptions().hourCycle,
+    "h24"
+  );
+}
+
+if (typeof reportCompare === "function")
+    reportCompare(0, 0, "ok");