Given a multi-line string, it is rather tedious to programmatically insert a string at a certain place. In this post, I would like to share a node.js library that I wrote to provide a general solution.

By the way, it is called Editer and available on npm. The source code is hosted on this repo.

I also talked about this at the Sydney node.js meetup. The slides are available here.

Example

Let’s look at an example of the problem of multi-line string manipulation.

sample.js

This is line 1 // line 1
This is line 3 // line 2
This is line 4 // line 3

How would you insert This is line 2 on line 2 as a new line?

You could probably do something like the following:

import fs from 'fs';

let sample = fs.readFileSync('./sample.js');
let sampleLines = sample.split('\n');
sampleLines.splice(2, 0, 'This is line 2').join('\n')

Yet, dealing with the nitty gritty of the operation quickly becomes tedious. With editer, you can write more expressive code to do the same job.

import fs from 'fs';
import editer from 'editer';

let sample = fs.readFileSync('./sample.js');
editer.insert('This is line 2', sample, {after: {line: 1}, asNewLine: true});

Editer is an npm module that allows you to manipulate a multi-line string with a high level API, saving you the trouble of splitting the string into array or doing regex matching.

In short, you can use editer to:

  • insert a string before/after a certain line in a multiline string optionally as a new line
  • insert a string before/after nth match of a regex in a multiline string optionally as a new line

Syntax

editer.insert(addition, target, options)

addition is the String you want to insert, and target is the String into which you want to insert addition.

option is where you can specify where and how to insert addition to your target. Within option, editer expects a condition, which specifies where to modify the target.

Condition

A condition is an object that appears under either after or before key. Here are examples:

{before: {regex: /[a-zA-z]{5}/g, occurrence: 2}}
{after: {regex: /[a-zA-z]{5}/g, last: true}}
{after: {line: 1}}

A condition can have either regex or line. With regex, you can optionally provide occurrence which specifies the order of match after which the target should be modified. For instance, the first example shown above will try to insert before the second match of the provided regex.

You can also set last to true, when providing a regex. That way, editer will insert before/after the last match of the regex in the target.

Many options

You can pass in many options by using Mongo-esque or syntax. See the example here:

var target = "Whoa, whoa, whoa, whoa... stop right there.";
var options = {or: [
  {before: {regex: /unicorn/ig, last: true}},
  {after: {regex: /whoa,\s/ig, occurrence: 3}},
  {after: {regex: /stop/i}}
]};
var result = editer.insert("hey, ", target, options);
console.log(result);
// => "Whoa, whoa, whoa, hey, whoa... stop right there."

Editer will try all the options from the start to the end until it finds one that matches with target. All the other options that come after will be disregarded.

This or option will save you the trouble of writing if-else statements and make your code more expressive.

asNewLine

By default, editer inserts your addition as a normal string, not followed by line break. You can set asNewLine option to true to ensure that your addition appears as a new line in the modified target.

How does it work?

Editer does two things to insert a string at a desired location in another string.

  1. Find the index of the desired position
  2. Split the target string into two at that index, and concatenate those two strings at the beginning and and the end of the addition string.

The second step is rather trivial. The first one needs some code. Under the hood, Editer talks to another module I made , named locater.

Locater does one thing: given a pattern or an array of patterns, find all the occurrences of those patterns in a target string. It can return the indices of the occurrences, which is exactly what we need.

After getting all the indices of potential positions from locater, editer simply chooses the final position depending on the condition provided by the caller, and inserts the addition at that particular position, returning the modified target.

Post mortem

Editer solves my current problem. But there are some design decisions I think I should have made, and some plans for the future.

  • New API

All editer does at the moment is inserting a string. It seems rather pointless to do editer.insert. I could export the insert function by default and simply call it by

import editer from 'editer';

editer(string, target, options);

Also, I want to implement and key in the options that will take an array of conditions as value. Unlike or, it will try all the conditions and modify the target in a greedy manner.

In the future versions, options should take insert key with the value to be inserted. This way, I don’t have to call editer multiple times to make more than one insertion.

Here is what I envision an API will look in the future:

editer(target, {or: [{insert: 'hello', after: {line: 1}}], and: [{insert: 'world', before: {regex: /foo/g, last: true}, asNewLine: true}]});
  • Access capturing group

It would be useful to allow callers to access the capturing group of the regex in the addition string, if they are using regex in the condition.

This is tricky because the type of addition is String, and it will require eval of some sort to pass the capturing group’s value into the addition. Perhaps we can agree in the API documentation that ####matched[n]#### in the addition will be evaluated as the value of the capturing group’s nth element. But that seems hacky and unintuitive.

I think we can draw some inspiration from template languages like erb. We can use <%= %> to evaluate the value within addition and allow callers to specify the variable name referring to the capturing group.

var target = "It's Zed's.\nWho's Zed?";
var options = {before: {regex: /Zed.*\n/g}, capturingGroupVariable: 'matched'};
editer.insert("<%= matched[1] %>'s chopper", target, options);

Conclusion

Editer is a small library that does one thing: inserting a string to a multi-line string at a specific position. I think having small, smart libraries is important for a prosperity of platforms such as node.js.

It makes developer’s life easy to have small libraries that do their job well. That way, a developer can orchestrate libraries to work together to perform a desired task. If editer couldn’t talk to locater, its code would have been a lot messier and convoluted.

In this light, we all should make it a habit to to publish small libraries for the problems we solve. Doing so will benefit the whole node community by enriching developer experience.

Such deed of sharing solutions is not only a good will. You will also have the whole open source community to work on your code and make your solutions better.