Repository: lingui/js-lingui (ts)

Issue:
Cannot read property 'locale' of null for `@lingui/loader`
**Describe the bug**
App crashes when using `@lingui/loader`

**To Reproduce**
Install deps and run example of `create-react-app` from `next` branch.

**Expected behavior**
It should work :)

**Additional context**
![image](https://user-images.githubusercontent.com/1096340/79052362-4e2ef580-7c36-11ea-9e14-d9bd15691e4d.png)

It's coming from this line. Apparently the `getCatalogForFile` returns `null` for some reason. I will investigate it further, but I suspect it's a classic issue of wrong path separators (I am on Windows).

https://github.com/lingui/js-lingui/blob/24d521ff385647f69f73188fd35e07079efdc1f3/packages/loader/src/index.js#L41

- jsLingui version ``lingui --version``: 3.0.0-10


Pull request description:
Use micromatch to handle Win path sep
Fixes #642 



Patch:
diff --git a/packages/cli/package.json b/packages/cli/package.json
index ab282ea7..3fc00b91 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -51,14 +51,17 @@
     "inquirer": "^7.1.0",
     "make-plural": "^6.2.1",
     "messageformat-parser": "^4.1.2",
-    "minimatch": "^3.0.4",
+    "micromatch": "4.0.2",
     "mkdirp": "^1.0.4",
+    "normalize-path": "^3.0.0",
     "ora": "^4.0.3",
     "pofile": "^1.0.11",
     "pseudolocale": "^1.1.0",
     "ramda": "^0.27.0"
   },
   "devDependencies": {
+    "@types/micromatch": "^4.0.1",
+    "@types/normalize-path": "^3.0.0",
     "mockdate": "^2.0.2",
     "typescript": "^3.8.3"
   },
diff --git a/packages/cli/src/api/catalog.ts b/packages/cli/src/api/catalog.ts
index 1414f0ed..52409e24 100644
--- a/packages/cli/src/api/catalog.ts
+++ b/packages/cli/src/api/catalog.ts
@@ -4,7 +4,8 @@ import path from "path"
 import * as R from "ramda"
 import chalk from "chalk"
 import glob from "glob"
-import minimatch from "minimatch"
+import micromatch from "micromatch"
+import normalize from "normalize-path"
 
 import { LinguiConfig, OrderBy } from "@lingui/conf"
 import getFormat from "./formats"
@@ -20,6 +21,7 @@ import { CliExtractOptions } from "../lingui-extract"
 
 const NAME = "{name}"
 const LOCALE = "{locale}"
+const PATHSEP = "/" // force posix everywhere
 
 export interface MakeOptions extends CliExtractOptions {
   projectType?: string
@@ -55,9 +57,9 @@ export class Catalog {
     config: LinguiConfig
   ) {
     this.name = name
-    this.path = path
-    this.include = include
-    this.exclude = [this.localeDir, ...exclude]
+    this.path = normalizeRelativePath(path)
+    this.include = include.map(normalizeRelativePath)
+    this.exclude = [this.localeDir, ...exclude.map(normalizeRelativePath)]
     this.config = config
     this.format = getFormat(config.format)
   }
@@ -272,18 +274,18 @@ export class Catalog {
   }
 
   get sourcePaths() {
-    const includeGlob = this.include.map((includePath) =>
-      path.join(includePath, "**", "*.*")
+    const includeGlobs = this.include.map(
+      (includePath) => `${includePath}${PATHSEP}**${PATHSEP}*.*`
     )
     const patterns =
-      includeGlob.length > 1 ? `{${includeGlob.join("|")}` : includeGlob[0]
+      includeGlobs.length > 1 ? `{${includeGlobs.join("|")}` : includeGlobs[0]
     return glob.sync(patterns, { ignore: this.exclude })
   }
 
   get localeDir() {
-    const localePatternIndex = this.path.indexOf("{locale}")
+    const localePatternIndex = this.path.indexOf(LOCALE)
     if (localePatternIndex === -1) {
-      throw Error("Invalid catalog path: {locale} variable is missing")
+      throw Error(`Invalid catalog path: ${LOCALE} variable is missing`)
     }
     return this.path.substr(0, localePatternIndex)
   }
@@ -302,7 +304,7 @@ export function getCatalogs(config: LinguiConfig) {
 
   catalogsConfig.forEach((catalog) => {
     // Validate that `catalogPath` doesn't end with trailing slash
-    if (catalog.path.endsWith(path.sep)) {
+    if (catalog.path.endsWith(PATHSEP)) {
       const extension = getFormat(config.format).catalogExtension
       const correctPath = catalog.path.slice(0, -1)
       const examplePath =
@@ -319,17 +321,13 @@ export function getCatalogs(config: LinguiConfig) {
       )
     }
 
-    const include = ensureArray(catalog.include).map((path) =>
-      normalizeRelativePath(path)
-    )
-    const exclude = ensureArray(catalog.exclude).map((path) =>
-      normalizeRelativePath(path)
-    )
+    const include = ensureArray(catalog.include).map(normalizeRelativePath)
+    const exclude = ensureArray(catalog.exclude).map(normalizeRelativePath)
 
     // catalogPath without {name} pattern -> always refers to a single catalog
     if (!catalog.path.includes(NAME)) {
       // Validate that sourcePaths doesn't use {name} pattern either
-      const invalidSource = include.filter((path) => path.includes(NAME))[0]
+      const invalidSource = include.find((path) => path.includes(NAME))
       if (invalidSource !== undefined) {
         throw new Error(
           `Catalog with path "${catalog.path}" doesn't have a {name} pattern` +
@@ -342,7 +340,7 @@ export function getCatalogs(config: LinguiConfig) {
       // catalog name is the last directory of catalogPath.
       // If the last part is {locale}, then catalog doesn't have an explicit name
       const name = (function () {
-        const _name = catalog.path.split(path.sep).slice(-1)[0]
+        const _name = normalize(catalog.path).split(PATHSEP).slice(-1)[0]
         return _name !== LOCALE ? _name : null
       })()
 
@@ -389,23 +387,17 @@ export function getCatalogs(config: LinguiConfig) {
 
 export function getCatalogForFile(file: string, catalogs: Array<Catalog>) {
   for (const catalog of catalogs) {
-    const regexp = new RegExp(
-      minimatch
-        // convert glob pattern to regexp
-        .makeRe(catalog.path + catalog.format.catalogExtension)
-        // convert it back to string, so we can replace {locale} with regexp pattern
-        .toString()
-        // remove regexp delimiters
-        .slice(1, -1)
-        .replace("\\{locale\\}", "([^/.]+)")
+    const catalogFile = `${catalog.path}${catalog.format.catalogExtension}`
+    const catalogGlob = catalogFile.replace(LOCALE, "*")
+    const match = micromatch.capture(
+      normalizeRelativePath(catalogGlob),
+      normalizeRelativePath(file)
     )
-
-    const match = regexp.exec(file)
-    if (!match) continue
-
-    return {
-      locale: match[1],
-      catalog,
+    if (match) {
+      return {
+        locale: match[0],
+        catalog,
+      }
     }
   }
 
@@ -447,13 +439,16 @@ const ensureArray = <T>(value: Array<T> | T | null | undefined): Array<T> => {
  * Preserve absolute paths:    /absolute/path => /absolute/path
  */
 function normalizeRelativePath(sourcePath: string): string {
+  const sourcePathPosix = normalize(sourcePath)
+
   // absolute path, do nothing
-  if (sourcePath.startsWith("/")) return sourcePath
+  if (sourcePathPosix.startsWith(PATHSEP)) return sourcePathPosix
 
   // preserve trailing slash for directories
-  const isDir = sourcePath.endsWith("/")
+  const isDir = normalize(sourcePath, false).endsWith(PATHSEP)
   return (
-    path.relative(process.cwd(), path.resolve(sourcePath)) + (isDir ? "/" : "")
+    path.relative(process.cwd(), path.resolve(sourcePathPosix)) +
+    (isDir ? PATHSEP : "")
   )
 }
 
diff --git a/yarn.lock b/yarn.lock
index d6b1b4cc..2b913385 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1322,6 +1322,11 @@
   dependencies:
     "@babel/types" "^7.3.0"
 
+"@types/braces@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/braces/-/braces-3.0.0.tgz#7da1c0d44ff1c7eb660a36ec078ea61ba7eb42cb"
+  integrity sha512-TbH79tcyi9FHwbyboOKeRachRq63mSuWYXOflsNO9ZyE5ClQ/JaozNKl+aWUq87qPNsXasXxi2AbgfwIJ+8GQw==
+
 "@types/bunyan@*":
   version "1.8.6"
   resolved "https://registry.yarnpkg.com/@types/bunyan/-/bunyan-1.8.6.tgz#6527641cca30bedec5feb9ab527b7803b8000582"
@@ -1392,6 +1397,13 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
   integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==
 
+"@types/micromatch@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7"
+  integrity sha512-my6fLBvpY70KattTNzYOK6KU1oR1+UCz9ug/JbcF5UrEmeCt9P7DV2t7L8+t18mMPINqGQCE4O8PLOPbI84gxw==
+  dependencies:
+    "@types/braces" "*"
+
 "@types/node@*":
   version "12.12.14"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.14.tgz#1c1d6e3c75dba466e0326948d56e8bd72a1903d2"
@@ -1402,6 +1414,11 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-6.14.9.tgz#733583e21ef0eab85a9737dfafbaa66345a92ef0"
   integrity sha512-leP/gxHunuazPdZaCvsCefPQxinqUDsCxCR5xaDUrY2MkYxQRFZZwU5e7GojyYsGB7QVtCi7iVEl/hoFXQYc+w==
 
+"@types/normalize-path@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/normalize-path/-/normalize-path-3.0.0.tgz#bb5c46cab77b93350b4cf8d7ff1153f47189ae31"
+  integrity sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q==
+
 "@types/parse-json@^4.0.0":
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
@@ -6061,7 +6078,7 @@ messageformat-parser@^4.1.2:
   resolved "https://registry.yarnpkg.com/messageformat-parser/-/messageformat-parser-4.1.2.tgz#fd34ec39912a14868a1595eaeb742485ab8ab372"
   integrity sha512-7dWuifeyldz7vhEuL96Kwq1fhZXBW+TUfbnHN4UCrCxoXQTYjHnR78eI66Gk9LaLLsAvzPNVJBaa66DRfFNaiA==
 
-micromatch@4.x, micromatch@^4.0.2:
+micromatch@4.0.2, micromatch@4.x, micromatch@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259"
   integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==


Test patch:
diff --git a/packages/cli/src/api/catalog.test.ts b/packages/cli/src/api/catalog.test.ts
index 045a0ab5..c656c9b4 100644
--- a/packages/cli/src/api/catalog.test.ts
+++ b/packages/cli/src/api/catalog.test.ts
@@ -712,6 +712,23 @@ describe("getCatalogForFile", function () {
       catalog,
     })
   })
+
+  it("should work with Windows path delimiters", function () {
+    const catalog = new Catalog(
+      {
+        name: null,
+        path: ".\\src\\locales\\{locale}",
+        include: ["./src/"],
+      },
+      mockConfig({ format: "po" })
+    )
+    const catalogs = [catalog]
+
+    expect(getCatalogForFile(".\\src\\locales\\en.po", catalogs)).toEqual({
+      locale: "en",
+      catalog,
+    })
+  })
 })
 
 describe("cleanObsolete", function () {