├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── checks.yml │ └── depsbot.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── mod.ts ├── parser.test.ts ├── parser.ts ├── regex.ts └── test_deps.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: denosaurs 2 | github: denosaurs 3 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout sources 10 | uses: actions/checkout@v2 11 | 12 | - name: Setup latest deno version 13 | uses: denolib/setup-deno@v2 14 | with: 15 | deno-version: v1.x 16 | 17 | - name: Run deno fmt 18 | run: deno fmt --check 19 | 20 | - name: Run deno lint 21 | run: deno lint --unstable 22 | 23 | test: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout sources 27 | uses: actions/checkout@v2 28 | 29 | - name: Setup latest deno version 30 | uses: denolib/setup-deno@v2 31 | with: 32 | deno-version: v1.x 33 | 34 | - name: Run deno test 35 | run: deno test 36 | -------------------------------------------------------------------------------- /.github/workflows/depsbot.yml: -------------------------------------------------------------------------------- 1 | name: depsbot 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: "0 0 */2 * *" 10 | 11 | jobs: 12 | run: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Run depsbot 19 | uses: denosaurs/depsbot@master 20 | with: 21 | github_token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS files 2 | .DS_Store 3 | .cache 4 | 5 | # IDE 6 | .vscode 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog], 6 | and this project adheres to [Semantic Versioning]. 7 | 8 | ## [0.1.5] - 2020-09-19 9 | 10 | ### Bug Fixes 11 | 12 | - import type in regex.ts ([`48d9ec1`]) 13 | 14 | ## [0.1.4] - 2020-09-19 15 | 16 | ## [0.1.3] - 2020-08-27 17 | 18 | ### Features 19 | 20 | - export all types ([`df5dd3b`]) 21 | 22 | ## [0.1.2] - 2020-08-27 23 | 24 | ### Bug Fixes 25 | 26 | - remove debug call ([`a09d344`]) 27 | 28 | ## [0.1.1] - 2020-08-27 29 | 30 | ## [0.1.0] - 2020-08-27 31 | 32 | ### Features 33 | 34 | - parse cc spec ([`92e337f`]) 35 | 36 | [keep a changelog]: https://keepachangelog.com/en/1.0.0/ 37 | [semantic versioning]: https://semver.org/spec/v2.0.0.html 38 | [0.1.5]: https://github.com/denosaurs/commit/compare/0.1.4...0.1.5 39 | [`48d9ec1`]: https://github.com/denosaurs/commit/commit/48d9ec13c10c8ccbce73e477884fa30aade07c1b 40 | [0.1.4]: https://github.com/denosaurs/commit/compare/0.1.3...0.1.4 41 | [0.1.3]: https://github.com/denosaurs/commit/compare/0.1.2...0.1.3 42 | [`df5dd3b`]: https://github.com/denosaurs/commit/commit/df5dd3b2c0da2247fb9a2d55bf269110dfe1e3ea 43 | [0.1.2]: https://github.com/denosaurs/commit/compare/0.1.1...0.1.2 44 | [`a09d344`]: https://github.com/denosaurs/commit/commit/a09d344e0f5563770ec30ec622ca3094c7ed54ab 45 | [0.1.1]: https://github.com/denosaurs/commit/compare/0.1.0...0.1.1 46 | [0.1.0]: https://github.com/denosaurs/commit/compare/0.1.0 47 | [`92e337f`]: https://github.com/denosaurs/commit/commit/92e337f48f62a5baa70558d43b5ed52b6a6931a2 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present the denosaurs team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # commit 2 | 3 | [![Tags](https://img.shields.io/github/release/denosaurs/commit)](https://github.com/denosaurs/commit/releases) 4 | [![CI Status](https://img.shields.io/github/workflow/status/denosaurs/commit/check)](https://github.com/denosaurs/commit/actions) 5 | [![License](https://img.shields.io/github/license/denosaurs/commit)](https://github.com/denosaurs/commit/blob/master/LICENSE) 6 | 7 | ```typescript 8 | import { parse } from "https://deno.land/x/commit/mod.ts"; 9 | 10 | const commit = parse("fix(std/io): utf-8 encoding"); 11 | console.log(commit); 12 | /* { 13 | type: "fix", 14 | scope: "std/io", 15 | subject: "utf-8 encoding", 16 | merge: null, 17 | header: "fix(std/io): utf-8 encoding", 18 | body: null, 19 | footer: null, 20 | notes: [], 21 | references: [], 22 | mentions: [], 23 | revert: null 24 | } */ 25 | ``` 26 | 27 | ## other 28 | 29 | ### contribution 30 | 31 | Pull request, issues and feedback are very welcome. Code style is formatted with deno fmt and commit messages are done following Conventional Commits spec. 32 | 33 | ### licence 34 | 35 | Copyright 2020-present, the denosaurs team. All rights reserved. MIT license. 36 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { parser } from "./parser.ts"; 2 | import { regex } from "./regex.ts"; 3 | 4 | export type Actions = string[] | null; 5 | export type Correspondence = string[] | null; 6 | export type Keywords = string[] | null; 7 | export type Pattern = RegExp | null; 8 | export type Prefixes = string[] | null; 9 | 10 | export interface Options { 11 | /** 12 | * Pattern to match merge headers. EG: branch merge, GitHub or GitLab like pull 13 | * requests headers. When a merge header is parsed, the next line is used for 14 | * conventional header parsing. 15 | * 16 | * For example, if we have a commit 17 | * 18 | * ```text 19 | * Merge pull request #1 from user/feature/feature-name 20 | * 21 | * feat(scope): broadcast $destroy event on scope destruction 22 | * ``` 23 | * 24 | * We can parse it with these options and the default headerPattern: 25 | * 26 | * ```javascript 27 | * { 28 | * mergePattern: /^Merge pull request #(\d+) from (.*)$/, 29 | * mergeCorrespondence: ['id', 'source'] 30 | * } 31 | * ``` 32 | * 33 | * @default 34 | * null 35 | */ 36 | mergePattern?: Pattern; 37 | 38 | /** 39 | * Used to define what capturing group of `mergePattern`. 40 | * 41 | * If it's a `string` it will be converted to an `array` separated by a comma. 42 | * 43 | * @default 44 | * null 45 | */ 46 | mergeCorrespondence?: Correspondence; 47 | 48 | /** 49 | * Used to match header pattern. 50 | * 51 | * @default 52 | * /^(\w*)(?:\(([\w\$\.\-\* ]*)\))?\: (.*)$/ 53 | */ 54 | headerPattern?: Pattern; 55 | 56 | /** 57 | * Used to define what capturing group of `headerPattern` captures what header 58 | * part. The order of the array should correspond to the order of 59 | * `headerPattern`'s capturing group. If the part is not captured it is `null`. 60 | * If it's a `string` it will be converted to an `array` separated by a comma. 61 | * 62 | * @default 63 | * ['type', 'scope', 'subject'] 64 | */ 65 | headerCorrespondence?: Correspondence; 66 | 67 | /** 68 | * Keywords to reference an issue. This value is case __insensitive__. If it's a 69 | * `string` it will be converted to an `array` separated by a comma. 70 | * 71 | * Set it to `null` to reference an issue without any action. 72 | * 73 | * @default 74 | * ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved'] 75 | */ 76 | referenceActions?: Actions; 77 | 78 | /** 79 | * The prefixes of an issue. EG: In `gh-123` `gh-` is the prefix. 80 | * 81 | * @default 82 | * ['#'] 83 | */ 84 | issuePrefixes?: Prefixes; 85 | 86 | /** 87 | * Used to define if `issuePrefixes` should be considered case sensitive. 88 | * 89 | * @default 90 | * false 91 | */ 92 | issuePrefixesCaseSensitive?: boolean; 93 | 94 | /** 95 | * Keywords for important notes. This value is case __insensitive__. If it's a 96 | * `string` it will be converted to an `array` separated by a comma. 97 | * 98 | * @default 99 | * ['BREAKING CHANGE'] 100 | */ 101 | noteKeywords?: Keywords; 102 | 103 | /** 104 | * Pattern to match other fields. 105 | * 106 | * @default 107 | * /^-(.*?)-$/ 108 | */ 109 | fieldPattern?: Pattern; 110 | 111 | /** 112 | * Pattern to match what this commit reverts. 113 | * 114 | * @default 115 | * /^Revert\s"([\s\S]*)"\s*This reverts commit (\w*)\./ 116 | */ 117 | revertPattern?: Pattern; 118 | 119 | /** 120 | * Used to define what capturing group of `revertPattern` captures what reverted 121 | * commit fields. The order of the array should correspond to the order of 122 | * `revertPattern`'s capturing group. 123 | * 124 | * For example, if we had commit 125 | * 126 | * ``` 127 | * Revert "throw an error if a callback is passed" 128 | * 129 | * This reverts commit 9bb4d6c. 130 | * ``` 131 | * 132 | * If configured correctly, the parsed result would be 133 | * 134 | * ``` 135 | * { 136 | * revert: { 137 | * header: 'throw an error if a callback is passed', 138 | * hash: '9bb4d6c' 139 | * } 140 | * } 141 | * ``` 142 | * 143 | * It implies that this commit reverts a commit with header `'throw an error if 144 | * a callback is passed'` and hash `'9bb4d6c'`. 145 | * 146 | * If it's a `string` it will be converted to an `array` separated by a comma. 147 | * 148 | * @default 149 | * ['header', 'hash'] 150 | */ 151 | revertCorrespondence?: Correspondence; 152 | 153 | /** 154 | * What commentChar to use. By default it is `null`, so no comments are stripped. 155 | * Set to `#` if you pass the contents of `.git/COMMIT_EDITMSG` directly. 156 | * 157 | * If you have configured the git commentchar via git config `core.commentchar` 158 | * you'll want to pass what you have set there. 159 | * 160 | * @default 161 | * null 162 | */ 163 | commentChar?: string | null; 164 | 165 | /** 166 | * What warn function to use. For example, `console.warn.bind(console)` or 167 | * `grunt.log.writeln`. By default, it's a noop. If it is `true`, it will error 168 | * if commit cannot be parsed (strict). 169 | * 170 | * @default 171 | * function () {} 172 | */ 173 | warn?: (message?: string) => void | boolean; 174 | } 175 | 176 | export type Commit< 177 | Fields extends string | number | symbol = string | number | symbol, 178 | > = CommitBase & { [Field in Exclude]?: Field }; 179 | 180 | export type Field = string | null; 181 | 182 | export interface Note { 183 | title: string; 184 | text: string; 185 | } 186 | 187 | export interface Reference { 188 | issue: string; 189 | 190 | /** 191 | * @default 192 | * null 193 | */ 194 | action: Field; 195 | 196 | /** 197 | * @default 198 | * null 199 | */ 200 | owner: Field; 201 | 202 | /** 203 | * @default 204 | * null 205 | */ 206 | repository: Field; 207 | 208 | prefix: string; 209 | raw: string; 210 | } 211 | 212 | export interface Revert { 213 | hash?: Field; 214 | header?: Field; 215 | [field: string]: Field | undefined; 216 | } 217 | 218 | export interface CommitBase { 219 | /** 220 | * @default 221 | * null 222 | */ 223 | merge: Field; 224 | 225 | /** 226 | * @default 227 | * null 228 | */ 229 | header: Field; 230 | 231 | /** 232 | * @default 233 | * null 234 | */ 235 | body: Field; 236 | 237 | /** 238 | * @default 239 | * null 240 | */ 241 | footer: Field; 242 | 243 | /** 244 | * @default 245 | * [] 246 | */ 247 | notes: Note[]; 248 | 249 | /** 250 | * @default 251 | * [] 252 | */ 253 | references: Reference[]; 254 | 255 | /** 256 | * @default 257 | * [] 258 | */ 259 | mentions: string[]; 260 | 261 | /** 262 | * @default 263 | * null 264 | */ 265 | revert: Revert | null; 266 | 267 | type?: Field; 268 | scope?: Field; 269 | subject?: Field; 270 | } 271 | 272 | function assignOpts(options?: Options): Required { 273 | options = Object.assign( 274 | { 275 | headerPattern: /^(\w*)(?:\(([\w$.\-*/ ]*)\))?: (.*)$/, 276 | headerCorrespondence: ["type", "scope", "subject"], 277 | referenceActions: [ 278 | "close", 279 | "closes", 280 | "closed", 281 | "fix", 282 | "fixes", 283 | "fixed", 284 | "resolve", 285 | "resolves", 286 | "resolved", 287 | ], 288 | issuePrefixes: ["#"], 289 | noteKeywords: ["BREAKING CHANGE"], 290 | fieldPattern: /^-(.*?)-$/, 291 | revertPattern: /^Revert\s"([\s\S]*)"\s*This reverts commit (\w*)\./, 292 | revertCorrespondence: ["header", "hash"], 293 | warn: function () {}, 294 | mergePattern: null, 295 | mergeCorrespondence: null, 296 | }, 297 | options, 298 | ); 299 | 300 | return options as Required; 301 | } 302 | 303 | /** 304 | * The sync version. Useful when parsing a single commit. Returns the result. 305 | * 306 | * @param commit A single commit to be parsed. 307 | * @param options Same as the `options` of `conventionalCommitsParser`. 308 | */ 309 | export function parse(commit?: string, options?: Options): Commit { 310 | options = assignOpts(options); 311 | const reg = regex(options); 312 | 313 | return parser(commit, options as Required, reg); 314 | } 315 | -------------------------------------------------------------------------------- /parser.test.ts: -------------------------------------------------------------------------------- 1 | import { parse, Options } from "./mod.ts"; 2 | 3 | import { assertEquals, assertThrows } from "./test_deps.ts"; 4 | 5 | const options: Options = { 6 | revertPattern: /^Revert\s"([\s\S]*)"\s*This reverts commit (.*)\.$/, 7 | revertCorrespondence: ["header", "hash"], 8 | fieldPattern: /^-(.*?)-$/, 9 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 10 | headerCorrespondence: ["type", "scope", "subject"], 11 | noteKeywords: ["BREAKING AMEND"], 12 | issuePrefixes: ["#", "gh-"], 13 | referenceActions: ["kill", "kills", "killed", "handle", "handles", "handled"], 14 | }; 15 | 16 | Deno.test({ 17 | name: "meta | expect raw commits", 18 | fn(): void { 19 | assertThrows(() => parse()); 20 | assertThrows(() => parse("")); 21 | assertThrows(() => parse(" \n ")); 22 | }, 23 | }); 24 | 25 | Deno.test({ 26 | name: "parse | trim extra lines", 27 | fn(): void { 28 | assertEquals( 29 | parse( 30 | "\n\n\n\n\n\n\nfeat(scope): broadcast $destroy event on scope destruction\n\n\n" + 31 | "\n\n\nperf testing shows that in chrome this change adds 5-15% overhead\n" + 32 | "\n\n\nwhen destroying 10k nested scopes where each scope has a $destroy listener\n\n" + 33 | "\n\n\n\nBREAKING AMEND: some breaking change\n" + 34 | "\n\n\n\nBREAKING AMEND: An awesome breaking change\n\n\n```\ncode here\n```" + 35 | "\n\nKills #1\n" + 36 | "\n\n\nkilled #25\n\n\n\n\n", 37 | options, 38 | ), 39 | { 40 | merge: null, 41 | header: "feat(scope): broadcast $destroy event on scope destruction", 42 | body: 43 | "perf testing shows that in chrome this change adds 5-15% overhead\n\n\n\nwhen destroying 10k nested scopes where each scope has a $destroy listener", 44 | footer: 45 | "BREAKING AMEND: some breaking change\n\n\n\n\nBREAKING AMEND: An awesome breaking change\n\n\n```\ncode here\n```\n\nKills #1\n\n\n\nkilled #25", 46 | notes: [ 47 | { 48 | title: "BREAKING AMEND", 49 | text: "some breaking change", 50 | }, 51 | { 52 | title: "BREAKING AMEND", 53 | text: "An awesome breaking change\n\n\n```\ncode here\n```", 54 | }, 55 | ], 56 | references: [ 57 | { 58 | action: "Kills", 59 | owner: null, 60 | repository: null, 61 | issue: "1", 62 | raw: "#1", 63 | prefix: "#", 64 | }, 65 | { 66 | action: "killed", 67 | owner: null, 68 | repository: null, 69 | issue: "25", 70 | raw: "#25", 71 | prefix: "#", 72 | }, 73 | ], 74 | mentions: [], 75 | revert: null, 76 | scope: "scope", 77 | subject: "broadcast $destroy event on scope destruction", 78 | type: "feat", 79 | }, 80 | ); 81 | }, 82 | }); 83 | 84 | Deno.test({ 85 | name: "parse | keep spaces", 86 | fn(): void { 87 | assertEquals( 88 | parse( 89 | " feat(scope): broadcast $destroy event on scope destruction \n" + 90 | " perf testing shows that in chrome this change adds 5-15% overhead \n\n" + 91 | " when destroying 10k nested scopes where each scope has a $destroy listener \n" + 92 | " BREAKING AMEND: some breaking change \n\n" + 93 | " BREAKING AMEND: An awesome breaking change\n\n\n```\ncode here\n```" + 94 | "\n\n Kills #1\n", 95 | options, 96 | ), 97 | { 98 | merge: null, 99 | header: " feat(scope): broadcast $destroy event on scope destruction ", 100 | body: 101 | " perf testing shows that in chrome this change adds 5-15% overhead \n\n when destroying 10k nested scopes where each scope has a $destroy listener ", 102 | footer: 103 | " BREAKING AMEND: some breaking change \n\n BREAKING AMEND: An awesome breaking change\n\n\n```\ncode here\n```\n\n Kills #1", 104 | notes: [ 105 | { 106 | title: "BREAKING AMEND", 107 | text: "some breaking change ", 108 | }, 109 | { 110 | title: "BREAKING AMEND", 111 | text: "An awesome breaking change\n\n\n```\ncode here\n```", 112 | }, 113 | ], 114 | references: [ 115 | { 116 | action: "Kills", 117 | owner: null, 118 | repository: null, 119 | issue: "1", 120 | raw: "#1", 121 | prefix: "#", 122 | }, 123 | ], 124 | mentions: [], 125 | revert: null, 126 | scope: null, 127 | subject: null, 128 | type: null, 129 | }, 130 | ); 131 | }, 132 | }); 133 | 134 | Deno.test({ 135 | name: "parse | ignore comments", 136 | fn(): void { 137 | var commentOptions = Object.assign({}, options, { commentChar: "#" }); 138 | assertEquals(parse("# comment", commentOptions), { 139 | merge: null, 140 | header: null, 141 | body: null, 142 | footer: null, 143 | notes: [], 144 | references: [], 145 | mentions: [], 146 | revert: null, 147 | scope: null, 148 | subject: null, 149 | type: null, 150 | }); 151 | assertEquals(parse(" # non-comment", commentOptions), { 152 | merge: null, 153 | header: " # non-comment", 154 | body: null, 155 | footer: null, 156 | notes: [], 157 | references: [], 158 | mentions: [], 159 | revert: null, 160 | scope: null, 161 | subject: null, 162 | type: null, 163 | }); 164 | assertEquals(parse("header\n# comment\n\nbody", commentOptions), { 165 | merge: null, 166 | header: "header", 167 | body: "body", 168 | footer: null, 169 | notes: [], 170 | references: [], 171 | mentions: [], 172 | revert: null, 173 | scope: null, 174 | subject: null, 175 | type: null, 176 | }); 177 | }, 178 | }); 179 | 180 | Deno.test({ 181 | name: "parse | respect comments", 182 | fn(): void { 183 | var commentOptions = Object.assign({}, options, { commentChar: "*" }); 184 | assertEquals(parse("* comment", commentOptions), { 185 | merge: null, 186 | header: null, 187 | body: null, 188 | footer: null, 189 | notes: [], 190 | references: [], 191 | mentions: [], 192 | revert: null, 193 | scope: null, 194 | subject: null, 195 | type: null, 196 | }); 197 | assertEquals(parse("# non-comment", commentOptions), { 198 | merge: null, 199 | header: "# non-comment", 200 | body: null, 201 | footer: null, 202 | notes: [], 203 | references: [], 204 | mentions: [], 205 | revert: null, 206 | scope: null, 207 | subject: null, 208 | type: null, 209 | }); 210 | assertEquals(parse(" * non-comment", commentOptions), { 211 | merge: null, 212 | header: " * non-comment", 213 | body: null, 214 | footer: null, 215 | notes: [], 216 | references: [], 217 | mentions: [], 218 | revert: null, 219 | scope: null, 220 | subject: null, 221 | type: null, 222 | }); 223 | assertEquals(parse("header\n* comment\n\nbody", commentOptions), { 224 | merge: null, 225 | header: "header", 226 | body: "body", 227 | footer: null, 228 | notes: [], 229 | references: [], 230 | mentions: [], 231 | revert: null, 232 | scope: null, 233 | subject: null, 234 | type: null, 235 | }); 236 | }, 237 | }); 238 | 239 | Deno.test({ 240 | name: "parse | scissor line", 241 | fn(): void { 242 | assertEquals( 243 | parse( 244 | "this is some header before a scissors-line\n" + 245 | "# ------------------------ >8 ------------------------\n" + 246 | "this is a line that should be truncated\n", 247 | options, 248 | ).body, 249 | null, 250 | ); 251 | assertEquals( 252 | parse( 253 | "this is some header before a scissors-line\n" + 254 | "# ------------------------ >8 ------------------------\n" + 255 | "this is a line that should be truncated\n", 256 | options, 257 | ).header, 258 | "this is some header before a scissors-line", 259 | ); 260 | assertEquals( 261 | parse( 262 | "this is some header before a scissors-line\n" + 263 | "this is some body before a scissors-line\n" + 264 | "# ------------------------ >8 ------------------------\n" + 265 | "this is a line that should be truncated\n", 266 | options, 267 | ).body, 268 | "this is some body before a scissors-line", 269 | ); 270 | }, 271 | }); 272 | 273 | Deno.test({ 274 | name: "parse | mentions", 275 | fn(): void { 276 | let mentionOptions: Options = { 277 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 278 | headerCorrespondence: ["type", "scope", "subject"], 279 | mergePattern: /^Merge pull request #(\d+) from (.*)$/, 280 | mergeCorrespondence: ["issueId", "source"], 281 | }; 282 | assertEquals( 283 | parse( 284 | "@Steve\n" + 285 | "@conventional-changelog @someone" + 286 | "\n" + 287 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 288 | "@this is", 289 | mentionOptions, 290 | ).mentions, 291 | ["Steve", "conventional-changelog", "someone", "this"], 292 | ); 293 | }, 294 | }); 295 | 296 | Deno.test({ 297 | name: "parse | merge | general", 298 | fn(): void { 299 | let mergeOptions: Options = { 300 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 301 | headerCorrespondence: ["type", "scope", "subject"], 302 | mergePattern: /^Merge branch '(\w+)'$/, 303 | mergeCorrespondence: ["source", "issueId"], 304 | }; 305 | const general = parse("Merge branch 'feature'\nHEADER", mergeOptions); 306 | assertEquals(general.source, "feature"); 307 | assertEquals(general.issueId, null); 308 | }, 309 | }); 310 | 311 | Deno.test({ 312 | name: "parse | merge | github", 313 | fn(): void { 314 | let githubOptions = { 315 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 316 | headerCorrespondence: ["type", "scope", "subject"], 317 | mergePattern: /^Merge pull request #(\d+) from (.*)$/, 318 | mergeCorrespondence: ["issueId", "source"], 319 | }; 320 | 321 | const github = parse( 322 | "Merge pull request #1 from user/feature/feature-name\n" + 323 | "\n" + 324 | "feat(scope): broadcast $destroy event on scope destruction\n" + 325 | "\n" + 326 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 327 | "when destroying 10k nested scopes where each scope has a $destroy listener", 328 | githubOptions, 329 | ); 330 | assertEquals( 331 | github.header, 332 | "feat(scope): broadcast $destroy event on scope destruction", 333 | ); 334 | assertEquals(github.type, "feat"); 335 | assertEquals(github.scope, "scope"); 336 | assertEquals( 337 | github.subject, 338 | "broadcast $destroy event on scope destruction", 339 | ); 340 | assertEquals( 341 | github.merge, 342 | "Merge pull request #1 from user/feature/feature-name", 343 | ); 344 | assertEquals(github.issueId, "1"); 345 | assertEquals(github.source, "user/feature/feature-name"); 346 | }, 347 | }); 348 | 349 | Deno.test({ 350 | name: "parse | merge | gitlab", 351 | fn(): void { 352 | let gitlabOptions = { 353 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 354 | headerCorrespondence: ["type", "scope", "subject"], 355 | mergePattern: /^Merge branch '([^']+)' into '[^']+'$/, 356 | mergeCorrespondence: ["source"], 357 | }; 358 | 359 | const gitlab = parse( 360 | "Merge branch 'feature/feature-name' into 'master'\r\n" + 361 | "\r\n" + 362 | "feat(scope): broadcast $destroy event on scope destruction\r\n" + 363 | "\r\n" + 364 | "perf testing shows that in chrome this change adds 5-15% overhead\r\n" + 365 | "when destroying 10k nested scopes where each scope has a $destroy listener\r\n" + 366 | "\r\n" + 367 | "See merge request !1", 368 | gitlabOptions, 369 | ); 370 | assertEquals( 371 | gitlab.header, 372 | "feat(scope): broadcast $destroy event on scope destruction", 373 | ); 374 | assertEquals(gitlab.type, "feat"); 375 | assertEquals(gitlab.scope, "scope"); 376 | assertEquals( 377 | gitlab.subject, 378 | "broadcast $destroy event on scope destruction", 379 | ); 380 | assertEquals( 381 | gitlab.merge, 382 | "Merge branch 'feature/feature-name' into 'master'", 383 | ); 384 | assertEquals(gitlab.source, "feature/feature-name"); 385 | }, 386 | }); 387 | 388 | Deno.test({ 389 | name: "parse | header | allow : in scope", 390 | fn(): void { 391 | let msg = parse("feat(ng:list): Allow custom separator", { 392 | headerPattern: /^(\w*)(?:\(([:\w$.\-* ]*)\))?: (.*)$/, 393 | headerCorrespondence: ["type", "scope", "subject"], 394 | }); 395 | assertEquals(msg.scope, "ng:list"); 396 | }, 397 | }); 398 | 399 | Deno.test({ 400 | name: "parse | header | null if not parsed", 401 | fn(): void { 402 | let msg = parse("header", options); 403 | assertEquals(msg.type, null); 404 | assertEquals(msg.scope, null); 405 | assertEquals(msg.subject, null); 406 | }, 407 | }); 408 | 409 | Deno.test({ 410 | name: "parse | header | parse", 411 | fn(): void { 412 | let msg = parse( 413 | "feat(scope): broadcast $destroy event on scope destruction\n" + 414 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 415 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 416 | "BREAKING AMEND: some breaking change\n" + 417 | "Kills #1, #123\n" + 418 | "killed #25\n" + 419 | "handle #33, Closes #100, Handled #3 kills repo#77\n" + 420 | "kills stevemao/conventional-commits-parser#1", 421 | options, 422 | ); 423 | assertEquals(msg.type, "feat"); 424 | assertEquals(msg.scope, "scope"); 425 | assertEquals(msg.subject, "broadcast $destroy event on scope destruction"); 426 | }, 427 | }); 428 | 429 | Deno.test({ 430 | name: "parse | header | correspondence", 431 | fn(): void { 432 | let msg = parse("scope(my subject): fix this", { 433 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 434 | headerCorrespondence: ["scope", "subject", "type"], 435 | }); 436 | assertEquals(msg.type, "fix this"); 437 | assertEquals(msg.scope, "scope"); 438 | assertEquals(msg.subject, "my subject"); 439 | }, 440 | }); 441 | 442 | Deno.test({ 443 | name: "parse | header | undefined correspondence", 444 | fn(): void { 445 | let msg = parse("scope(my subject): fix this", { 446 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 447 | headerCorrespondence: ["scop", "subject"], 448 | }); 449 | assertEquals(msg.scope, undefined); 450 | }, 451 | }); 452 | 453 | Deno.test({ 454 | name: "parse | header | reference issue with an owner", 455 | fn(): void { 456 | let msg = parse("handled angular/angular.js#1", options); 457 | assertEquals(msg.references, [ 458 | { 459 | action: "handled", 460 | owner: "angular", 461 | repository: "angular.js", 462 | issue: "1", 463 | raw: "angular/angular.js#1", 464 | prefix: "#", 465 | }, 466 | ]); 467 | }, 468 | }); 469 | 470 | Deno.test({ 471 | name: "parse | header | reference issue with a repository", 472 | fn(): void { 473 | let msg = parse("handled angular.js#1", options); 474 | assertEquals(msg.references, [ 475 | { 476 | action: "handled", 477 | owner: null, 478 | repository: "angular.js", 479 | issue: "1", 480 | raw: "angular.js#1", 481 | prefix: "#", 482 | }, 483 | ]); 484 | }, 485 | }); 486 | 487 | Deno.test({ 488 | name: "parse | header | reference issue", 489 | fn(): void { 490 | let msg = parse("handled gh-1", options); 491 | assertEquals(msg.references, [ 492 | { 493 | action: "handled", 494 | owner: null, 495 | repository: null, 496 | issue: "1", 497 | raw: "gh-1", 498 | prefix: "gh-", 499 | }, 500 | ]); 501 | }, 502 | }); 503 | 504 | Deno.test({ 505 | name: "parse | header | reference issue without action", 506 | fn(): void { 507 | let options = { 508 | revertPattern: /^Revert\s"([\s\S]*)"\s*This reverts commit (.*)\.$/, 509 | revertCorrespondence: ["header", "hash"], 510 | fieldPattern: /^-(.*?)-$/, 511 | headerPattern: /^(\w*)(?:\(([\w$.\-* ]*)\))?: (.*)$/, 512 | headerCorrespondence: ["type", "scope", "subject"], 513 | noteKeywords: ["BREAKING AMEND"], 514 | issuePrefixes: ["#", "gh-"], 515 | }; 516 | 517 | let msg = parse("This is gh-1", options); 518 | assertEquals(msg.references, [ 519 | { 520 | action: null, 521 | owner: null, 522 | repository: null, 523 | issue: "1", 524 | raw: "This is gh-1", 525 | prefix: "gh-", 526 | }, 527 | ]); 528 | }, 529 | }); 530 | 531 | Deno.test({ 532 | name: "parse | body | null if not parsed", 533 | fn(): void { 534 | let msg = parse("header", options); 535 | assertEquals(msg.body, null); 536 | }, 537 | }); 538 | 539 | Deno.test({ 540 | name: "parse | body | parse", 541 | fn(): void { 542 | let msg = parse( 543 | "feat(scope): broadcast $destroy event on scope destruction\n" + 544 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 545 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 546 | "BREAKING AMEND: some breaking change\n" + 547 | "Kills #1, #123\n" + 548 | "killed #25\n" + 549 | "handle #33, Closes #100, Handled #3 kills repo#77\n" + 550 | "kills stevemao/conventional-commits-parser#1", 551 | options, 552 | ); 553 | assertEquals( 554 | msg.body, 555 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 556 | "when destroying 10k nested scopes where each scope has a $destroy listener", 557 | ); 558 | }, 559 | }); 560 | 561 | Deno.test({ 562 | name: "parse | footer | null if not parsed", 563 | fn(): void { 564 | let msg = parse("header", options); 565 | assertEquals(msg.footer, null); 566 | }, 567 | }); 568 | 569 | Deno.test({ 570 | name: "parse | footer | parse", 571 | fn(): void { 572 | let msg = parse( 573 | "feat(scope): broadcast $destroy event on scope destruction\n" + 574 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 575 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 576 | "BREAKING AMEND: some breaking change\n" + 577 | "Kills #1, #123\n" + 578 | "killed #25\n" + 579 | "handle #33, Closes #100, Handled #3 kills repo#77\n" + 580 | "kills stevemao/conventional-commits-parser#1", 581 | options, 582 | ); 583 | assertEquals( 584 | msg.footer, 585 | "BREAKING AMEND: some breaking change\n" + 586 | "Kills #1, #123\n" + 587 | "killed #25\n" + 588 | "handle #33, Closes #100, Handled #3 kills repo#77\n" + 589 | "kills stevemao/conventional-commits-parser#1", 590 | ); 591 | assertEquals(msg.notes[0], { 592 | title: "BREAKING AMEND", 593 | text: "some breaking change", 594 | }); 595 | }, 596 | }); 597 | 598 | Deno.test({ 599 | name: "parse | footer | notes", 600 | fn(): void { 601 | let simpleMsg = parse("chore: some chore"); 602 | assertEquals(simpleMsg.notes, []); 603 | let msg = parse( 604 | "feat(scope): broadcast $destroy event on scope destruction\n" + 605 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 606 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 607 | "BREAKING AMEND: some breaking change\n" + 608 | "Kills #1, #123\n" + 609 | "killed #25\n" + 610 | "handle #33, Closes #100, Handled #3 kills repo#77\n" + 611 | "kills stevemao/conventional-commits-parser#1", 612 | options, 613 | ); 614 | assertEquals(msg.notes[0], { 615 | title: "BREAKING AMEND", 616 | text: "some breaking change", 617 | }); 618 | let longMsg = parse( 619 | "feat(scope): broadcast $destroy event on scope destruction\n" + 620 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 621 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 622 | "BREAKING AMEND:\n" + 623 | "some breaking change\n" + 624 | "some other breaking change\n" + 625 | "Kills #1, #123\n" + 626 | "killed #25\n" + 627 | "handle #33, Closes #100, Handled #3", 628 | options, 629 | ); 630 | assertEquals(longMsg.notes[0], { 631 | title: "BREAKING AMEND", 632 | text: "some breaking change\nsome other breaking change", 633 | }); 634 | }, 635 | }); 636 | 637 | Deno.test({ 638 | name: "parse | footer | references", 639 | fn(): void { 640 | let simpleMsg = parse("chore: some chore"); 641 | assertEquals(simpleMsg.references, []); 642 | let msg = parse( 643 | "feat(scope): broadcast $destroy event on scope destruction\n" + 644 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 645 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 646 | "BREAKING AMEND: some breaking change\n" + 647 | "Kills #1, #123\n" + 648 | "killed #25\n" + 649 | "handle #33, Closes #100, Handled #3 kills repo#77\n" + 650 | "kills stevemao/conventional-commits-parser#1", 651 | options, 652 | ); 653 | assertEquals(msg.references, [ 654 | { 655 | action: "Kills", 656 | owner: null, 657 | repository: null, 658 | issue: "1", 659 | raw: "#1", 660 | prefix: "#", 661 | }, 662 | { 663 | action: "Kills", 664 | owner: null, 665 | repository: null, 666 | issue: "123", 667 | raw: ", #123", 668 | prefix: "#", 669 | }, 670 | { 671 | action: "killed", 672 | owner: null, 673 | repository: null, 674 | issue: "25", 675 | raw: "#25", 676 | prefix: "#", 677 | }, 678 | { 679 | action: "handle", 680 | owner: null, 681 | repository: null, 682 | issue: "33", 683 | raw: "#33", 684 | prefix: "#", 685 | }, 686 | { 687 | action: "handle", 688 | owner: null, 689 | repository: null, 690 | issue: "100", 691 | raw: ", Closes #100", 692 | prefix: "#", 693 | }, 694 | { 695 | action: "Handled", 696 | owner: null, 697 | repository: null, 698 | issue: "3", 699 | raw: "#3", 700 | prefix: "#", 701 | }, 702 | { 703 | action: "kills", 704 | owner: null, 705 | repository: "repo", 706 | issue: "77", 707 | raw: "repo#77", 708 | prefix: "#", 709 | }, 710 | { 711 | action: "kills", 712 | owner: "stevemao", 713 | repository: "conventional-commits-parser", 714 | issue: "1", 715 | raw: "stevemao/conventional-commits-parser#1", 716 | prefix: "#", 717 | }, 718 | ]); 719 | let longMsg = parse( 720 | "feat(scope): broadcast $destroy event on scope destruction\n" + 721 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 722 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 723 | "BREAKING AMEND:\n" + 724 | "some breaking change\n" + 725 | "some other breaking change\n" + 726 | "Kills #1, #123\n" + 727 | "killed #25\n" + 728 | "handle #33, Closes #100, Handled #3", 729 | options, 730 | ); 731 | assertEquals(longMsg.notes[0], { 732 | title: "BREAKING AMEND", 733 | text: "some breaking change\nsome other breaking change", 734 | }); 735 | }, 736 | }); 737 | 738 | Deno.test({ 739 | name: "parse | footer | after references in footer", 740 | fn(): void { 741 | var msg = parse( 742 | "feat(scope): broadcast $destroy event on scope destruction\n" + 743 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 744 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 745 | "Kills #1, #123\n" + 746 | "what\n" + 747 | "killed #25\n" + 748 | "handle #33, Closes #100, Handled #3\n" + 749 | "other", 750 | options, 751 | ); 752 | 753 | assertEquals( 754 | msg.footer, 755 | "Kills #1, #123\nwhat\nkilled #25\nhandle #33, Closes #100, Handled #3\nother", 756 | ); 757 | }, 758 | }); 759 | 760 | Deno.test({ 761 | name: "parse | footer | after references in footer", 762 | fn(): void { 763 | var msg = parse( 764 | "feat(scope): broadcast $destroy event on scope destruction\n" + 765 | "perf testing shows that in chrome this change adds 5-15% overhead\n" + 766 | "when destroying 10k nested scopes where each scope has a $destroy listener\n" + 767 | "Kills #1, #123\n" + 768 | "BREAKING AMEND: some breaking change\n", 769 | options, 770 | ); 771 | 772 | assertEquals(msg.notes[0], { 773 | title: "BREAKING AMEND", 774 | text: "some breaking change", 775 | }); 776 | assertEquals(msg.references, [ 777 | { 778 | action: "Kills", 779 | owner: null, 780 | repository: null, 781 | issue: "1", 782 | raw: "#1", 783 | prefix: "#", 784 | }, 785 | { 786 | action: "Kills", 787 | owner: null, 788 | repository: null, 789 | issue: "123", 790 | raw: ", #123", 791 | prefix: "#", 792 | }, 793 | ]); 794 | assertEquals( 795 | msg.footer, 796 | "Kills #1, #123\nBREAKING AMEND: some breaking change", 797 | ); 798 | }, 799 | }); 800 | 801 | Deno.test({ 802 | name: "other | parse hash", 803 | fn(): void { 804 | var msg = parse( 805 | "My commit message\n" + 806 | "-hash-\n" + 807 | "9b1aff905b638aa274a5fc8f88662df446d374bd", 808 | options, 809 | ); 810 | 811 | assertEquals(msg.hash, "9b1aff905b638aa274a5fc8f88662df446d374bd"); 812 | }, 813 | }); 814 | 815 | Deno.test({ 816 | name: "other | parse sideNotes", 817 | fn(): void { 818 | var msg = parse( 819 | "My commit message\n" + 820 | "-sideNotes-\n" + 821 | "It should warn the correct unfound file names.\n" + 822 | "Also it should continue if one file cannot be found.\n" + 823 | "Tests are added for these", 824 | options, 825 | ); 826 | 827 | assertEquals( 828 | msg.sideNotes, 829 | "It should warn the correct unfound file names.\n" + 830 | "Also it should continue if one file cannot be found.\n" + 831 | "Tests are added for these", 832 | ); 833 | }, 834 | }); 835 | 836 | Deno.test({ 837 | name: "other | parse committer name and email", 838 | fn(): void { 839 | var msg = parse( 840 | "My commit message\n" + 841 | "-committerName-\n" + 842 | "Steve Mao\n" + 843 | "- committerEmail-\n" + 844 | "test@github.com", 845 | options, 846 | ); 847 | 848 | assertEquals(msg.committerName, "Steve Mao"); 849 | assertEquals(msg[" committerEmail"], "test@github.com"); 850 | }, 851 | }); 852 | 853 | Deno.test({ 854 | name: "revert | parse", 855 | fn(): void { 856 | var msg = parse( 857 | 'Revert "throw an error if a callback is passed to animate methods"\n\n' + 858 | "This reverts commit 9bb4d6ccbe80b7704c6b7f53317ca8146bc103ca.", 859 | options, 860 | ); 861 | 862 | assertEquals(msg.revert, { 863 | header: "throw an error if a callback is passed to animate methods", 864 | hash: "9bb4d6ccbe80b7704c6b7f53317ca8146bc103ca", 865 | }); 866 | }, 867 | }); 868 | 869 | Deno.test({ 870 | name: "revert | parse lazy", 871 | fn(): void { 872 | var msg = parse('Revert ""\n\n' + "This reverts commit .", options); 873 | 874 | assertEquals(msg.revert, { 875 | header: null, 876 | hash: null, 877 | }); 878 | }, 879 | }); 880 | -------------------------------------------------------------------------------- /parser.ts: -------------------------------------------------------------------------------- 1 | import type { Options, Commit, Reference, Field, Note, Revert } from "./mod.ts"; 2 | import type { ParsingRegex } from "./regex.ts"; 3 | 4 | const reNewlines = /^(?:\r\n|\n|\r)+|(?:\r\n|\n|\r)+$/g; 5 | 6 | function trimOffNewlines(str: string) { 7 | return str.replace(reNewlines, ""); 8 | } 9 | 10 | const CATCH_ALL = /()(.+)/gi; 11 | const SCISSOR = "# ------------------------ >8 ------------------------"; 12 | 13 | function append(src: string | null, line: string) { 14 | if (src) { 15 | src += "\n" + line; 16 | } else { 17 | src = line; 18 | } 19 | 20 | return src; 21 | } 22 | 23 | function getCommentFilter(char: string) { 24 | return (line: string) => { 25 | return line.charAt(0) !== char; 26 | }; 27 | } 28 | 29 | function truncateToScissor(lines: string[]) { 30 | const scissorIndex = lines.indexOf(SCISSOR); 31 | 32 | if (scissorIndex === -1) { 33 | return lines; 34 | } 35 | 36 | return lines.slice(0, scissorIndex); 37 | } 38 | 39 | function getReferences(input: string, regex: ParsingRegex): Reference[] { 40 | const references = []; 41 | let referenceSentences; 42 | let referenceMatch; 43 | 44 | const reApplicable = input.match(regex.references) !== null 45 | ? regex.references 46 | : CATCH_ALL; 47 | 48 | while ((referenceSentences = reApplicable.exec(input))) { 49 | const action = referenceSentences[1] || null; 50 | const sentence = referenceSentences[2]; 51 | 52 | while ((referenceMatch = regex.referenceParts.exec(sentence))) { 53 | let owner = null; 54 | let repository: string | null = referenceMatch[1] || ""; 55 | const ownerRepo = repository.split("/"); 56 | 57 | if (ownerRepo.length > 1) { 58 | owner = ownerRepo.shift()!; 59 | repository = ownerRepo.join("/"); 60 | } 61 | 62 | repository ||= null; 63 | 64 | const issue = referenceMatch[3]; 65 | const raw = referenceMatch[0]; 66 | const prefix = referenceMatch[2]; 67 | 68 | const reference = { 69 | action, 70 | owner, 71 | repository, 72 | issue, 73 | raw, 74 | prefix, 75 | }; 76 | 77 | references.push(reference); 78 | } 79 | } 80 | 81 | return references; 82 | } 83 | 84 | function passTrough() { 85 | return true; 86 | } 87 | 88 | export function parser( 89 | raw?: string, 90 | options?: Required, 91 | regex?: ParsingRegex, 92 | ): Commit { 93 | if (!raw || !raw.trim()) { 94 | throw new TypeError("Expected a raw commit"); 95 | } 96 | 97 | if (!options) { 98 | throw new TypeError("Expected options"); 99 | } 100 | 101 | if (!regex) { 102 | throw new TypeError("Expected regexes"); 103 | } 104 | 105 | let mentionsMatch; 106 | const commentFilter = typeof options.commentChar === "string" 107 | ? getCommentFilter(options.commentChar) 108 | : passTrough; 109 | 110 | const rawLines = trimOffNewlines(raw).split(/\r?\n/); 111 | const lines = truncateToScissor(rawLines).filter(commentFilter); 112 | 113 | let continueNote = false; 114 | let isBody = true; 115 | const headerCorrespondence = options.headerCorrespondence?.map((part) => { 116 | return part.trim(); 117 | }); 118 | const revertCorrespondence = options.revertCorrespondence?.map((field) => { 119 | return field.trim(); 120 | }); 121 | const mergeCorrespondence = options.mergeCorrespondence?.map((field) => { 122 | return field.trim(); 123 | }); 124 | 125 | const mentions: string[] = []; 126 | const notes: Note[] = []; 127 | const references: Reference[] = []; 128 | 129 | let body: Field = null; 130 | let footer: Field = null; 131 | let header = null; 132 | let merge = null; 133 | let revert: Revert | null = null; 134 | 135 | if (lines.length === 0) { 136 | return { 137 | body: body, 138 | footer: footer, 139 | header: header, 140 | mentions: mentions, 141 | merge: merge, 142 | notes: notes, 143 | references: references, 144 | revert: revert, 145 | scope: null, 146 | subject: null, 147 | type: null, 148 | } as Commit; 149 | } 150 | 151 | // msg parts 152 | merge = lines.shift()!; 153 | const mergeParts: { [key: string]: string | null } = {}; 154 | const headerParts: { [key: string]: string | null } = {}; 155 | body = ""; 156 | footer = ""; 157 | 158 | const mergeMatch = merge.match(options.mergePattern!); 159 | if (mergeMatch && options.mergePattern) { 160 | merge = mergeMatch[0]; 161 | 162 | header = lines.shift(); 163 | while (!header?.trim()) { 164 | header = lines.shift(); 165 | } 166 | 167 | mergeCorrespondence?.forEach((partName, index) => { 168 | const partValue = mergeMatch![index + 1] ?? null; 169 | mergeParts[partName] = partValue; 170 | }); 171 | } else { 172 | header = merge; 173 | merge = null; 174 | 175 | mergeCorrespondence?.forEach((partName) => { 176 | mergeParts[partName] = null; 177 | }); 178 | } 179 | 180 | const headerMatch = header.match(options.headerPattern!); 181 | if (headerMatch) { 182 | headerCorrespondence?.forEach((partName, index) => { 183 | const partValue = headerMatch![index + 1] || null; 184 | headerParts[partName] = partValue; 185 | }); 186 | } else { 187 | headerCorrespondence?.forEach((partName) => { 188 | headerParts[partName] = null; 189 | }); 190 | } 191 | 192 | Array.prototype.push.apply(references, getReferences(header, regex)); 193 | 194 | // body or footer 195 | const otherFields: { [key: string]: string } = {}; 196 | let currentProcessedField: string; 197 | 198 | lines.forEach((line) => { 199 | if (options.fieldPattern) { 200 | const fieldMatch = options.fieldPattern.exec(line); 201 | 202 | if (fieldMatch) { 203 | currentProcessedField = fieldMatch[1]; 204 | 205 | return; 206 | } 207 | 208 | if (currentProcessedField) { 209 | otherFields[currentProcessedField] = append( 210 | otherFields[currentProcessedField], 211 | line, 212 | ); 213 | 214 | return; 215 | } 216 | } 217 | 218 | let referenceMatched; 219 | 220 | // this is a new important note 221 | const notesMatch = line.match(regex.notes); 222 | if (notesMatch) { 223 | continueNote = true; 224 | isBody = false; 225 | footer = append(footer, line); 226 | 227 | const note = { 228 | title: notesMatch[1], 229 | text: notesMatch[2], 230 | }; 231 | 232 | notes.push(note); 233 | 234 | return; 235 | } 236 | 237 | const lineReferences = getReferences(line, regex); 238 | 239 | if (lineReferences.length > 0) { 240 | isBody = false; 241 | referenceMatched = true; 242 | continueNote = false; 243 | } 244 | 245 | Array.prototype.push.apply(references, lineReferences); 246 | 247 | if (referenceMatched) { 248 | footer = append(footer, line); 249 | 250 | return; 251 | } 252 | 253 | if (continueNote) { 254 | notes[notes.length - 1].text = append(notes[notes.length - 1].text, line); 255 | footer = append(footer, line); 256 | 257 | return; 258 | } 259 | 260 | if (isBody) { 261 | body = append(body, line); 262 | } else { 263 | footer = append(footer, line); 264 | } 265 | }); 266 | 267 | // if (options.breakingHeaderPattern && notes.length === 0) { 268 | // let breakingHeader = header.match(options.breakingHeaderPattern); 269 | // if (breakingHeader) { 270 | // const noteText = breakingHeader[3]; // the description of the change. 271 | // notes.push({ 272 | // title: "BREAKING CHANGE", 273 | // text: noteText, 274 | // }); 275 | // } 276 | // } 277 | 278 | while ((mentionsMatch = regex.mentions.exec(raw))) { 279 | mentions.push(mentionsMatch[1]); 280 | } 281 | 282 | // does this commit revert any other commit? 283 | const revertMatch = raw.match(options.revertPattern!); 284 | if (revertMatch) { 285 | revert = {}; 286 | revertCorrespondence?.forEach((partName, index) => { 287 | const partValue = revertMatch![index + 1] || null; 288 | revert![partName] = partValue; 289 | }); 290 | } else { 291 | revert = null; 292 | } 293 | 294 | notes.forEach((note) => { 295 | note.text = trimOffNewlines(note.text); 296 | 297 | return note; 298 | }); 299 | 300 | const msg = Object.assign( 301 | headerParts, 302 | mergeParts, 303 | { 304 | merge: merge, 305 | header: header, 306 | body: body ? trimOffNewlines(body) : null, 307 | footer: footer ? trimOffNewlines(footer) : null, 308 | notes: notes, 309 | references: references, 310 | mentions: mentions, 311 | revert: revert, 312 | }, 313 | otherFields, 314 | ); 315 | 316 | return msg as Commit; 317 | } 318 | -------------------------------------------------------------------------------- /regex.ts: -------------------------------------------------------------------------------- 1 | import type { Options, Keywords, Prefixes, Actions } from "./mod.ts"; 2 | 3 | const reNomatch = /(?!.*)/; 4 | 5 | function join(array: string[], joiner: string): string { 6 | return array 7 | .map(function (val) { 8 | return val.trim(); 9 | }) 10 | .filter(function (val) { 11 | return val.length; 12 | }) 13 | .join(joiner); 14 | } 15 | 16 | function getNotesRegex(noteKeywords?: Keywords): RegExp { 17 | if (!noteKeywords) { 18 | return reNomatch; 19 | } 20 | 21 | return new RegExp( 22 | "^[\\s|*]*(" + join(noteKeywords as string[], "|") + ")[:\\s]+(.*)", 23 | "i", 24 | ); 25 | } 26 | 27 | function getReferencePartsRegex( 28 | issuePrefixes?: Prefixes, 29 | issuePrefixesCaseSensitive?: boolean, 30 | ): RegExp { 31 | if (!issuePrefixes) { 32 | return reNomatch; 33 | } 34 | 35 | const flags = issuePrefixesCaseSensitive ? "g" : "gi"; 36 | return new RegExp( 37 | "(?:.*?)??\\s*([\\w-\\.\\/]*?)??(" + 38 | join(issuePrefixes as string[], "|") + 39 | ")([\\w-]*\\d+)", 40 | flags, 41 | ); 42 | } 43 | 44 | function getReferencesRegex(referenceActions?: Actions): RegExp { 45 | if (!referenceActions) { 46 | // matches everything 47 | return /()(.+)/gi; 48 | } 49 | 50 | const joinedKeywords = join(referenceActions as string[], "|"); 51 | return new RegExp( 52 | "(" + joinedKeywords + ")(?:\\s+(.*?))(?=(?:" + joinedKeywords + ")|$)", 53 | "gi", 54 | ); 55 | } 56 | 57 | export interface ParsingRegex { 58 | notes: RegExp; 59 | referenceParts: RegExp; 60 | references: RegExp; 61 | mentions: RegExp; 62 | } 63 | 64 | export function regex(options: Options): ParsingRegex { 65 | options = options || {}; 66 | const reNotes = getNotesRegex(options.noteKeywords); 67 | const reReferenceParts = getReferencePartsRegex( 68 | options.issuePrefixes, 69 | options.issuePrefixesCaseSensitive, 70 | ); 71 | const reReferences = getReferencesRegex(options.referenceActions); 72 | 73 | return { 74 | notes: reNotes, 75 | referenceParts: reReferenceParts, 76 | references: reReferences, 77 | mentions: /@([\w-]+)/g, 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /test_deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | assertThrows, 5 | } from "https://deno.land/std@0.78.0/testing/asserts.ts"; 6 | --------------------------------------------------------------------------------