Skip to content

Commit d329e83

Browse files
committed
perf: only modify array once per action & write once per file
On a file with 10000 of the same typo, replacing all of that typo will be ~8% faster and include 99.99% less reads and writes
1 parent 7de31ed commit d329e83

3 files changed

Lines changed: 59 additions & 43 deletions

File tree

src/display.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,20 @@ export const centerText = (text: string, length: number, width: number) => {
4949
};
5050

5151
export const determineAction = async (
52-
url: URL,
5352
issue: Issue,
5453
issues: Issue[],
5554
totalIssueCount: number
5655
): Promise<[Action, string]> => {
5756
const index = totalIssueCount - issues.length;
5857
const progressIndicator = `${index}/${totalIssueCount} ── `;
5958
const text = formatContext(issue);
60-
const path = fileURLToPath(url);
59+
const path = fileURLToPath(new URL(issue.uri!));
6160
const trace = `:${issue.row}:${issue.col}`;
6261

6362
// Create a header that displays the absolute path to the file and the line and column of the typo. This header
6463
// is centered in the terminal (the width of the terminal is stored in process.stdout.columns)
6564

66-
const typoLocation = bold(
67-
greenBright(progressIndicator) + whiteBright(fileURLToPath(url)) + cyan(`:${issue.row}:${issue.col}`)
68-
);
65+
const typoLocation = bold(greenBright(progressIndicator) + whiteBright(path) + cyan(`:${issue.row}:${issue.col}`));
6966

7067
const width = process.stdout.columns;
7168
const typoLocationHeader = centerText(typoLocation, path.length + trace.length + progressIndicator.length, width);

src/handleIssue.ts

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,19 @@ import { addIgnoreWordToSettings, writeToSettings } from './config';
88

99
let totalIssueCount = 0;
1010

11-
export const writeChangeToFile = async (url: URL, issue: Issue, replacer: string) => {
12-
const file = await readFile(url, 'utf8');
13-
11+
export const updateFileText = (issue: Issue, replacer: string, file: string) => {
1412
// `issue.offset` is the index of the first character of the issue. With this, we slice everything in the file
1513
// before the text, then add the replacer, then add everything after the text.
16-
const newFile = file.slice(0, issue.offset) + replacer + file.slice(issue.offset + issue.text.length);
17-
await writeFile(url, newFile);
14+
return file.slice(0, issue.offset) + replacer + file.slice(issue.offset + issue.text.length);
15+
};
16+
17+
export const replaceSingleChange = async (issue: Issue, issues: Issue[], replacer: string) => {
18+
previousState.resolvedIssues.push(issue);
19+
updateFutureIssues(issue, replacer, issues);
20+
21+
const url = new URL(issue.uri!);
22+
const file = await readFile(url, 'utf8');
23+
await writeFile(url, updateFileText(issue, replacer, file));
1824
};
1925

2026
// When we replace a word, we need to update the offset of all future typos in that file, because they would be
@@ -66,15 +72,11 @@ export const updateFutureIssues = (issue: Issue, replacer: string, issues: Issue
6672
}
6773
};
6874

69-
export const fixIssue = async (issues: Issue[], issue: Issue, replacer: string, url: URL) => {
70-
previousState.resolvedIssues.push(issue);
71-
updateFutureIssues(issue, replacer, issues);
72-
await writeChangeToFile(url, issue, replacer).catch(() => null);
73-
};
75+
export const performSideEffects = async (issues: Issue[], issue: Issue, action: Action, replacer: string) => {
76+
const filesToWrite = new Map<string, { file: string; url: URL }>();
77+
const updatedIssues = [];
7478

75-
export const performSideEffects = async (issues: Issue[], issue: Issue, action: Action, replacer: string, url: URL) => {
76-
// The issues array is modified in place, so we need to make a copy of it.
77-
for (const futureIssue of [...issues]) {
79+
for (const [idx, futureIssue] of issues.entries()) {
7880
if (
7981
!futureIssue.uri ||
8082
// If we're skipping the file, we only want to remove issues that share the same URI. Otherwise (action
@@ -83,22 +85,26 @@ export const performSideEffects = async (issues: Issue[], issue: Issue, action:
8385
? futureIssue.uri !== issue.uri
8486
: futureIssue.text !== issue.text
8587
) {
88+
updatedIssues.push(futureIssue);
8689
continue;
8790
}
8891

89-
issues.splice(issues.indexOf(futureIssue), 1);
92+
if (action === Action.ReplaceAll) {
93+
const url = new URL(futureIssue.uri!);
94+
const file = filesToWrite.get(futureIssue.uri!)?.file ?? (await readFile(url, 'utf8'));
95+
filesToWrite.set(futureIssue.uri!, { file: updateFileText(futureIssue, replacer, file), url });
9096

91-
// If we're ignoring the issue, we don't need to do any more work.
92-
if (action !== Action.ReplaceAll) {
93-
continue;
97+
previousState.resolvedIssues.push(futureIssue);
98+
updateFutureIssues(futureIssue, replacer, issues.slice(idx + 1));
9499
}
95-
96-
// Otherwise, we update the offsets of future issues and write the change to file, just as we would if it
97-
// was a normal Replace action.
98-
// TODO: If multiple of these issues are in the same file, we should store the file contents in memory and
99-
// only write it once at the end.
100-
await fixIssue(issues, futureIssue, replacer, futureIssue.uri === issue.uri ? url : new URL(futureIssue.uri!));
101100
}
101+
102+
// `issues.splice(0, issues.length, ...)` is used to replace the entire array in place. This is faster than calling
103+
// issues.splice individually, especially when there are many issues.
104+
issues.splice(0, issues.length, ...updatedIssues);
105+
106+
// Only write to a file once, instead of once per issue.
107+
await Promise.allSettled([...filesToWrite.values()].map(({ url, file }) => writeFile(url, file)));
102108
};
103109

104110
export const restoreState = async (issues: Issue[]) => {
@@ -109,12 +115,21 @@ export const restoreState = async (issues: Issue[]) => {
109115
}
110116

111117
if (replacer) {
112-
for (const resolvedIssue of resolvedIssues) {
113-
// Replace the new replacer with the old text instead of the other way around.
114-
const newReplacer = resolvedIssue.text;
115-
resolvedIssue.text = replacer;
116-
await writeChangeToFile(new URL(resolvedIssue.uri!), resolvedIssue, newReplacer).catch(() => null);
118+
// Abridged version of performSideEffects, but without updating future issues, because we're restoring the state
119+
// right after this.
120+
const filesToWrite = new Map<string, { file: string; url: URL }>();
121+
for (const resolvedIssue of resolvedIssues.reverse()) {
122+
const url = new URL(resolvedIssue.uri!);
123+
const file = filesToWrite.get(resolvedIssue.uri!)?.file ?? (await readFile(url, 'utf8'));
124+
125+
// The issue text is now the replacer and it should be replaced with the original text to effectively revert
126+
// the change.
127+
const newFile = updateFileText({ ...resolvedIssue, text: replacer }, resolvedIssue.text, file);
128+
129+
filesToWrite.set(resolvedIssue.uri!, { file: newFile, url });
117130
}
131+
132+
await Promise.allSettled([...filesToWrite.values()].map(({ url, file }) => writeFile(url, file)));
118133
}
119134

120135
// Edit the entire issues array in place, so that the reference is the same.
@@ -127,8 +142,7 @@ export const handleIssue = async (issues: Issue[], issue: Issue) => {
127142
return;
128143
}
129144

130-
const url = new URL(issue.uri);
131-
const [action, replacer] = await determineAction(url, issue, issues, totalIssueCount);
145+
const [action, replacer] = await determineAction(issue, issues, totalIssueCount);
132146

133147
previousState.action = action;
134148

@@ -155,13 +169,18 @@ export const handleIssue = async (issues: Issue[], issue: Issue) => {
155169
config: undefined
156170
} as Omit<typeof previousState, 'action'>);
157171

158-
if (action === Action.Replace || action === Action.ReplaceAll) {
159-
await fixIssue(issues, issue, replacer, url);
160-
}
172+
if (action === Action.Replace) {
173+
await replaceSingleChange(issue, issues, replacer).catch(() => null);
174+
} else if (action === Action.ReplaceAll || action === Action.IgnoreAll || action === Action.SkipFile) {
175+
// The above actions all have side effects (as in, they require modification of future issues).
176+
177+
// Add the current issue back to the list of issues so it can be used in the performSideEffects loop to prevent
178+
// duplicated code. It will be removed from the list in the loop.
179+
if (action === Action.ReplaceAll) {
180+
issues.unshift(issue);
181+
}
161182

162-
// The following actions all have side effects (as in, they require modification of future issues).
163-
if (action === Action.ReplaceAll || action === Action.IgnoreAll || action === Action.SkipFile) {
164-
await performSideEffects(issues, issue, action, replacer, url);
183+
await performSideEffects(issues, issue, action, replacer);
165184

166185
if (action === Action.IgnoreAll) {
167186
await addIgnoreWordToSettings(issue.text).catch(() => null);

test/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ afterEach(() => {
3434
});
3535

3636
describe.each(allTypoSets)('%s', (name, data) => {
37-
test('Fixing & Displaying Typos', async () => {
37+
test('should accurately find and replace individual typos', async () => {
3838
file = data.text;
3939

4040
const originalIssueLength = data.issues.length;
@@ -52,7 +52,7 @@ describe.each(allTypoSets)('%s', (name, data) => {
5252

5353
// We can't use the original data.issues, because the `offset` and `line.text` properties are updated. So, we
5454
// have to use the mocked `determineAction` function to get the issues that were displayed.
55-
const displayedIssues = vi.mocked(determineAction).mock.calls.map(([, issue]) => issue);
55+
const displayedIssues = vi.mocked(determineAction).mock.calls.map(([issue]) => issue);
5656
const contextDisplays = displayedIssues.map((issue) => formatContext(issue));
5757

5858
expect(contextDisplays, name).toMatchObject(data.displays);

0 commit comments

Comments
 (0)