From e374c75655417659bc78bbedfb349a96c606052d Mon Sep 17 00:00:00 2001 From: Will Ockelmann-Wagner Date: Tue, 5 Mar 2019 10:13:41 -0800 Subject: [PATCH 1/5] ignore more files vscode doesn't need --- .vscodeignore | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index fc62257..5a83c5d 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,12 +1,15 @@ -.vscode/** +.build.yml +.gitignore +.prettierignore +.projections.json .vscode-test/** -out/test/** +.vscode/** +assets/screencasts out/**/*.map +out/test/** src/** -.gitignore +test-project tsconfig.json -vsc-extension-quickstart.md tslint.json -yarn.lock -test-project -assets/screencasts \ No newline at end of file +vsc-extension-quickstart.md +yarn.lock \ No newline at end of file From dc2587145609da97997bd7de42ab2e4f9ea4791d Mon Sep 17 00:00:00 2001 From: Will Ockelmann-Wagner Date: Tue, 5 Mar 2019 10:22:19 -0800 Subject: [PATCH 2/5] set banner color to match icon --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index b0a8adf..4f78e1e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "alt": "./bin/cli.js" }, "icon": "assets/icon.png", + "galleryBanner": { + "color": "#3771C8" + }, "activationEvents": [ "onCommand:alternate.alternateFile", "onCommand:alternate.alternateFileInSplit", From 0d4bf0c3f4b998fbc295db9f0b7324355439219d Mon Sep 17 00:00:00 2001 From: Will Ockelmann-Wagner Date: Tue, 5 Mar 2019 10:43:38 -0800 Subject: [PATCH 3/5] handle json parsing errors --- src/engine/AlternatePattern.test.ts | 13 +++++++------ src/engine/File.ts | 8 ++++++-- src/engine/Projections.ts | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/engine/AlternatePattern.test.ts b/src/engine/AlternatePattern.test.ts index 396d212..561026b 100644 --- a/src/engine/AlternatePattern.test.ts +++ b/src/engine/AlternatePattern.test.ts @@ -20,7 +20,7 @@ describe("AlternatePattern", () => { it("finds an implementation from a test", () => { expect( AlternatePattern.alternatePath( - "src/components/__test__/Foo.test.ts", + "/project/src/components/__test__/Foo.test.ts", projectionsPath )(patterns[0]) ).toBe("/project/src/components/Foo.ts"); @@ -28,7 +28,7 @@ describe("AlternatePattern", () => { it("finds alternate for short path", () => { expect( - AlternatePattern.alternatePath("app/foo.rb", projectionsPath)( + AlternatePattern.alternatePath("/project/app/foo.rb", projectionsPath)( patterns[1] ) ).toBe("/project/test/foo_spec.rb"); @@ -36,15 +36,16 @@ describe("AlternatePattern", () => { it("finds ts specs", () => { expect( - AlternatePattern.alternatePath("./src/foo/bar.ts", projectionsPath)( - patterns[0] - ) + AlternatePattern.alternatePath( + "/project/src/foo/bar.ts", + projectionsPath + )(patterns[0]) ).toBe("/project/src/foo/__test__/bar.test.ts"); }); it("returns null for non-matches", () => { expect( - AlternatePattern.alternatePath("src/foo.rb", projectionsPath)( + AlternatePattern.alternatePath("/project/src/foo.rb", projectionsPath)( patterns[0] ) ).toBe(null); diff --git a/src/engine/File.ts b/src/engine/File.ts index 15ec514..e0eba18 100644 --- a/src/engine/File.ts +++ b/src/engine/File.ts @@ -81,11 +81,15 @@ export const readFile = (path: string): Result.P => * Wrap a JSON parse in a Result. * @returns Ok(body) */ -export const parseJson = (data: string): Result.Result => { +export const parseJson = ( + data: string, + fileName?: string +): Result.Result => { try { return Result.ok(JSON.parse(data)); } catch (e) { - return Result.error(e); + const message = `Couldn't parse ${fileName || "file"}: ${e.message}`; + return Result.error(message); } }; diff --git a/src/engine/Projections.ts b/src/engine/Projections.ts index e1e3a04..607638b 100644 --- a/src/engine/Projections.ts +++ b/src/engine/Projections.ts @@ -121,7 +121,7 @@ export const readProjections = async ( projectionsPath, File.readFile, Result.mapOk((data: string): string => (data === "" ? "{}" : data)), - Result.chainOk((x: string) => File.parseJson(x)), + Result.chainOk((x: string) => File.parseJson(x, projectionsPath)), Result.mapError((error: string) => ({ startingFile: projectionsPath, message: error From 90284f00e54fe37394b6de0a450ecdc75a479484 Mon Sep 17 00:00:00 2001 From: Will Ockelmann-Wagner Date: Tue, 5 Mar 2019 14:50:37 -0800 Subject: [PATCH 4/5] refactor AlternatePattern and add multi dirname support --- .projections.json | 2 +- .../projections.elixir-umbrella.json | 5 + src/engine/AlternatePattern.test.ts | 13 ++ src/engine/AlternatePattern.ts | 127 ++++++++++++++---- src/engine/Projections.ts | 2 +- src/engine/utils.test.ts | 27 +++- src/engine/utils.ts | 30 +++++ src/test/extension.test.ts | 5 + test-project/.projections.json | 3 + .../elixir/apps/my_app/lib/accounts/user.ex | 0 .../apps/my_app/test/accounts/user_test.exs | 0 11 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 sample-projections/projections.elixir-umbrella.json create mode 100644 test-project/elixir/apps/my_app/lib/accounts/user.ex create mode 100644 test-project/elixir/apps/my_app/test/accounts/user_test.exs diff --git a/.projections.json b/.projections.json index 85f4340..1b89644 100644 --- a/.projections.json +++ b/.projections.json @@ -1,3 +1,3 @@ { - "src/*.ts": { "alternate": ["src/test/{}.test.ts", "src/{}.test.ts"] } + "src/*.ts": { "alternate": ["src/{}.test.ts", "src/test/{}.test.ts"] } } diff --git a/sample-projections/projections.elixir-umbrella.json b/sample-projections/projections.elixir-umbrella.json new file mode 100644 index 0000000..dff8889 --- /dev/null +++ b/sample-projections/projections.elixir-umbrella.json @@ -0,0 +1,5 @@ +{ + "apps/**/lib/**/*.ex": { + "alternate": "apps/{dirname}/test/{dirname}/{basename}_test.exs" + } +} diff --git a/src/engine/AlternatePattern.test.ts b/src/engine/AlternatePattern.test.ts index 561026b..48982a4 100644 --- a/src/engine/AlternatePattern.test.ts +++ b/src/engine/AlternatePattern.test.ts @@ -10,6 +10,10 @@ describe("AlternatePattern", () => { { main: "app/{dirname}/{basename}.rb", alternate: "test/{dirname}/{basename}_spec.rb" + }, + { + main: "apps/{dirname}/lib/{dirname}/{basename}.ex", + alternate: "apps/{dirname}/test/{dirname}/{basename}_test.exs" } ]; @@ -50,5 +54,14 @@ describe("AlternatePattern", () => { ) ).toBe(null); }); + + it("finds a match with multiple dirnames", () => { + const path = AlternatePattern.alternatePath( + "/project/apps/my_app/lib/accounts/user.ex", + projectionsPath + )(patterns[2]); + + expect(path).toBe("/project/apps/my_app/test/accounts/user_test.exs"); + }); }); }); diff --git a/src/engine/AlternatePattern.ts b/src/engine/AlternatePattern.ts index 771427a..d8c69e1 100644 --- a/src/engine/AlternatePattern.ts +++ b/src/engine/AlternatePattern.ts @@ -1,4 +1,6 @@ import * as path from "path"; +import * as utils from "./utils"; +import * as Result from "../result/Result"; /** * A computer-friendly representation of paths for switching between alternate files. @@ -8,23 +10,28 @@ export interface t { alternate: string; } -const slash = "/"; -const backslash = "\\\\"; -const anyBackslashRegex = new RegExp(backslash, "g"); +const slash = "[/\\]"; +const notSlash = "[^/\\]"; +const escapedSlash = "[/\\\\]"; -const dirnameRegex = new RegExp( - `{dirname}(?:${slash}|${backslash}${backslash})`, - "g" -); +const anyBackslashRegex = /\\/g; +const escapedBackslash = "\\\\"; + +const transformationPattern = "{([^{}]+)}"; + +const dirnameRegex = new RegExp(`{dirname}${escapedSlash}`, "g"); const basenameRegex = /{basename}/g; -const dirnamePattern = `(?:(.+)[${slash}${backslash}])?`; -const basenamePattern = `([^${slash}${backslash}]+)`; +const dirnamePattern = `(?:(.+)${slash})?`; +const basenamePattern = `(${notSlash}+)`; /** - * Use an AlternatePath to find a possible alternate path for a file. - * @param path - * @param projectionsPath + * Given a filePath and an AlternatePath, calculate if the filePath matches + * a pattern, and if it does, calculate what the matching alternate file path + * would be. + * @param path - the absolute path to the file + * @param projectionsPath - the absolute path to the projections file + * @param alternatePath - the AlternatePath object to match against. */ export const alternatePath = (path: string, projectionsPath: string) => ({ main, @@ -45,31 +52,101 @@ const alternatePathForSide = ( alternatePattern ); - const regex = patternToRegex(absolutePattern); + const matchResult = matchPatternToPath(absolutePattern, filePath); + if (Result.isError(matchResult)) return null; + + const pathMatches = matchResult.ok; + const transformations = patternToTransformations(absolutePattern); + + return fillPattern(transformations, pathMatches, absoluteAlternatePattern); +}; + +/** + * Take the available transformation names and match values, and use them to fill up the alternate pattern. + * @param transformations + * @param matches + * @param alternatePattern + * @returns A complete file path. + */ +const fillPattern = ( + transformations: string[], + matches: string[], + alternatePattern: string +): string => { + const filledPath = utils + .zip(transformations, matches) + .reduce( + (alternatePattern: string, [transformation, match]: [string, string]) => + alternatePattern.replace(`{${transformation}}`, match || ""), + alternatePattern + ); + + return path.normalize(filledPath); +}; + +/** + * Extract a list of transformations from a pattern, to be zipped with their matches. + * @param pathPattern + * @returns list of transformation names + */ +const patternToTransformations = (pathPattern: string) => { + const regex = new RegExp(transformationPattern, "g"); + + const transformations: string[] = []; + let matches: RegExpExecArray | null; + + while ((matches = regex.exec(pathPattern)) !== null) { + transformations.push(matches[1]); + } + + return transformations; +}; + +/** + * Take a path pattern string, and use it to try to pull out matches from the file path. + * @param pathPattern - String to be converted to regex + * @param filePath - Current file + * @returns Ok(matches) | Error(null) if no matches + */ +const matchPatternToPath = ( + pathPattern: string, + filePath: string +): Result.Result => { + const regex = patternToRegex(pathPattern); const matches = filePath.match(regex); - if (!matches || !matches[2]) return null; + if (!matches || !matches[2]) return Result.error(null); - const dirname = matches[1]; - const basename = matches[2]; + const pathMatches = matches.slice(1); - return path.normalize( - absoluteAlternatePattern - .replace(dirnameRegex, dirname ? `${dirname}/` : "") - .replace(basenameRegex, basename) - ); + return Result.ok(pathMatches); }; +/** + * Take a projections pattern, and convert it to a regex that will extract the variable parts. + */ const patternToRegex = (pathPattern: string): RegExp => { const regexPattern = pathPattern .replace(dirnameRegex, dirnamePattern) .replace(basenameRegex, basenamePattern); - return new RegExp(regexPattern); + + const escapedPattern = escapeBackslashes(regexPattern); + + return new RegExp(escapedPattern); }; +/** + * Append a pattern to the absolute path of the projections file + * @param projectionsPath - absolute path to the projections file + * @param filePattern - + */ const combinePaths = (projectionsPath: string, filePattern: string): string => { const projectionsDir = path.dirname(projectionsPath); - const fullPath = path.resolve(projectionsDir, filePattern); - - return fullPath.replace(anyBackslashRegex, backslash); + return path.resolve(projectionsDir, filePattern); }; + +/** + * Escape backslashes before converting a string to a regex. + */ +const escapeBackslashes = (pattern: string): string => + pattern.replace(anyBackslashRegex, escapedBackslash); diff --git a/src/engine/Projections.ts b/src/engine/Projections.ts index 607638b..f9d8b05 100644 --- a/src/engine/Projections.ts +++ b/src/engine/Projections.ts @@ -184,7 +184,7 @@ const mainPathToAlternate = (path: string): string => { const taggedPath = /\*\*/.test(path) ? path : path.replace("*", "**/*"); - return taggedPath.replace("**", "{dirname}").replace("*", "{basename}"); + return taggedPath.replace(/\*\*/g, "{dirname}").replace("*", "{basename}"); }; const alternatePathToAlternate = (path: string): string => { diff --git a/src/engine/utils.test.ts b/src/engine/utils.test.ts index 60d9edc..93a1d82 100644 --- a/src/engine/utils.test.ts +++ b/src/engine/utils.test.ts @@ -1,11 +1,26 @@ import * as utils from "./utils"; -describe("log", () => { - it("returns the passing in data", () => { - jest - .spyOn(console, "log") - .mockImplementation((...args: any[]) => (x: any) => x); +describe("utils", () => { + describe("log", () => { + it("returns the passing in data", () => { + jest + .spyOn(console, "log") + .mockImplementation((...args: any[]) => (x: any) => x); - expect(utils.log("a test")(1)).toBe(1); + expect(utils.log("a test")(1)).toBe(1); + }); + }); + + describe("zip", () => { + it("zips two arrays", () => { + expect(utils.zip([1, 2, 3], [4, 5, 6])).toEqual([[1, 4], [2, 5], [3, 6]]); + }); + }); + + describe("replace", () => { + it("replaces text in a string", () => { + const numberReplacer = utils.replace(/\d/g, "*"); + expect(numberReplacer("123-12-1234")).toEqual("***-**-****"); + }); }); }); diff --git a/src/engine/utils.ts b/src/engine/utils.ts index d138e7a..9c19b23 100644 --- a/src/engine/utils.ts +++ b/src/engine/utils.ts @@ -1,9 +1,39 @@ +/** + * Pipeable console.log + * @param args - Varadic args to tag the log with. + * @param data - Final data to console.log + */ export const log = (...args: any[]) => (data: T): T => { const logArgs = args.concat([data]); console.log(...logArgs); return data; }; +/** + * Async sleep + * @param milliseconds + */ export const sleep = (milliseconds: number): Promise => { return new Promise(resolve => setTimeout(resolve, milliseconds)); }; + +/** + * Zip two arrays together. Output is the length of the first array. + * @param array1 + * @param array2 + */ +export const zip = (array1: T[], array2: U[]): [T, U][] => + array1.map((a, i) => { + const b = array2[i]; + return [a, b] as [T, U]; + }); + +/** + * Pipeable version of String.replace + * @param pattern - The pattern to replace + * @param replacement - The value to replace the pattern with + * @returns the updated string. + */ +export const replace = (pattern: RegExp | string, replacement: string) => ( + string: string +): string => string.replace(pattern, replacement); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index c5ae321..4602194 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -26,6 +26,11 @@ const testCases = [ description: "are a secondary match", implementation: "js/js-tested-file.ts", spec: "js/js-tested-file.test.js" + }, + { + description: "have two variable directories", + implementation: "elixir/apps/my_app/lib/accounts/user.ex", + spec: "elixir/apps/my_app/test/accounts/user_test.exs" } ]; diff --git a/test-project/.projections.json b/test-project/.projections.json index d08fc18..094bf36 100644 --- a/test-project/.projections.json +++ b/test-project/.projections.json @@ -21,5 +21,8 @@ }, "ruby/app/*.rb": { "alternate": ["ruby/spec/{}_spec.rb"] + }, + "elixir/apps/**/lib/**/*.ex": { + "alternate": "elixir/apps/{dirname}/test/{dirname}/{basename}_test.exs" } } diff --git a/test-project/elixir/apps/my_app/lib/accounts/user.ex b/test-project/elixir/apps/my_app/lib/accounts/user.ex new file mode 100644 index 0000000..e69de29 diff --git a/test-project/elixir/apps/my_app/test/accounts/user_test.exs b/test-project/elixir/apps/my_app/test/accounts/user_test.exs new file mode 100644 index 0000000..e69de29 From bbbd5376f8e1e543dc6c0b596ed30daa2a7eeec5 Mon Sep 17 00:00:00 2001 From: Will Ockelmann-Wagner Date: Tue, 5 Mar 2019 14:58:04 -0800 Subject: [PATCH 5/5] document multiple dirnames --- CHANGELOG.md | 1 + README.md | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e60f7..c2520a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support finding .projections.json in places other than the workspace. +- Support multiple dirname parts ### Changed diff --git a/README.md b/README.md index 9b8e88a..e98c34e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ Each line should have the pattern for an implementation file as the key, and an If your test paths have an extra directly in the middle of them, like with `app/some/path/__test__/file.test.js` with Jest, you can use `{dirname}` for the directory path and `{basename}` for the filename. You can do the same thing on the implementation side with the standard glob syntax: `**` to represent the directory path, and `*` to represent the filename, like `app/**/something/*.js`. +If your paths have more than two variable parts, that can work too! You can use multiple sets of `**`/`{dirname}` pairs, which allows you to do something like: + +```json +"apps/**/lib/**/*.ex": { + "alternate": "apps/{dirname}/test/{dirname}/{basename}_test.exs" +} +``` + ### Multiple alternates If your project is inconsistent about where specs go (it happens to the best of us), you can also pass an array to `alternate`. The extension will look for a file matching the first alternate, then the second, and so on. When you create an alternate file, it will always follow the first pattern. @@ -119,4 +127,4 @@ Click the Debug button in the sidebar and run `Extension` - Support templates for auto-populating new files. - Automatically create default .projection.json files - Support all the transformations from Projectionist, not just `dirname` and `basename`. -- Support the "type" attribute in `.projections.json`, and allow for lookup by filetype, like for "controller/view/template". +- Support the "type" attribute in `.projections.json`, and allow for lookup by filetype, like for "`controller`/`view`/`template`".