From 62ab2faf2079be895196cae2b1d50132dd29e546 Mon Sep 17 00:00:00 2001 From: Kevin Gilpin Date: Mon, 28 Oct 2024 17:24:42 -0400 Subject: [PATCH 1/2] fix: Adjust the first line of a patch when it has 0 indent Fixes https://github.com/getappmap/appmap-js/issues/2095 --- packages/cli/src/rpc/file/applyFileUpdate.ts | 104 +++++++++++++++--- .../unit/rpc/file/applyFileUpdate.spec.ts | 53 +++++---- .../apply.yml | 22 ++++ .../expected.txt | 12 ++ .../original.txt | 11 ++ .../missing-firstline-indent/apply.yml | 22 ++++ .../missing-firstline-indent/expected.txt | 12 ++ .../missing-firstline-indent/original.txt | 11 ++ 8 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/apply.yml create mode 100644 packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/expected.txt create mode 100644 packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/original.txt create mode 100644 packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/apply.yml create mode 100644 packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/expected.txt create mode 100644 packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/original.txt diff --git a/packages/cli/src/rpc/file/applyFileUpdate.ts b/packages/cli/src/rpc/file/applyFileUpdate.ts index 4be63954bf..78df8db52a 100644 --- a/packages/cli/src/rpc/file/applyFileUpdate.ts +++ b/packages/cli/src/rpc/file/applyFileUpdate.ts @@ -1,7 +1,8 @@ -import { verbose } from '../../utils'; import { readFile, writeFile } from 'fs/promises'; -import { warn } from 'console'; import assert from 'assert'; +import makeDebug from 'debug'; + +const debug = makeDebug('appmap:cli:file-update'); function findLineMatch( haystack: readonly string[], @@ -47,35 +48,102 @@ function makeWhitespaceAdjuster(to: string, from: string) { return adjuster; } +type MatchResult = { + index: number; + length: number; + whitespaceAdjuster: (s: string) => string; + whitespaceAdjusterDescription: string; +}; + +function matchFileUpdate(fileLines: string[], originalLines: string[]): MatchResult | undefined { + const match = findLineMatch(fileLines, originalLines); + if (!match) return undefined; + + const [index, length] = match; + const nonEmptyIndex = originalLines.findIndex((s) => s.trim()); + const adjustWhitespace = makeWhitespaceAdjuster( + fileLines[index + nonEmptyIndex], + originalLines[nonEmptyIndex] + ); + + return { + index, + length, + whitespaceAdjuster: adjustWhitespace, + whitespaceAdjusterDescription: adjustWhitespace.desc, + }; +} + +function searchForFileUpdate( + whitespaceAdjustments: string[], + fileLines: string[], + originalLines: string[] +): [MatchResult, string] | undefined { + for (const whitespaceAdjustment of whitespaceAdjustments) { + const adjustedOriginalLines = [...originalLines]; + adjustedOriginalLines[0] = whitespaceAdjustment + adjustedOriginalLines[0]; + const match = matchFileUpdate(fileLines, adjustedOriginalLines); + if (match) return [match, whitespaceAdjustment]; + } + + return undefined; +} + export default async function applyFileUpdate( file: string, original: string, modified: string ): Promise { - // Read the original file const fileContents = await readFile(file, 'utf-8'); const fileLines = fileContents.split('\n'); - const originalLines = original.split('\n'); - const match = findLineMatch(fileLines, originalLines); - if (!match) return [`[file-update] Failed to find match for ${file}.\n`]; + if (fileLines.length === 0) { + debug(`File is empty. Skipping.`); + return undefined; + } + if (originalLines.length === 0) { + debug(`Original text is empty. Skipping.`); + return undefined; + } - const [index, length] = match; + const firstLineLeadingWhitespace = originalLines[0].match(/^\s*/)?.[0]; + let whitespaceAdjustments: Set; - const nonEmptyIndex = originalLines.findIndex((s) => s.trim()); - const adjustWhitespace = makeWhitespaceAdjuster( - fileLines[index + nonEmptyIndex], - originalLines[nonEmptyIndex] + if (!firstLineLeadingWhitespace) { + debug( + `No leading whitespace found in the first line of the original text. Will attempt a fuzzy match.` + ); + const fileLinesMatchingFirstOriginalLine = new Set( + fileLines.filter((line) => line.includes(originalLines[0])) + ); + whitespaceAdjustments = new Set( + Array.from(fileLinesMatchingFirstOriginalLine).map((line) => line.match(/^\s*/)?.[0] ?? '') + ); + } else { + whitespaceAdjustments = new Set(['']); + } + + const searchResult = searchForFileUpdate( + Array.from(whitespaceAdjustments), + fileLines, + originalLines ); + if (!searchResult) { + debug(`No match found for the original text.`); + return undefined; + } - if (verbose()) - warn( - `[file-update] Found match at line ${index + 1}, whitespace adjustment: ${ - adjustWhitespace.desc - }\n` - ); - fileLines.splice(index, length, ...modified.split('\n').map(adjustWhitespace)); + const [match, leadingWhitespace] = searchResult; + const adjustedModified = [...modified.split('\n')]; + adjustedModified[0] = leadingWhitespace + adjustedModified[0]; + + debug( + `[file-update] Found match at line ${match.index + 1}, whitespace adjustment: ${ + match.whitespaceAdjusterDescription + }\n` + ); + fileLines.splice(match.index, match.length, ...adjustedModified.map(match.whitespaceAdjuster)); await writeFile(file, fileLines.join('\n'), 'utf8'); } diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts b/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts index 79dd74f21c..3b07c9b834 100644 --- a/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts @@ -5,7 +5,6 @@ import tmp from 'tmp'; import applyFileUpdate from '../../../../src/rpc/file/applyFileUpdate'; import { load } from 'js-yaml'; -import assert from 'node:assert'; type Change = { file: string; @@ -25,30 +24,34 @@ describe(applyFileUpdate, () => { process.chdir(startCwd); }); - const example = (name: string) => async () => { - expect.assertions(1); + const example = + (name: string, expectedAssertions = 1) => + async (): Promise => { + expect.assertions(expectedAssertions); - const fixtureDir = join(__dirname, 'applyFileUpdate', name); - await cp(fixtureDir, process.cwd(), { recursive: true }); - const applyStr = await readFile('apply.yml', 'utf8'); - const change = load(applyStr) as Change; - const { file, original, modified } = change; + const fixtureDir = join(__dirname, 'applyFileUpdate', name); + await cp(fixtureDir, process.cwd(), { recursive: true }); + const applyStr = await readFile('apply.yml', 'utf8'); + const change = load(applyStr) as Change; + const { file, original, modified } = change; - await applyFileUpdate(file, original, modified); + await applyFileUpdate(file, original, modified); - const updatedStr = await readFile('original.txt', 'utf8'); - const updated = updatedStr - .split('\n') - .map((line) => line.trim()) - .join('\n'); - const expectedStr = await readFile('expected.txt', 'utf8'); - const expected = expectedStr - .split('\n') - .map((line) => line.trim()) - .join('\n'); + const updatedStr = await readFile('original.txt', 'utf8'); + const updated = updatedStr + .split('\n') + .map((line) => line.trim()) + .join('\n'); + const expectedStr = await readFile('expected.txt', 'utf8'); + const expected = expectedStr + .split('\n') + .map((line) => line.trim()) + .join('\n'); - expect(updated).toEqual(expected); - }; + expect(updated).toEqual(expected); + + return updatedStr; + }; it('correctly applies an update even with broken whitespace', example('whitespace-mismatch')); it('correctly applies an update even with trailing newlines', example('trailing-newlines')); @@ -57,4 +60,12 @@ describe(applyFileUpdate, () => { 'correctly applies an update even when there are repeated similar but mismatching lines', example('mismatched-similar') ); + it('compensates for missing indentation in the first line', example('missing-firstline-indent')); + it('compensates for missing indentation in the first line', async () => { + const updated = await example('missing-firstline-indent-must-match', 4)(); + const updatedLines = updated.split('\n'); + expect(updatedLines[0]).toEqual(' # hello'); + expect(updatedLines[1]).toEqual(' def _bind_to_schema(self, field_name, schema):'); + expect(updatedLines[2]).toEqual(' super()._bind_to_schema(field_name, schema)'); + }); }); diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/apply.yml b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/apply.yml new file mode 100644 index 0000000000..bce6255b16 --- /dev/null +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/apply.yml @@ -0,0 +1,22 @@ +file: original.txt +original: |- + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields +modified: |- + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + if hasattr(field, '_bind_to_schema'): + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/expected.txt b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/expected.txt new file mode 100644 index 0000000000..fa8efc4e5c --- /dev/null +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/expected.txt @@ -0,0 +1,12 @@ + # hello + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + if hasattr(field, '_bind_to_schema'): + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields + diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/original.txt b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/original.txt new file mode 100644 index 0000000000..2dc7a4f1e2 --- /dev/null +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent-must-match/original.txt @@ -0,0 +1,11 @@ + # hello + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields + diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/apply.yml b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/apply.yml new file mode 100644 index 0000000000..cb287e8987 --- /dev/null +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/apply.yml @@ -0,0 +1,22 @@ +file: original.txt +original: |- + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields +modified: |- + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + if hasattr(field, '_bind_to_schema'): + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/expected.txt b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/expected.txt new file mode 100644 index 0000000000..fa8efc4e5c --- /dev/null +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/expected.txt @@ -0,0 +1,12 @@ + # hello + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + if hasattr(field, '_bind_to_schema'): + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields + diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/original.txt b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/original.txt new file mode 100644 index 0000000000..2dc7a4f1e2 --- /dev/null +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate/missing-firstline-indent/original.txt @@ -0,0 +1,11 @@ + # hello + def _bind_to_schema(self, field_name, schema): + super()._bind_to_schema(field_name, schema) + new_tuple_fields = [] + for field in self.tuple_fields: + field = copy.deepcopy(field) + field._bind_to_schema(field_name, self) + new_tuple_fields.append(field) + + self.tuple_fields = new_tuple_fields + From 7c705fabe6731e7bfa272b5c7ce84bb70e2c8df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Tue, 29 Oct 2024 14:08:38 +0100 Subject: [PATCH 2/2] chore: Streamline apply file update tests --- .../unit/rpc/file/applyFileUpdate.spec.ts | 47 +++++++------------ .../trailing-newlines/expected.txt | 10 ++-- 2 files changed, 21 insertions(+), 36 deletions(-) diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts b/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts index 3b07c9b834..daa36e8d00 100644 --- a/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate.spec.ts @@ -24,34 +24,22 @@ describe(applyFileUpdate, () => { process.chdir(startCwd); }); - const example = - (name: string, expectedAssertions = 1) => - async (): Promise => { - expect.assertions(expectedAssertions); + const example = (name: string) => async () => { + expect.assertions(1); - const fixtureDir = join(__dirname, 'applyFileUpdate', name); - await cp(fixtureDir, process.cwd(), { recursive: true }); - const applyStr = await readFile('apply.yml', 'utf8'); - const change = load(applyStr) as Change; - const { file, original, modified } = change; + const fixtureDir = join(__dirname, 'applyFileUpdate', name); + await cp(fixtureDir, process.cwd(), { recursive: true }); + const applyStr = await readFile('apply.yml', 'utf8'); + const change = load(applyStr) as Change; + const { file, original, modified } = change; - await applyFileUpdate(file, original, modified); + await applyFileUpdate(file, original, modified); - const updatedStr = await readFile('original.txt', 'utf8'); - const updated = updatedStr - .split('\n') - .map((line) => line.trim()) - .join('\n'); - const expectedStr = await readFile('expected.txt', 'utf8'); - const expected = expectedStr - .split('\n') - .map((line) => line.trim()) - .join('\n'); + const updatedStr = await readFile('original.txt', 'utf8'); + const expectedStr = await readFile('expected.txt', 'utf8'); - expect(updated).toEqual(expected); - - return updatedStr; - }; + expect(updatedStr).toEqual(expectedStr); + }; it('correctly applies an update even with broken whitespace', example('whitespace-mismatch')); it('correctly applies an update even with trailing newlines', example('trailing-newlines')); @@ -61,11 +49,8 @@ describe(applyFileUpdate, () => { example('mismatched-similar') ); it('compensates for missing indentation in the first line', example('missing-firstline-indent')); - it('compensates for missing indentation in the first line', async () => { - const updated = await example('missing-firstline-indent-must-match', 4)(); - const updatedLines = updated.split('\n'); - expect(updatedLines[0]).toEqual(' # hello'); - expect(updatedLines[1]).toEqual(' def _bind_to_schema(self, field_name, schema):'); - expect(updatedLines[2]).toEqual(' super()._bind_to_schema(field_name, schema)'); - }); + it( + 'compensates for missing indentation in the first line (even when there is a mismatch)', + example('missing-firstline-indent-must-match') + ); }); diff --git a/packages/cli/tests/unit/rpc/file/applyFileUpdate/trailing-newlines/expected.txt b/packages/cli/tests/unit/rpc/file/applyFileUpdate/trailing-newlines/expected.txt index 65fd6eafd8..ae2432b151 100644 --- a/packages/cli/tests/unit/rpc/file/applyFileUpdate/trailing-newlines/expected.txt +++ b/packages/cli/tests/unit/rpc/file/applyFileUpdate/trailing-newlines/expected.txt @@ -8,24 +8,24 @@ # A buffer for all table expressions in join conditions from_expression_elements = [] column_reference_segments = [] - + from_clause_segment = segment.get_child("from_clause") - + if not from_clause_segment: return None - + # Check if the FROM clause contains any JOINs join_clauses = from_clause_segment.recursive_crawl('join_clause') if not join_clauses: return None - + from_expression = from_clause_segment.get_child("from_expression") from_expression_element = None if from_expression: from_expression_element = from_expression.get_child( "from_expression_element" ) - + if not from_expression_element: return None from_expression_element = from_expression_element.get_child(