diff --git a/.gitignore b/.gitignore index dd87e2d..1d2a37f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules build +coverage diff --git a/.husky/pre-commit b/.husky/pre-push similarity index 100% rename from .husky/pre-commit rename to .husky/pre-push diff --git a/.prettierignore b/.prettierignore index bf4fb70..28c99dd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ node_modules build yarn.lock +coverage diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..0a72520 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..88de8f2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - 12 +before_script: + - yarn +script: + - yarn check:all diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1cb7ff6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 YeonJuan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8229282..7d4a6e3 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ # parse-git-diff + +## Installation + +```bash +npm install parse-git-diff +``` + +## Usage + +```js +import parseGitDiff from 'parse-git-diff'; +parseGitDiff('...'); +``` + +## License + +- [MIT](./LICENSE) diff --git a/jest.config.js b/jest.config.js index 21a1e97..e86e13b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ module.exports = { - preset: "ts-jest", - testEnvironment: "node", + preset: 'ts-jest', + testEnvironment: 'node', }; diff --git a/package.json b/package.json index f3f240d..bdb688e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "parse-git-diff", - "version": "0.0.1", - "description": "", + "version": "0.0.4", + "description": "Parse git diff", "main": "build/index.js", + "types": "build/index.d.ts", "scripts": { + "prepublish": "yarn build", "build": "rimraf build && tsc", "format": "prettier . --write", "test": "jest", - "postinstall": "yarn husky install", "check:all": "prettier --check . && tsc --noEmit && yarn test" }, "repository": { @@ -28,5 +29,16 @@ "rimraf": "^3.0.2", "ts-jest": "^27.0.5", "typescript": "^4.4.3" - } + }, + "files": [ + "build", + "tsconfig.json", + "README.md", + "yarn.lock", + "package.json" + ], + "keywords": [ + "git", + "git diff" + ] } diff --git a/src/__fixtures__/delete-line-diff b/src/__fixtures__/delete-line-diff new file mode 100644 index 0000000..c298f39 --- /dev/null +++ b/src/__fixtures__/delete-line-diff @@ -0,0 +1,7 @@ +diff --git a/rename.md b/rename.md +index 0e05564..aa39060 100644 +--- a/rename.md ++++ b/rename.md +@@ -1,2 +1 @@ + newfile +-newline \ No newline at end of file diff --git a/src/__fixtures__/deleted-file-diff b/src/__fixtures__/deleted-file-diff new file mode 100644 index 0000000..e214d78 --- /dev/null +++ b/src/__fixtures__/deleted-file-diff @@ -0,0 +1,7 @@ +diff --git a/newfile.md b/newfile.md +deleted file mode 100644 +index aa39060..0000000 +--- a/newfile.md ++++ /dev/null +@@ -1 +0,0 @@ +-newfile \ No newline at end of file diff --git a/src/__fixtures__/new-file-diff b/src/__fixtures__/new-file-diff new file mode 100644 index 0000000..5b52f6a --- /dev/null +++ b/src/__fixtures__/new-file-diff @@ -0,0 +1,7 @@ +diff --git a/newfile.md b/newfile.md +new file mode 100644 +index 0000000..aa39060 +--- /dev/null ++++ b/newfile.md +@@ -0,0 +1 @@ ++newfile \ No newline at end of file diff --git a/src/__fixtures__/new-line-diff b/src/__fixtures__/new-line-diff new file mode 100644 index 0000000..cb376d5 --- /dev/null +++ b/src/__fixtures__/new-line-diff @@ -0,0 +1,7 @@ +diff --git a/rename.md b/rename.md +index aa39060..0e05564 100644 +--- a/rename.md ++++ b/rename.md +@@ -1 +1,2 @@ + newfile ++newline \ No newline at end of file diff --git a/src/__fixtures__/rename-file-diff b/src/__fixtures__/rename-file-diff new file mode 100644 index 0000000..63e2d7f --- /dev/null +++ b/src/__fixtures__/rename-file-diff @@ -0,0 +1,4 @@ +diff --git a/newfile.md b/rename.md +similarity index 100% +rename from newfile.md +rename to rename.md \ No newline at end of file diff --git a/src/__tests__/parse-git-diff.test.ts b/src/__tests__/parse-git-diff.test.ts deleted file mode 100644 index 6991cb1..0000000 --- a/src/__tests__/parse-git-diff.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import parseGitDiff from "../parse-git-diff"; - -describe("parse-git-diff", () => { - test("test", () => { - expect(parseGitDiff("a")).toBe("a"); - }); -}); diff --git a/src/index.ts b/src/index.ts index e69de29..b484a9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,2 @@ +import parseGitDiff from './parser/parse-git-diff'; +export = parseGitDiff; diff --git a/src/parse-git-diff.ts b/src/parse-git-diff.ts deleted file mode 100644 index f7599a9..0000000 --- a/src/parse-git-diff.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function parseGitDiff(diff: string): string { - return diff; -} diff --git a/src/parser/__snapshots__/parse-change-markers.test.ts.snap b/src/parser/__snapshots__/parse-change-markers.test.ts.snap new file mode 100644 index 0000000..4735987 --- /dev/null +++ b/src/parser/__snapshots__/parse-change-markers.test.ts.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseChangeMarkers should parse change markers. 1`] = ` +Object { + "added": "src/tests/addition.test.ts", + "deleted": "src/tests/addition.test.ts", +} +`; diff --git a/src/parser/__snapshots__/parse-changes.test.ts.snap b/src/parser/__snapshots__/parse-changes.test.ts.snap new file mode 100644 index 0000000..2059f74 --- /dev/null +++ b/src/parser/__snapshots__/parse-changes.test.ts.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseChanges should parse changes. 1`] = ` +Array [ + Object { + "content": "unchanged", + "lineAfter": 1, + "lineBefore": 1, + "type": "UnchangedLine", + }, + Object { + "content": "-deleted line", + "lineAfter": 2, + "lineBefore": 2, + "type": "UnchangedLine", + }, + Object { + "content": "+added line", + "lineAfter": 3, + "lineBefore": 3, + "type": "UnchangedLine", + }, + Object { + "content": "unchanged", + "lineAfter": 4, + "lineBefore": 4, + "type": "UnchangedLine", + }, +] +`; diff --git a/src/parser/__snapshots__/parse-chunk-header.test.ts.snap b/src/parser/__snapshots__/parse-chunk-header.test.ts.snap new file mode 100644 index 0000000..bdcee93 --- /dev/null +++ b/src/parser/__snapshots__/parse-chunk-header.test.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseChunkHeader should parse chunk header. 1`] = ` +Object { + "rangeAfter": Object { + "lines": 4, + "start": 3, + }, + "rangeBefore": Object { + "lines": 71, + "start": 3, + }, +} +`; + +exports[`parseChunkHeader should parse concise chunk header 1`] = ` +Object { + "rangeAfter": Object { + "lines": 1, + "start": 1, + }, + "rangeBefore": Object { + "lines": 1, + "start": 1, + }, +} +`; + +exports[`parseChunkHeader should parse normal chunk header. 1`] = ` +Object { + "rangeAfter": Object { + "lines": 4, + "start": 3, + }, + "rangeBefore": Object { + "lines": 71, + "start": 3, + }, +} +`; diff --git a/src/parser/__snapshots__/parse-chunks.test.ts.snap b/src/parser/__snapshots__/parse-chunks.test.ts.snap new file mode 100644 index 0000000..96ebb33 --- /dev/null +++ b/src/parser/__snapshots__/parse-chunks.test.ts.snap @@ -0,0 +1,343 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseChunks should parse chunks 2. 1`] = ` +Array [ + Object { + "changes": Array [ + Object { + "content": "/** changed file types */", + "lineAfter": 18, + "lineBefore": 18, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 19, + "lineBefore": 19, + "type": "UnchangedLine", + }, + Object { + "content": "interface BaseFileChange extends Base {", + "lineAfter": 20, + "lineBefore": 20, + "type": "UnchangedLine", + }, + Object { + "content": " hunks: Hunk[];", + "lineBefore": 21, + "type": "DeletedLine", + }, + Object { + "content": " chunks: Chunk[];", + "lineAfter": 21, + "type": "AddedLine", + }, + Object { + "content": "}", + "lineAfter": 22, + "lineBefore": 22, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 23, + "lineBefore": 23, + "type": "UnchangedLine", + }, + Object { + "content": "export interface ChangedFile extends BaseFileChange<'ChangedFile'> {}", + "lineAfter": 24, + "lineBefore": 24, + "type": "UnchangedLine", + }, + ], + "rangeAfter": Object { + "lines": 7, + "start": 18, + }, + "rangeBefore": Object { + "lines": 7, + "start": 18, + }, + "type": "Chunk", + }, + Object { + "changes": Array [ + Object { + "content": "", + "lineAfter": 40, + "lineBefore": 40, + "type": "UnchangedLine", + }, + Object { + "content": "/** hunk */", + "lineAfter": 41, + "lineBefore": 41, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 42, + "lineBefore": 42, + "type": "UnchangedLine", + }, + Object { + "content": "export interface HunkPos {", + "lineBefore": 43, + "type": "DeletedLine", + }, + Object { + "content": "export interface ChunkPos {", + "lineAfter": 43, + "type": "AddedLine", + }, + Object { + "content": " start: number;", + "lineAfter": 44, + "lineBefore": 44, + "type": "UnchangedLine", + }, + Object { + "content": " lines: number;", + "lineAfter": 45, + "lineBefore": 45, + "type": "UnchangedLine", + }, + Object { + "content": "}", + "lineAfter": 46, + "lineBefore": 46, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 47, + "lineBefore": 47, + "type": "UnchangedLine", + }, + Object { + "content": "export interface Hunk extends Base<'Hunk'> {", + "lineBefore": 48, + "type": "DeletedLine", + }, + Object { + "content": " addedPos: HunkPos;", + "lineBefore": 49, + "type": "DeletedLine", + }, + Object { + "content": " deletedPos: HunkPos;", + "lineBefore": 50, + "type": "DeletedLine", + }, + Object { + "content": "export interface Chunk extends Base<'Hunk'> {", + "lineAfter": 48, + "type": "AddedLine", + }, + Object { + "content": " addedPos: ChunkPos;", + "lineAfter": 49, + "type": "AddedLine", + }, + Object { + "content": " deletedPos: ChunkPos;", + "lineAfter": 50, + "type": "AddedLine", + }, + Object { + "content": " changes: AnyChange[];", + "lineAfter": 51, + "lineBefore": 51, + "type": "UnchangedLine", + }, + Object { + "content": "}", + "lineAfter": 52, + "lineBefore": 52, + "type": "UnchangedLine", + }, + ], + "rangeAfter": Object { + "lines": 13, + "start": 40, + }, + "rangeBefore": Object { + "lines": 13, + "start": 40, + }, + "type": "Chunk", + }, +] +`; + +exports[`parseChunks should parse chunks. 1`] = ` +Array [ + Object { + "changes": Array [ + Object { + "content": "/** changed file types */", + "lineAfter": 18, + "lineBefore": 18, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 19, + "lineBefore": 19, + "type": "UnchangedLine", + }, + Object { + "content": "interface BaseFileChange extends Base {", + "lineAfter": 20, + "lineBefore": 20, + "type": "UnchangedLine", + }, + Object { + "content": " hunks: Hunk[];", + "lineBefore": 21, + "type": "DeletedLine", + }, + Object { + "content": " chunks: Chunk[];", + "lineAfter": 21, + "type": "AddedLine", + }, + Object { + "content": "}", + "lineAfter": 22, + "lineBefore": 22, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 23, + "lineBefore": 23, + "type": "UnchangedLine", + }, + Object { + "content": "export interface ChangedFile extends BaseFileChange<'ChangedFile'> {}", + "lineAfter": 24, + "lineBefore": 24, + "type": "UnchangedLine", + }, + ], + "rangeAfter": Object { + "lines": 7, + "start": 18, + }, + "rangeBefore": Object { + "lines": 7, + "start": 18, + }, + "type": "Chunk", + }, + Object { + "changes": Array [ + Object { + "content": "", + "lineAfter": 40, + "lineBefore": 40, + "type": "UnchangedLine", + }, + Object { + "content": "/** hunk */", + "lineAfter": 41, + "lineBefore": 41, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 42, + "lineBefore": 42, + "type": "UnchangedLine", + }, + Object { + "content": "export interface HunkPos {", + "lineBefore": 43, + "type": "DeletedLine", + }, + Object { + "content": "export interface ChunkPos {", + "lineAfter": 43, + "type": "AddedLine", + }, + Object { + "content": " start: number;", + "lineAfter": 44, + "lineBefore": 44, + "type": "UnchangedLine", + }, + Object { + "content": " lines: number;", + "lineAfter": 45, + "lineBefore": 45, + "type": "UnchangedLine", + }, + Object { + "content": "}", + "lineAfter": 46, + "lineBefore": 46, + "type": "UnchangedLine", + }, + Object { + "content": "", + "lineAfter": 47, + "lineBefore": 47, + "type": "UnchangedLine", + }, + Object { + "content": "export interface Hunk extends Base<'Hunk'> {", + "lineBefore": 48, + "type": "DeletedLine", + }, + Object { + "content": " addedPos: HunkPos;", + "lineBefore": 49, + "type": "DeletedLine", + }, + Object { + "content": " deletedPos: HunkPos;", + "lineBefore": 50, + "type": "DeletedLine", + }, + Object { + "content": "export interface Chunk extends Base<'Hunk'> {", + "lineAfter": 48, + "type": "AddedLine", + }, + Object { + "content": " addedPos: ChunkPos;", + "lineAfter": 49, + "type": "AddedLine", + }, + Object { + "content": " deletedPos: ChunkPos;", + "lineAfter": 50, + "type": "AddedLine", + }, + Object { + "content": " changes: AnyChange[];", + "lineAfter": 51, + "lineBefore": 51, + "type": "UnchangedLine", + }, + Object { + "content": "}", + "lineAfter": 52, + "lineBefore": 52, + "type": "UnchangedLine", + }, + ], + "rangeAfter": Object { + "lines": 13, + "start": 40, + }, + "rangeBefore": Object { + "lines": 13, + "start": 40, + }, + "type": "Chunk", + }, +] +`; diff --git a/src/parser/__snapshots__/parse-file-changes.test.ts.snap b/src/parser/__snapshots__/parse-file-changes.test.ts.snap new file mode 100644 index 0000000..98ac5b2 --- /dev/null +++ b/src/parser/__snapshots__/parse-file-changes.test.ts.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseChunkHeader should parse normal chunk header. 1`] = ` +Array [ + Object { + "chunks": Array [ + Object { + "changes": Array [ + Object { + "content": "node_modules", + "lineAfter": 1, + "lineBefore": 1, + "type": "UnchangedLine", + }, + Object { + "content": "build", + "lineAfter": 2, + "lineBefore": 2, + "type": "UnchangedLine", + }, + Object { + "content": "coverage", + "lineAfter": 3, + "type": "AddedLine", + }, + ], + "rangeAfter": Object { + "lines": 3, + "start": 1, + }, + "rangeBefore": Object { + "lines": 2, + "start": 1, + }, + "type": "Chunk", + }, + ], + "path": ".gitignore", + "type": "ChangedFile", + }, + Object { + "chunks": Array [ + Object { + "changes": Array [ + Object { + "content": "# parse-git-diff", + "lineBefore": 1, + "type": "DeletedLine", + }, + ], + "rangeAfter": Object { + "lines": 0, + "start": 0, + }, + "rangeBefore": Object { + "lines": 1, + "start": 1, + }, + "type": "Chunk", + }, + ], + "path": "README.md", + "type": "DeletedFile", + }, +] +`; diff --git a/src/parser/__snapshots__/parse-git-diff.test.ts.snap b/src/parser/__snapshots__/parse-git-diff.test.ts.snap new file mode 100644 index 0000000..65defb5 --- /dev/null +++ b/src/parser/__snapshots__/parse-git-diff.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseGitDiff deleted file 1`] = ` +Object { + "files": Array [], + "type": "GitDiff", +} +`; + +exports[`parseGitDiff new file. 1`] = ` +Object { + "files": Array [], + "type": "GitDiff", +} +`; + +exports[`parseGitDiff rename 1`] = ` +Object { + "files": Array [ + Object { + "chunks": Array [], + "pathAfter": "parse-git-diff-test/ts.json", + "pathBefore": "parse-git-diff-test/tsconfig.json", + "type": "RenamedFile", + }, + ], + "type": "GitDiff", +} +`; diff --git a/src/parser/context.ts b/src/parser/context.ts new file mode 100644 index 0000000..055d92c --- /dev/null +++ b/src/parser/context.ts @@ -0,0 +1,33 @@ +export default class Context { + private line: number = 1; + private lines: string[] = []; + public constructor(diff: string) { + this.lines = diff.split('\n'); + } + + public getCurLine(): string { + return this.lines[this.getInternalIndex()]; + } + + public getCurLineIndex(): number { + return this.line; + } + + public nextLine(): string | undefined { + this.line++; + return this.getCurLine(); + } + + public eatChars(to: number) { + const line = this.getCurLine().slice(to); + this.lines[this.getInternalIndex()] = line; + } + + public isEof(): boolean { + return this.line > this.lines.length; + } + + private getInternalIndex() { + return this.line - 1; + } +} diff --git a/src/parser/index.ts b/src/parser/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/parser/parse-change-markers.test.ts b/src/parser/parse-change-markers.test.ts new file mode 100644 index 0000000..63d8dec --- /dev/null +++ b/src/parser/parse-change-markers.test.ts @@ -0,0 +1,16 @@ +import { createContext } from '../test-utils'; +import parseChangeMarkers from './parse-change-markers'; + +describe('parseChangeMarkers', () => { + it('should parse change markers.', () => { + // prettier-ignore + const src = +`--- a/src/tests/addition.test.ts ++++ b/src/tests/addition.test.ts`; + + const result = parseChangeMarkers(createContext(src)); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/parser/parse-change-markers.ts b/src/parser/parse-change-markers.ts new file mode 100644 index 0000000..6c4b189 --- /dev/null +++ b/src/parser/parse-change-markers.ts @@ -0,0 +1,19 @@ +import type Context from './context'; + +export default function parseChangeMarkers(context: Context): { + deleted: string; + added: string; +} | null { + const deleted = parseMarker(context, '--- ')?.replace('a/', ''); + const added = parseMarker(context, '+++ ')?.replace('b/', ''); + return added && deleted ? { added, deleted } : null; +} + +function parseMarker(context: Context, marker: string): string | null { + const line = context.getCurLine(); + if (line?.startsWith(marker)) { + context.nextLine(); + return line.replace(marker, ''); + } + return null; +} diff --git a/src/parser/parse-changes.test.ts b/src/parser/parse-changes.test.ts new file mode 100644 index 0000000..d53fbb5 --- /dev/null +++ b/src/parser/parse-changes.test.ts @@ -0,0 +1,28 @@ +import { createContext } from '../test-utils'; +import parseChanges from './parse-changes'; + +describe('parseChanges', () => { + it('should parse changes.', () => { + // prettier-ignore + const src = +` unchanged + -deleted line + +added line + unchanged`; + + const result = parseChanges( + createContext(src), + { + lines: 4, + start: 1, + }, + { + lines: 4, + start: 1, + } + ); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/parser/parse-changes.ts b/src/parser/parse-changes.ts new file mode 100644 index 0000000..9879004 --- /dev/null +++ b/src/parser/parse-changes.ts @@ -0,0 +1,65 @@ +import type Context from './context'; +import type { AnyLineChange, ChunkRange } from '../types'; + +type LineType = AnyLineChange['type']; + +const CHAR_TYPE_MAP: Record = { + '+': 'AddedLine', + '-': 'DeletedLine', + ' ': 'UnchangedLine', +}; + +export default function parseChanges( + ctx: Context, + rangeBefore: ChunkRange, + rangeAfter: ChunkRange +): AnyLineChange[] { + const changes: AnyLineChange[] = []; + let lineBefore = rangeBefore.start; + let lineAfter = rangeAfter.start; + + while (!ctx.isEof()) { + const line = ctx.getCurLine()!; + const type = getLineType(line); + if (!type) { + break; + } + ctx.nextLine(); + + let change: AnyLineChange; + const content = line.slice(1); + switch (type) { + case 'AddedLine': { + change = { + type: 'AddedLine', + lineAfter: lineAfter++, + content, + }; + break; + } + case 'DeletedLine': { + change = { + type: 'DeletedLine', + lineBefore: lineBefore++, + content, + }; + break; + } + case 'UnchangedLine': { + change = { + type: 'UnchangedLine', + lineBefore: lineBefore++, + lineAfter: lineAfter++, + content, + }; + break; + } + } + changes.push(change); + } + return changes; +} + +function getLineType(line: string): LineType | null { + return CHAR_TYPE_MAP[line[0]] || null; +} diff --git a/src/parser/parse-chunk-header.test.ts b/src/parser/parse-chunk-header.test.ts new file mode 100644 index 0000000..dfb1e06 --- /dev/null +++ b/src/parser/parse-chunk-header.test.ts @@ -0,0 +1,29 @@ +import { createContext } from '../test-utils'; +import parseChunkHeader from './parse-chunk-header'; + +describe('parseChunkHeader', () => { + it('should parse normal chunk header.', () => { + const src = `@@ -3,71 +3,4 @@`; + const result = parseChunkHeader(createContext(src)); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); + + it('should parse chunk header.', () => { + const src = `@@ -3,71 +3,4 @@ unchanged line`; + const context = createContext(src); + const result = parseChunkHeader(context); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); + + it('should parse concise chunk header', () => { + const src = `@@ -1 +1 @@`; + const result = parseChunkHeader(createContext(src)); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/parser/parse-chunk-header.ts b/src/parser/parse-chunk-header.ts new file mode 100644 index 0000000..c443f07 --- /dev/null +++ b/src/parser/parse-chunk-header.ts @@ -0,0 +1,26 @@ +import type { Chunk } from '../types'; +import type Context from './context'; + +export default function parseChunkHeader( + ctx: Context +): Pick | null { + const line = ctx.getCurLine(); + const exec = /^@@\s\-(\d+),?(\d+)?\s\+(\d+),?(\d+)?\s@@/.exec(line); + if (!exec) { + return null; + } + const [all, delStart, delLines, addStart, addLines] = exec; + ctx.nextLine(); + return { + rangeAfter: getRange(addStart, addLines), + rangeBefore: getRange(delStart, delLines), + }; +} + +function getRange(start: string, lines?: string) { + const startNum = parseInt(start, 10); + return { + start: startNum, + lines: lines === undefined ? startNum : parseInt(lines, 10), + }; +} diff --git a/src/parser/parse-chunks.test.ts b/src/parser/parse-chunks.test.ts new file mode 100644 index 0000000..3e6df68 --- /dev/null +++ b/src/parser/parse-chunks.test.ts @@ -0,0 +1,76 @@ +import { createContext } from '../test-utils'; +import parseChunks from './parse-chunks'; + +describe('parseChunks', () => { + it('should parse chunks.', () => { + // prettier-ignore + const src = +`@@ -18,7 +18,7 @@ export type AnyChange = Added | Deleted | Unchanged; + /** changed file types */ + + interface BaseFileChange extends Base { +- hunks: Hunk[]; ++ chunks: Chunk[]; + } + + export interface ChangedFile extends BaseFileChange<'ChangedFile'> {} +@@ -40,13 +40,13 @@ export type AnyFileChange = ChangedFile | AddedFile | DeletedFile | RenamedFile; + + /** hunk */ + +-export interface HunkPos { ++export interface ChunkPos { + start: number; + lines: number; + } + +-export interface Hunk extends Base<'Hunk'> { +- addedPos: HunkPos; +- deletedPos: HunkPos; ++export interface Chunk extends Base<'Hunk'> { ++ addedPos: ChunkPos; ++ deletedPos: ChunkPos; + changes: AnyChange[]; + }`; + const result = parseChunks(createContext(src)); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); + + it('should parse chunks 2.', () => { + // prettier-ignore + const src = +`@@ -18,7 +18,7 @@ + /** changed file types */ + + interface BaseFileChange extends Base { +- hunks: Hunk[]; ++ chunks: Chunk[]; + } + + export interface ChangedFile extends BaseFileChange<'ChangedFile'> {} +@@ -40,13 +40,13 @@ export type AnyFileChange = ChangedFile | AddedFile | DeletedFile | RenamedFile; + + /** hunk */ + +-export interface HunkPos { ++export interface ChunkPos { + start: number; + lines: number; + } + +-export interface Hunk extends Base<'Hunk'> { +- addedPos: HunkPos; +- deletedPos: HunkPos; ++export interface Chunk extends Base<'Hunk'> { ++ addedPos: ChunkPos; ++ deletedPos: ChunkPos; + changes: AnyChange[]; + }`; + const result = parseChunks(createContext(src)); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/parser/parse-chunks.ts b/src/parser/parse-chunks.ts new file mode 100644 index 0000000..ed29a7e --- /dev/null +++ b/src/parser/parse-chunks.ts @@ -0,0 +1,36 @@ +import type { AnyLineChange, Chunk } from '../types'; +import type Context from './context'; +import parseChanges from './parse-changes'; +import parseChunkHeader from './parse-chunk-header'; + +export default function parseChunks(context: Context): Chunk[] { + const chunks: Chunk[] = []; + + while (!context.isEof()) { + const chunk = parseChunk(context); + if (!chunk) { + break; + } + chunks.push(chunk); + } + return chunks; +} + +function parseChunk(context: Context): Chunk | undefined { + const chunkHeader = parseChunkHeader(context); + if (!chunkHeader) { + return; + } + + const changes: AnyLineChange[] = parseChanges( + context, + chunkHeader.rangeBefore, + chunkHeader.rangeAfter + ); + + return { + type: 'Chunk', + ...chunkHeader, + changes, + }; +} diff --git a/src/parser/parse-extended-header.ts b/src/parser/parse-extended-header.ts new file mode 100644 index 0000000..99985e2 --- /dev/null +++ b/src/parser/parse-extended-header.ts @@ -0,0 +1,50 @@ +import type Context from './context'; + +const UNHANDLED_EXTENDED_HEADERS = new Set([ + 'index', + 'old', + 'copy', + 'similarity', + 'dissimilarity', +]); + +const startsWith = (str: string, target: string) => { + return str.indexOf(target) === 0; +}; + +export default function parseExtendedHeader(ctx: Context) { + const line = ctx.getCurLine(); + const type = line.slice(0, line.indexOf(' ')); + + if (UNHANDLED_EXTENDED_HEADERS.has(type)) { + ctx.nextLine(); + return { + type: 'unhandled', + } as const; + } + if (startsWith(line, 'deleted ')) { + ctx.nextLine(); + return { + type: 'deleted', + } as const; + } else if (startsWith(line, 'new file ')) { + ctx.nextLine(); + return { + type: 'new file', + } as const; + } else if (startsWith(line, 'rename from ')) { + ctx.nextLine(); + return { + type: 'rename from', + path: line.slice('rename from '.length), + } as const; + } else if (startsWith(line, 'rename to ')) { + ctx.nextLine(); + return { + type: 'rename to', + path: line.slice('rename to '.length), + } as const; + } + + return null; +} diff --git a/src/parser/parse-file-changes.test.ts b/src/parser/parse-file-changes.test.ts new file mode 100644 index 0000000..4f0f3ed --- /dev/null +++ b/src/parser/parse-file-changes.test.ts @@ -0,0 +1,29 @@ +import { createContext } from '../test-utils'; +import parseFileChanges from './parse-file-changes'; + +describe('parseChunkHeader', () => { + it('should parse normal chunk header.', () => { + // prettier-ignore + const src = +`diff --git a/.gitignore b/.gitignore +index dd87e2d..1d2a37f 100644 +--- a/.gitignore ++++ b/.gitignore +@@ -1,2 +1,3 @@ + node_modules + build ++coverage +diff --git a/README.md b/README.md +deleted file mode 100644 +index 8229282..0000000 +--- a/README.md ++++ /dev/null +@@ -1 +0,0 @@ +-# parse-git-diff`; + + const result = parseFileChanges(createContext(src)); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/parser/parse-file-changes.ts b/src/parser/parse-file-changes.ts new file mode 100644 index 0000000..a3425ea --- /dev/null +++ b/src/parser/parse-file-changes.ts @@ -0,0 +1,81 @@ +import type { AnyFileChange } from '../types'; +import type Context from './context'; +import parseExtendedHeader from './parse-extended-header'; +import parseChunks from './parse-chunks'; +import parseChangeMarkers from './parse-change-markers'; + +export default function parseFileChanges(ctx: Context): AnyFileChange[] { + const changedFiles: AnyFileChange[] = []; + while (!ctx.isEof()) { + const changed = parseFileChange(ctx); + if (!changed) { + break; + } + changedFiles.push(changed); + } + return changedFiles; +} + +function parseFileChange(ctx: Context): AnyFileChange | undefined { + if (!isComparisonInputLine(ctx.getCurLine())) { + return; + } + ctx.nextLine(); + + let isDeleted = false; + let isNew = false; + let isRename = false; + let pathBefore = ''; + let pathAfter = ''; + while (!ctx.isEof()) { + const extHeader = parseExtendedHeader(ctx); + if (!extHeader) { + break; + } + if (extHeader.type === 'deleted') isDeleted = true; + if (extHeader.type === 'new file') isNew = true; + if (extHeader.type === 'rename from') { + isRename = true; + pathBefore = extHeader.path; + } + if (extHeader.type === 'rename to') { + isRename = true; + pathAfter = extHeader.path; + } + } + + const changeMarkers = parseChangeMarkers(ctx); + const chunks = parseChunks(ctx); + + if (isDeleted && changeMarkers) { + return { + type: 'DeletedFile', + chunks, + path: changeMarkers.deleted, + }; + } else if (isNew && changeMarkers) { + return { + type: 'AddedFile', + chunks, + path: changeMarkers.added, + }; + } else if (isRename) { + return { + type: 'RenamedFile', + pathAfter, + pathBefore, + chunks, + }; + } else if (changeMarkers) { + return { + type: 'ChangedFile', + chunks, + path: changeMarkers.added, + }; + } + return; +} + +function isComparisonInputLine(line: string): boolean { + return line.indexOf('diff --git') === 0; +} diff --git a/src/parser/parse-git-diff.test.ts b/src/parser/parse-git-diff.test.ts new file mode 100644 index 0000000..591431a --- /dev/null +++ b/src/parser/parse-git-diff.test.ts @@ -0,0 +1,81 @@ +import { createContext } from '../test-utils'; +import parseGitDiff from './parse-git-diff'; + +describe('parseGitDiff', () => { + it('new file.', () => { + // prettier-ignore + const src = + `diff --git a/parse-git-diff-test/packages.json b/parse-git-diff-test/packages.json + new file mode 100644 + index 0000000..5515040 + --- /dev/null + +++ b/parse-git-diff-test/packages.json + @@ -0,0 +1,17 @@ + +{ + + "name": "parse-git-diff-test", + + "version": "1.0.0", + + "description": "", + + "main": "index.js", + + "scripts": { + + "build": "tsc" + + }, + + "author": "", + + "license": "ISC", + + "dependencies": { + + "parse-git-diff": "0.0.3" + + }, + + "devDependencies": { + + "typescript": "^4.4.4" + + } + +}`; + + const result = parseGitDiff(src); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); + + it('deleted file', () => { + // prettier-ignore + const src = + `diff --git a/parse-git-diff-test/package.json b/parse-git-diff-test/package.json + deleted file mode 100644 + index 5515040..0000000 + --- a/parse-git-diff-test/package.json + +++ /dev/null + @@ -1,17 +0,0 @@ + -{ + - "name": "parse-git-diff-test", + - "version": "1.0.0", + - "description": "", + - "main": "index.js", + - "scripts": { + - "build": "tsc" + - }, + - "author": "", + - "license": "ISC", + - "dependencies": { + - "parse-git-diff": "0.0.3" + - }, + - "devDependencies": { + - "typescript": "^4.4.4" + - } + -}`; + const result = parseGitDiff(src); + + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); + + it('rename', () => { + // prettier-ignore + const src = +`diff --git a/parse-git-diff-test/tsconfig.json b/parse-git-diff-test/ts.json +similarity index 100% +rename from parse-git-diff-test/tsconfig.json +rename to parse-git-diff-test/ts.json`; + const result = parseGitDiff(src); + expect(result).not.toBe(null); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/parser/parse-git-diff.ts b/src/parser/parse-git-diff.ts new file mode 100644 index 0000000..3f5498c --- /dev/null +++ b/src/parser/parse-git-diff.ts @@ -0,0 +1,13 @@ +import Context from './context'; +import type { GitDiff } from '../types'; +import parseFileChanges from './parse-file-changes'; + +export default function parseGitDiff(diff: string): GitDiff { + const ctx = new Context(diff); + const files = parseFileChanges(ctx); + + return { + type: 'GitDiff', + files, + }; +} diff --git a/src/parser/utils.ts b/src/parser/utils.ts new file mode 100644 index 0000000..abf85c4 --- /dev/null +++ b/src/parser/utils.ts @@ -0,0 +1,75 @@ +import type * as t from '../types'; + +/** + * Checks whether a line is a comparison input line or not. + * @param {string} line The line to check. + * @returns {boolean} Returns `true` if the given line is a comparison line, otherwise `false`. + * @example + * // comparison input line. + * "diff --git a/file.txt b/file.txt" + */ +export function isComparisonInputLine(line: string): boolean { + const [diff, doubleDashGit, fileA, fileB, rest] = line.split(' '); + return !!( + diff === 'diff' && + doubleDashGit === '--git' && + fileA && + fileB && + !rest + ); +} + +/** + * Checks whether a line is a meta data line or not. + * @param line The line to check. + * @returns {boolean} Return `true` if the given line is a meta data line, otherwise `false`. + * @example + * // meta data line. + * "index 6b0c6cf..b37e70a 100644" + */ +export function isMetaDataLine(line: string): boolean { + return /^index/.test(line); +} + +export function isAddMarkerLine(line: string): boolean { + return /^\+\+\+\s/.test(line); +} + +export function isDeleteMarkerLine(line: string): boolean { + return /^\-\-\-\s/.test(line); +} + +/** + * Checks whether the line is a chunk header or not + * @param line The line to check. + * @returns return `true` if the given line is a chunk header, otherwise `false`. + * @example + * // start line of diff chunks + * "@@ -1 +1 @@ describe('utils', () => {..." + * "@@ -23,15 +23,15 @@ describe('utils', () => { ..." + */ +export function isChunkHeader(line: string): boolean { + return /^@@\s\-\d+(,\d+)?\s\+\d+(,\d+)?\s@@\s?/.test(line); +} // test + +/** + * Checks whether the line is an addition line. + * @param {string} line The line to check. + * @returns {boolean} Return `true` if the given line is an addition line, otherwise `false`. + */ +export function isAdditionLine(line: string): boolean { + return line[0] === '+'; +} + +/** + * Checks whether the line is an deletion line. + * @param {string} line The line to check. + * @returns {boolean} Return `true` if the given line is an deletion line, otherwise `false`. + */ +export function isDeletionLine(line: string): boolean { + return line[0] === '-'; +} + +export function isUnchangedLine(line: string): boolean { + return line[0] === ' '; +} diff --git a/src/test-utils.ts b/src/test-utils.ts new file mode 100644 index 0000000..2a44dc8 --- /dev/null +++ b/src/test-utils.ts @@ -0,0 +1,30 @@ +import Context from './parser/context'; + +type Validator = (...args: Args) => boolean; + +function createBooleanTester( + validator: V, + result: boolean +) { + return function validTester(cases: Parameters[]): void { + cases.forEach((c) => { + expect(validator(...c)).toBe(result); + }); + }; +} + +export function createValidTester(validator: V) { + return createBooleanTester(validator, true); +} + +export function createInvalidTester(validator: V) { + return createBooleanTester(validator, false); +} + +export function createContext(diff: string, initial: number = 1) { + const context = new Context(diff); + for (let i = 0; i < initial - 1; i++) { + context.nextLine(); + } + return context; +} diff --git a/src/types/changes.ts b/src/types/changes.ts new file mode 100644 index 0000000..215d847 --- /dev/null +++ b/src/types/changes.ts @@ -0,0 +1,60 @@ +import type { Base } from './common'; + +/** changed content types */ + +interface BaseChange extends Base { + content: string; +} + +export interface AddedLine extends BaseChange<'AddedLine'> { + lineAfter: number; +} + +export interface DeletedLine extends BaseChange<'DeletedLine'> { + lineBefore: number; +} + +export interface UnchangedLine extends BaseChange<'UnchangedLine'> { + lineBefore: number; + lineAfter: number; +} + +export type AnyLineChange = AddedLine | DeletedLine | UnchangedLine; + +/** changed file types */ + +interface BaseFileChange extends Base { + chunks: Chunk[]; +} + +export interface ChangedFile extends BaseFileChange<'ChangedFile'> { + path: string; +} + +export interface AddedFile extends BaseFileChange<'AddedFile'> { + path: string; +} + +export interface DeletedFile extends BaseFileChange<'DeletedFile'> { + path: string; +} + +export interface RenamedFile extends BaseFileChange<'RenamedFile'> { + pathBefore: string; + pathAfter: string; +} + +export type AnyFileChange = ChangedFile | AddedFile | DeletedFile | RenamedFile; + +/** hunk */ + +export interface ChunkRange { + start: number; + lines: number; +} + +export interface Chunk extends Base<'Chunk'> { + rangeBefore: ChunkRange; + rangeAfter: ChunkRange; + changes: AnyLineChange[]; +} diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..edb149e --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,4 @@ +// +export interface Base { + readonly type: Type; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..b3f950e --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './nodes'; +export * from './changes'; diff --git a/src/types/nodes.ts b/src/types/nodes.ts new file mode 100644 index 0000000..59c756c --- /dev/null +++ b/src/types/nodes.ts @@ -0,0 +1,6 @@ +import type { Base } from './common'; +import type { AnyFileChange } from './changes'; + +export interface GitDiff extends Base<'GitDiff'> { + files: AnyFileChange[]; +} diff --git a/tsconfig.json b/tsconfig.json index ebf30ce..9c9c250 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "outDir": "build", "rootDir": "src" }, - "exclude": ["src/**/*.test.ts", "build"] + "exclude": ["src/**/*.test.ts", "build", "node_modules"] }