Skip to content

Commit

Permalink
feat: support more replace-in-file functionality (#172)
Browse files Browse the repository at this point in the history
* docs: generate documentation for current HEAD

The documentation was not completely up-to-date with the state of the
code. The URLs now all point back to the main repository.

* test: fix "file contents contain" assertion

This previously only set up a matcher and did not actually assert
anything.

This can be verified by changing to `expect.not.stringContaining` and
seeing whether any tests start to fail.

* feat: support full `from` functionality of `replace-in-file`

To better support configuration through JSON, the default behavior of
`from` for string inputs was previously changed relative to that of the
`replace-in-file` package.

For more advanced use cases, i.e. where a JavaScript configuration file
is used, the full functionality of the `replace-in-file` package is made
available.

* fix: improve typing for multi-argument callback functions for `to`

The `to` callback function can be as simple as a two-argument function
receiving the matched string and the filename it was matched in. However
if `from` is a regular expression, the function arguments may
additionally contain captures, the offset for the matched substring, the
whole string being inspected, etc. This type definition ensures those
functions are accepted, but require providing additional type
information.

* feat: pass the `semantic-release` `Context` to `from` callback functions

Information from the `semantic-release` `Context` may be useful when
determining the which content should be replaced. The `Context` is made
available as the last argument to the callback function.

Alternative implementation of #167.

* feat: pass the `semantic-release` `Context` to `to` callback functions

Information from the `semantic-release` `Context` may be useful when
determining the replacement text. The `Context` is made available as the
last argument to the callback function.

Alternative implementation of #167.

* feat: accept multiple `from` matchers

The underlying `replace-in-file` package accepts multiple matchers to be
passed as an array. This functionality is made available for multiple
basic string and more advanced replacements.

* feat: accept multiple `to` replacements

The underlying `replace-in-file` package accepts multiple replacements
to be passed as an array. This functionality is made available for
multiple basic string replacements and callback functions. When using
multiple replacements the number of replacements needs to be the same as
the number of matchers. Otherwise only a subset of the replacements is
used, e.g. it's not possible to use a single string matcher and provide
multiple replacements.
  • Loading branch information
bauglir committed Dec 20, 2022
1 parent 70b91ae commit 429ed59
Show file tree
Hide file tree
Showing 7 changed files with 358 additions and 32 deletions.
156 changes: 155 additions & 1 deletion __tests__/prepare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@
import * as fs from "fs-extra";
import * as path from "path";
import * as tmp from "tmp";
import { Context } from "semantic-release";

import { prepare } from "../src/index";

const context = {
lastRelease: {
gitHead: "asdfasdf",
gitTag: "v1.0.0",
version: "1.0.0",
},
nextRelease: {
type: "major" as const,
gitTag: "2.0.0",
Expand Down Expand Up @@ -55,7 +61,7 @@ async function assertFileContents(name: string, expected: string) {

async function assertFileContentsContain(name: string, expected: string) {
const actual = await fs.readFileSync(path.join(d.name, name), "utf-8");
expect.stringContaining(expected);
expect(actual).toEqual(expect.stringContaining(expected));
}

test("prepare should replace using regex", async () => {
Expand Down Expand Up @@ -191,3 +197,151 @@ test("prepare should replace using function", async () => {
await assertFileContentsContain("__init__.py", `__VERSION__ = 2`);
await assertFileContents("build.gradle", "version = 2");
});

test("prepare accepts regular expressions for `from`", async () => {
const replacements = [
{
files: [path.join(d.name, "/foo.md")],
from: /yarn(.+?)@.*/g,
to: `yarn add foo@${context.nextRelease.version}`,
},
];

await prepare({ replacements }, context);

await assertFileContentsContain("foo.md", "npm i foo@1.0.0");
await assertFileContentsContain("foo.md", "yarn add foo@2.0.0");
});

test("prepare accepts callback functions for `from`", async () => {
const replacements = [
{
files: [path.join(d.name, "/foo.md")],
from: (filename: string) => `${path.basename(filename, ".md")}@1.0.0`, // Equivalent to "foo@1.0.0"
to: `foo@${context.nextRelease.version}`,
},
];

await prepare({ replacements }, context);

// As `from` ended up being a string after executing the function, only the
// first occurrence of `foo@1.0.0` in the file should have been replaced.
// Note that this is different behavior from the case where a string is
// passed directly to `from` (which the plugin implicitly turns into a global
// regular expression)
await assertFileContentsContain("foo.md", "npm i foo@2.0.0");
await assertFileContentsContain("foo.md", "yarn add foo@1.0.0");
});

test("prepare accepts multi-argument `to` callback functions for regular expression `from`", async () => {
const replacements = [
{
files: [path.join(d.name, "/foo.md")],
from: /npm i (.+)@(.+)`/g,
to: (match: string, package_name: string, version: string) => {
return match
.replace(version, context.nextRelease.version)
.replace(package_name, package_name.split("").reverse().join(""));
},
},
];

await prepare({ replacements }, context);

await assertFileContentsContain("foo.md", "npm i oof@2.0.0");
await assertFileContentsContain("foo.md", "yarn add foo@1.0.0");
});

test("prepare passes the `context` as the final function argument to `from` callbacks", async () => {
const replacements = [
{
files: [path.join(d.name, "/foo.md")],
// Returns a regular expression matching the previous version, so that
// _all_ occurrences in the document are updated
from: (_: string, context: Context) =>
new RegExp(context?.lastRelease?.version || "", "g"),
to: "3.0.0",
},
];

await prepare({ replacements }, context);

await assertFileContentsContain("foo.md", "npm i foo@3.0.0");
await assertFileContentsContain("foo.md", "yarn add foo@3.0.0");
});

test("prepare passes the `context` as the final function argument to `to` callbacks", async () => {
const replacements = [
{
files: [path.join(d.name, "/foo.md")],
from: /npm i (.*)@(.*)`/,
to: (_: string, package_name: string, ...args: unknown[]) => {
let reversed_package_name = package_name.split("").reverse().join("");
let context = args.pop() as Context;

return `npm i ${reversed_package_name}@${context?.nextRelease?.version}`;
},
},
];

await prepare({ replacements }, context);

await assertFileContentsContain(
"foo.md",
`npm i oof@${context.nextRelease.version}`
);
await assertFileContentsContain("foo.md", "yarn add foo@1.0.0");
});

test("prepare accepts an array of `from` matchers", async () => {
const replacements = [
{
files: [path.join(d.name, "/foo.md")],
// Similarly to single string values, strings in arrays should be taken
// to mean global replacements for improved JSON configuration
// capabilities. The regular expression and function matchers should only
// replace a single occurrence and hence only affect the `npm` line
from: [
"1.0.0",
/install with/,
(filename: string) => path.basename(filename, ".md"),
],
to: "bar",
},
];

await prepare({ replacements }, context);

await assertFileContentsContain("foo.md", "bar `npm i bar@bar`");
await assertFileContentsContain("foo.md", "install with `yarn add foo@bar`");
});

test("prepare accepts an array of `to` replacements", async () => {
// This replaces `npm i` with `npm install` and all occurrences of `1.0.0`
// with the `version` of the `nextRelease` as string `from` matchers are
// turned into global regular expressions
const replacements = [
{
files: [path.join(d.name, "/foo.md")],
from: ["npm i", "1.0.0"],
to: [
"npm install",
(...args: unknown[]) => {
const context = args.pop() as Context;
return context?.nextRelease?.version || "";
},
],
},
];

await prepare({ replacements }, context);

await assertFileContentsContain(
"foo.md",
`npm install foo@${context.nextRelease.version}`
);
await assertFileContentsContain(
"foo.md",
`yarn add foo@${context.nextRelease.version}`
);
});
33 changes: 29 additions & 4 deletions dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
* limitations under the License.
*/
import { Context } from "semantic-release";
declare type From = FromCallback | RegExp | string;
declare type FromCallback = (filename: string, ...args: unknown[]) => RegExp | string;
declare type To = string | ToCallback;
declare type ToCallback = (match: string, ...args: unknown[]) => string;
/**
* Replacement is simlar to the interface used by https://www.npmjs.com/package/replace-in-file
* with the difference being the single string for `to` and `from`.
Expand All @@ -26,9 +30,19 @@ export interface Replacement {
/**
* The RegExp pattern to use to match.
*
* Uses `String.replace(new RegExp(s, 'g'), to)` for implementation.
* Uses `String.replace(new RegExp(s, 'gm'), to)` for implementation, if
* `from` is a string.
*
* For advanced matching, i.e. when using a `release.config.js` file, consult
* the documentation of the `replace-in-file` package
* (https://github.com/adamreisnz/replace-in-file/blob/main/README.md) on its
* `from` option. This allows explicit specification of `RegExp`s, callback
* functions, etc.
*
* Multiple matchers may be provided as an array, following the same
* conversion rules as mentioned above.
*/
from: string;
from: From | From[];
/**
* The replacement value using a template of variables.
*
Expand All @@ -37,15 +51,25 @@ export interface Replacement {
* The context object is used to render the template. Additional values
* can be found at: https://semantic-release.gitbook.io/semantic-release/developer-guide/js-api#result
*
* For advacned replacement, pass in a function to replace non-standard variables
* For advanced replacement (NOTE: only for use with `release.config.js` file version), pass in a function to replace non-standard variables
* ```
* {
* from: `__VERSION__ = 11`, // eslint-disable-line
* to: (matched) => `__VERSION: ${parseInt(matched.split('=')[1].trim()) + 1}`, // eslint-disable-line
* },
* ```
*
* The `args` for a callback function can take a variety of shapes. In its
* simplest form, e.g. if `from` is a string, it's the filename in which the
* replacement is done. If `from` is a regular expression the `args` of the
* callback include captures, the offset of the matched string, the matched
* string, etc. See the `String.replace` documentation for details
*
* Multiple replacements may be specified as an array. These can be either
* strings or callback functions. Note that the amount of replacements needs
* to match the amount of `from` matchers.
*/
to: string | ((a: string) => string);
to: To | To[];
ignore?: string[];
allowEmptyPaths?: boolean;
countMatches?: boolean;
Expand Down Expand Up @@ -95,3 +119,4 @@ export interface PluginConfig {
replacements: Replacement[];
}
export declare function prepare(PluginConfig: PluginConfig, context: Context): Promise<void>;
export {};
58 changes: 54 additions & 4 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@ exports.prepare = void 0;
var replace_in_file_1 = require("replace-in-file");
var lodash_1 = require("lodash");
var jest_diff_1 = __importDefault(require("jest-diff"));
/**
* Wraps the `callback` in a new function that passes the `context` as the
* final argument to the `callback` when it gets called.
*/
function applyContextToCallback(callback, context) {
return function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
return callback.apply(null, args.concat(context));
};
}
/**
* Applies the `context` to the replacement property `to` depending on whether
* it is a string template or a callback function.
*/
function applyContextToReplacement(to, context) {
return typeof to === "function"
? applyContextToCallback(to, context)
: lodash_1.template(to)(__assign({}, context));
}
/**
* Normalizes a `value` into an array, making it more straightforward to apply
* logic to a single value of type `T` or an array of those values.
*/
function normalizeToArray(value) {
return value instanceof Array ? value : [value];
}
function prepare(PluginConfig, context) {
return __awaiter(this, void 0, void 0, function () {
var _i, _a, replacement, results, replaceInFileConfig, actual;
Expand All @@ -83,11 +112,32 @@ function prepare(PluginConfig, context) {
results = replacement.results;
delete replacement.results;
replaceInFileConfig = replacement;
// The `replace-in-file` package uses `String.replace` under the hood for
// the actual replacement. If `from` is a string, this means only a
// single occurence will be replaced. This plugin intents to replace
// _all_ occurrences when given a string to better support
// configuration through JSON, this requires conversion into a `RegExp`.
//
// If `from` is a callback function, the `context` is passed as the final
// parameter to the function. In all other cases, e.g. being a
// `RegExp`, the `from` property does not require any modifications.
//
// The `from` property may either be a single value to match or an array of
// values (in any of the previously described forms)
replaceInFileConfig.from = normalizeToArray(replacement.from).map(function (from) {
switch (typeof from) {
case "function":
return applyContextToCallback(from, context);
case "string":
return new RegExp(from, "gm");
default:
return from;
}
});
replaceInFileConfig.to =
typeof replacement.to === "function"
? replacement.to
: lodash_1.template(replacement.to)(__assign({}, context));
replaceInFileConfig.from = new RegExp(replacement.from, "gm");
replacement.to instanceof Array
? replacement.to.map(function (to) { return applyContextToReplacement(to, context); })
: applyContextToReplacement(replacement.to, context);
return [4 /*yield*/, replace_in_file_1.replaceInFile(replaceInFileConfig)];
case 2:
actual = _b.sent();
Expand Down
2 changes: 1 addition & 1 deletion docs/interfaces/_index_.pluginconfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ PluginConfig is used to provide multiple replacement.

**replacements**: [Replacement](_index_.replacement.md)[]

*Defined in [index.ts:101](https://github.com/google/semantic-release-replace-plugin/blob/master/src/index.ts#L101)*
*Defined in [index.ts:128](https://github.com/google/semantic-release-replace-plugin/blob/60c4ca8/src/index.ts#L128)*

An array of replacements to be made.
Loading

0 comments on commit 429ed59

Please sign in to comment.