Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .projections.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"src/*.ts": { "alternate": ["src/test/{}.test.ts", "src/{}.test.ts"] }
"src/*.ts": { "alternate": ["src/{}.test.ts", "src/test/{}.test.ts"] }
}
17 changes: 10 additions & 7 deletions .vscodeignore
Original file line number Diff line number Diff line change
@@ -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
vsc-extension-quickstart.md
yarn.lock
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`".
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"alt": "./bin/cli.js"
},
"icon": "assets/icon.png",
"galleryBanner": {
"color": "#3771C8"
},
"activationEvents": [
"onCommand:alternate.alternateFile",
"onCommand:alternate.alternateFileInSplit",
Expand Down
5 changes: 5 additions & 0 deletions sample-projections/projections.elixir-umbrella.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"apps/**/lib/**/*.ex": {
"alternate": "apps/{dirname}/test/{dirname}/{basename}_test.exs"
}
}
26 changes: 20 additions & 6 deletions src/engine/AlternatePattern.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
];

Expand All @@ -20,34 +24,44 @@ 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");
});

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");
});

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);
});

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");
});
});
});
127 changes: 102 additions & 25 deletions src/engine/AlternatePattern.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
Expand All @@ -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<string[], null> => {
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);
8 changes: 6 additions & 2 deletions src/engine/File.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,15 @@ export const readFile = (path: string): Result.P<string, any> =>
* Wrap a JSON parse in a Result.
* @returns Ok(body)
*/
export const parseJson = <T>(data: string): Result.Result<T, any> => {
export const parseJson = <T>(
data: string,
fileName?: string
): Result.Result<T, any> => {
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);
}
};

Expand Down
4 changes: 2 additions & 2 deletions src/engine/Projections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const readProjections = async (
projectionsPath,
File.readFile,
Result.mapOk((data: string): string => (data === "" ? "{}" : data)),
Result.chainOk((x: string) => File.parseJson<t>(x)),
Result.chainOk((x: string) => File.parseJson<t>(x, projectionsPath)),
Result.mapError((error: string) => ({
startingFile: projectionsPath,
message: error
Expand Down Expand Up @@ -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 => {
Expand Down
27 changes: 21 additions & 6 deletions src/engine/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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("***-**-****");
});
});
});
Loading