Repository: moment/luxon
Pull request #544 (2019-07-17 19:59:02)

#125 Literal parsing/formatting using brackets
This PR replaces simple quotes with brackets to create string literals #125 

I find brackets easier to read for a human, and they are already used in moment for this purpose.

Nested brackets are possible, if they are balanced (`'HH[[:]]mm' -> 23[:]45`).

However, escaping brackets, as in `\[` or `\]` was not implemented.
1. The `\` in `'\['` is already caught by JS, so this is equivalent to a single `'['`. One would need to use `'\\['`which is confusing.
2. The `parseFormat` automata would become much more complex.


icambron: This looks great!

Patch:
diff --git a/docs/formatting.md b/docs/formatting.md
index 2f5287f8..09675061 100644
--- a/docs/formatting.md
+++ b/docs/formatting.md
@@ -49,7 +49,7 @@ Modern browsers (and other JS environments) provide support for human-readable,
 ```js
 dt.toLocaleString(); //=> '4/20/2017'
 dt.toLocaleString(DateTime.DATETIME_FULL); //=> 'April 20, 2017, 11:32 AM EDT'
-dt.setLocale('fr').toLocaleString(DateTime.DATETIME_FULL); //=> '20 avril 2017 à 11:32 UTC−4'
+dt.setLocale("fr").toLocaleString(DateTime.DATETIME_FULL); //=> '20 avril 2017 à 11:32 UTC−4'
 ```
 
 ### Intl.DateTimeFormat
@@ -57,7 +57,7 @@ dt.setLocale('fr').toLocaleString(DateTime.DATETIME_FULL); //=> '20 avril 2017 
 In the example above, `DateTime.DATETIME_FULL` is one of several convenience formats provided by Luxon. But the arguments are really any object of options that can be provided to [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat). For example:
 
 ```js
-dt.toLocaleString({ month: 'long', day: 'numeric' }); //=> 'April 20'
+dt.toLocaleString({ month: "long", day: "numeric" }); //=> 'April 20'
 ```
 
 And that's all the preset is:
@@ -77,7 +77,7 @@ This also means you can modify the presets as you choose:
 
 ```js
 dt.toLocaleString(DateTime.DATE_SHORT); //=>  '4/20/2017'
-var newFormat = Object.assign({ weekday: 'long' }, DateTime.DATE_SHORT);
+var newFormat = Object.assign({ weekday: "long" }, DateTime.DATE_SHORT);
 dt.toLocaleString(newFormat); //=>  'Thursday, 4/20/2017'
 ```
 
@@ -125,7 +125,7 @@ You shouldn't create ad-hoc string formats if you can avoid it. If you intend fo
 See [DateTime#toFormat](../class/src/datetime.js~DateTime.html#instance-method-toFormat) for the API signature. As a brief motivating example:
 
 ```js
-DateTime.fromISO('2014-08-06T13:07:04.054').toFormat('yyyy LLL dd'); //=> '2014 Aug 06'
+DateTime.fromISO("2014-08-06T13:07:04.054").toFormat("yyyy LLL dd"); //=> '2014 Aug 06'
 ```
 
 The supported tokens are described in the table below.
@@ -135,17 +135,17 @@ The supported tokens are described in the table below.
 All of the strings (e.g. month names and weekday names) are internationalized by introspecting strings generated by the Intl API. Thus they exact strings you get are implementation-specific.
 
 ```js
-DateTime.fromISO('2014-08-06T13:07:04.054')
-  .setLocale('fr')
-  .toFormat('yyyy LLL dd'); //=> '2014 août 06'
+DateTime.fromISO("2014-08-06T13:07:04.054")
+  .setLocale("fr")
+  .toFormat("yyyy LLL dd"); //=> '2014 août 06'
 ```
 
 ### Escaping
 
-You may escape strings using single quotes:
+You may escape strings using brackets:
 
 ```js
-DateTime.local().toFormat("HH 'hours and' mm 'minutes'"); //=> '20 hours and 55 minutes'
+DateTime.local().toFormat("HH [hours and] mm [minutes]"); //=> '20 hours and 55 minutes'
 ```
 
 ### Standalone vs format tokens
@@ -153,9 +153,9 @@ DateTime.local().toFormat("HH 'hours and' mm 'minutes'"); //=> '20 hours and 55
 Some tokens have a "standalone" and "format" version. Some languages require different forms of a word based on whether it is part of a longer phrase or just by itself (e.g. "Monday the 22nd" vs "Monday"). Use them accordingly.
 
 ```js
-var d = DateTime.fromISO('2014-08-06T13:07:04.054').setLocale('ru');
-d.toFormat('LLLL'); //=> 'август' (standalone)
-d.toFormat('MMMM'); //=> 'августа' (format)
+var d = DateTime.fromISO("2014-08-06T13:07:04.054").setLocale("ru");
+d.toFormat("LLLL"); //=> 'август' (standalone)
+d.toFormat("MMMM"); //=> 'августа' (format)
 ```
 
 ### Macro tokens
@@ -163,7 +163,7 @@ d.toFormat('MMMM'); //=> 'августа' (format)
 Some of the formats are "macros", meaning they correspond to multiple components. These use the native Intl API and will order their constituent parts in a locale-friendly way.
 
 ```js
-DateTime.fromISO('2014-08-06T13:07:04.054').toFormat('ff'); //=> 'Aug 6, 2014, 1:07 PM'
+DateTime.fromISO("2014-08-06T13:07:04.054").toFormat("ff"); //=> 'Aug 6, 2014, 1:07 PM'
 ```
 
 The macro options available correspond one-to-one with the preset formats defined for `toLocaleString`.
@@ -238,4 +238,4 @@ The macro options available correspond one-to-one with the preset formats define
 | FFF              |              | verbose localized date and time with seconds                   | August 6, 2014, 1:07:04 PM EDT                              |
 | FFFF             |              | extra verbose localized date and time with seconds             | Wednesday, August 6, 2014, 1:07:04 PM Eastern Daylight Time |
 | X                |              | unix timestamp in seconds                                      | 1407287224                                                  |
-| x                |              | unix timestamp in milliseconds                                 | 1407287224054                                              |
+| x                |              | unix timestamp in milliseconds                                 | 1407287224054                                               |
diff --git a/src/datetime.js b/src/datetime.js
index 6a609a0a..6798f09c 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -1081,7 +1081,7 @@ export default class DateTime {
    */
   get isInDST() {
     return this.c
-      ? this.offset > this.set({ month: 1 }).offset || this.offset > this.set({ month: 5 }).offset
+      ? this.offset > this.set({ month: 12 }).offset || this.offset > this.set({ month: 6 }).offset
       : null;
   }
 
@@ -1264,7 +1264,7 @@ export default class DateTime {
    * Add a period of time to this DateTime and return the resulting DateTime
    *
    * Adding hours, minutes, seconds, or milliseconds increases the timestamp by the right number of milliseconds. Adding days, months, or years shifts the calendar, accounting for DSTs and leap years along the way. Thus, `dt.plus({ hours: 24 })` may result in a different time than `dt.plus({ days: 1 })` if there's a DST shift in between.
-   * @param {Duration|Object|number} duration - The amount to add. Either a Luxon Duration, a number of milliseconds, the object argument to Duration.tructoromObject()
+   * @param {Duration|Object|number} duration - The amount to add. Either a Luxon Duration, a number of milliseconds, the object argument to Duration.fromObject()
    * @example DateTime.local().plus(123) //~> in 123 milliseconds
    * @example DateTime.local().plus({ minutes: 15 }) //~> in 15 minutes
    * @example DateTime.local().plus({ days: 1 }) //~> this time tomorrow
@@ -1447,7 +1447,7 @@ export default class DateTime {
    * @return {string}
    */
   toISOWeekDate() {
-    return toTechFormat(this, "kkkk-'W'WW-c");
+    return toTechFormat(this, "kkkk-[W]WW-c");
   }
 
   /**
@@ -1487,7 +1487,7 @@ export default class DateTime {
    * @return {string}
    */
   toHTTP() {
-    return toTechFormat(this.toUTC(), "EEE, dd LLL yyyy HH:mm:ss 'GMT'");
+    return toTechFormat(this.toUTC(), "EEE, dd LLL yyyy HH:mm:ss [GMT]");
   }
 
   /**
diff --git a/src/impl/formatter.js b/src/impl/formatter.js
index 0944a919..d23fa200 100644
--- a/src/impl/formatter.js
+++ b/src/impl/formatter.js
@@ -49,18 +49,30 @@ export default class Formatter {
   static parseFormat(fmt) {
     let current = null,
       currentFull = "",
-      bracketed = false;
+      bracketedLevel = 0;
+
     const splits = [];
     for (let i = 0; i < fmt.length; i++) {
       const c = fmt.charAt(i);
-      if (c === "'") {
-        if (currentFull.length > 0) {
-          splits.push({ literal: bracketed, val: currentFull });
-        }
-        current = null;
-        currentFull = "";
-        bracketed = !bracketed;
-      } else if (bracketed) {
+      if (c === "[") {
+        if (bracketedLevel === 0) {
+          if (currentFull.length > 0) {
+            splits.push({ literal: false, val: currentFull });
+          }
+          current = null;
+          currentFull = "";
+        } else currentFull += c;
+        bracketedLevel = bracketedLevel + 1;
+      } else if (c === "]") {
+        bracketedLevel = bracketedLevel - 1;
+        if (bracketedLevel === 0) {
+          if (currentFull.length > 0) {
+            splits.push({ literal: true, val: currentFull });
+          }
+          current = null;
+          currentFull = "";
+        } else currentFull += c;
+      } else if (bracketedLevel > 0) {
         currentFull += c;
       } else if (c === current) {
         currentFull += c;
@@ -74,7 +86,7 @@ export default class Formatter {
     }
 
     if (currentFull.length > 0) {
-      splits.push({ literal: bracketed, val: currentFull });
+      splits.push({ literal: bracketedLevel > 0, val: currentFull });
     }
 
     return splits;


Test patch:
diff --git a/test/datetime/create.test.js b/test/datetime/create.test.js
index ea566706..374fc358 100644
--- a/test/datetime/create.test.js
+++ b/test/datetime/create.test.js
@@ -7,6 +7,7 @@ import {
   MismatchedWeekdayError,
   InvalidZoneError
 } from "../../src/errors";
+import Settings from "../../src/settings";
 
 import Helpers from "../helpers";
 
@@ -136,6 +137,10 @@ const badInputs = [
 ];
 
 test.each(badInputs)("Interval.fromISO will reject [%s]", s => {
+  expect(() => DateTime.fromISO(s)).toThrow();
+});
+
+test("local with no options", () => {
   const dt = DateTime.local(2017, 6, 12, 5, 25, 16, 255);
   expect(dt.year).toBe(2017);
   expect(dt.month).toBe(6);
@@ -144,6 +149,7 @@ test.each(badInputs)("Interval.fromISO will reject [%s]", s => {
   expect(dt.minute).toBe(25);
   expect(dt.second).toBe(16);
   expect(dt.millisecond).toBe(255);
+  expect(dt.zoneName).toBe(Settings.defaultZone.name);
 });
 
 test("DateTime.local accepts the default locale", () => {
diff --git a/test/datetime/toFormat.test.js b/test/datetime/toFormat.test.js
index 0f63b074..fa0b9947 100644
--- a/test/datetime/toFormat.test.js
+++ b/test/datetime/toFormat.test.js
@@ -606,9 +606,26 @@ test("DateTime#toFormat returns a full formatted string", () => {
   expect(dt.toFormat("MM/yyyy GG")).toBe("05/1982 Anno Domini");
 });
 
-test("DateTime#toFormat() accepts literals in single quotes", () => {
-  expect(dt.toFormat("dd/MM/yyyy 'at' hh:mm")).toBe("25/05/1982 at 09:23");
-  expect(dt.toFormat("MMdd'T'hh")).toBe("0525T09");
+test("DateTime#toFormat() accepts literals in brackets", () => {
+  expect(dt.toFormat("dd/MM/yyyy [at] hh:mm")).toBe("25/05/1982 at 09:23");
+  expect(dt.toFormat("MMdd[T]hh")).toBe("0525T09");
+  expect(dt.toFormat("[start]MMdd[middle]hh[end]")).toBe("start0525middle09end");
+});
+
+test("DateTime#toFormat() fails on invalid literals", () => {
+  expect(dt.toFormat("dd/MM/yyyy [no end")).toBe("25/05/1982 no end");
+  expect(dt.toFormat("MMdd]")).toBe("05dd]");
+  expect(dt.toFormat("[two ends]]")).toBe("two ends]");
+});
+
+test("DateTime#toFormat() accepts nested literals in brackets", () => {
+  expect(dt.toFormat("dd/MM/yyyy [at [almost]] hh:mm")).toBe("25/05/1982 at [almost] 09:23");
+  expect(dt.toFormat("MMdd[[[T]]]hh")).toBe("0525[[T]]09");
+});
+
+test("DateTime#toFormat() simply outputs non token characters", () => {
+  expect(dt.toFormat("begijklnprvw")).toBe("begijklnprvw");
+  expect(dt.toFormat("ABCIJKNOPQRUVY")).toBe("ABCIJKNOPQRUVY");
 });
 
 test("DateTime#toFormat() uses the numbering system", () => {
diff --git a/test/datetime/tokenParse.test.js b/test/datetime/tokenParse.test.js
index 89b4ca43..9e0138ce 100644
--- a/test/datetime/tokenParse.test.js
+++ b/test/datetime/tokenParse.test.js
@@ -366,7 +366,7 @@ test("DateTime.fromFormat() allows regex content", () => {
 });
 
 test("DateTime.fromFormat() allows literals", () => {
-  const i = DateTime.fromFormat("1982/05/25 hello 09:10:11.445", "yyyy/MM/dd 'hello' HH:mm:ss.SSS");
+  const i = DateTime.fromFormat("1982/05/25 hello 09:10:11.445", "yyyy/MM/dd [hello] HH:mm:ss.SSS");
 
   expect(i.year).toBe(1982);
   expect(i.month).toBe(5);
@@ -493,22 +493,22 @@ test("DateTime.fromFormat validates weekdays", () => {
 });
 
 test("DateTime.fromFormat containg special regex token", () => {
-  const ianaFormat = "yyyy-MM-dd'T'HH-mm[z]";
-  const dt = DateTime.fromFormat("2019-01-14T11-30[Indian/Maldives]", ianaFormat, {
+  const ianaFormat = "yyyy-MM-dd[T]HH-mm[']z[']";
+  const dt = DateTime.fromFormat("2019-01-14T11-30'Indian/Maldives'", ianaFormat, {
     setZone: true
   });
   expect(dt.zoneName).toBe("Indian/Maldives");
 
   expect(
-    DateTime.fromFormat("2019-01-14T11-30[[Indian/Maldives]]", "yyyy-MM-dd'T'HH-mm[[z]]")
+    DateTime.fromFormat("2019-01-14T11-30(Indian/Maldives)", "yyyy-MM-dd[T]HH-mm(z)")
   ).toBeTruthy();
 
   expect(
-    DateTime.fromFormat("2019-01-14T11-30tIndian/Maldivest", "yyyy-MM-dd'T'HH-mm't'z't'")
+    DateTime.fromFormat("2019-01-14T11-30tIndian/Maldivest", "yyyy-MM-dd[T]HH-mm[t]z[t]")
   ).toBeTruthy();
 
   expect(() =>
-    DateTime.fromFormat("2019-01-14T11-30\tIndian/Maldives\t", "yyyy-MM-dd'T'HH-mm't'z't'")
+    DateTime.fromFormat("2019-01-14T11-30\tIndian/Maldives\t", "yyyy-MM-dd[T]HH-mm[t]z[t]")
   ).toThrow(UnparsableStringError);
 });