diff --git a/.github/workflows/checkSingleCommitPR.yml b/.github/workflows/checkSingleCommitPR.yml new file mode 100644 index 000000000..6741d04a1 --- /dev/null +++ b/.github/workflows/checkSingleCommitPR.yml @@ -0,0 +1,49 @@ +name: Check single-commit PR + +on: + pull_request: + types: + - opened + - edited + +env: + DOTNET_VERSION: "10.0" + DOTNET_ROLL_FORWARD: Major + CHECKOUT_ACTION_VERSION: "4" + DOTNET_NOLOGO: 1 + +jobs: + check-single-commit-pr: + name: Check that single-commit PR matches commit message + runs-on: ubuntu-latest + container: + image: "ubuntu:26.04" + steps: + - name: Install required dependencies + run: | + apt update + apt install --yes sudo + sudo apt install --yes --no-install-recommends git + - name: Run actions/checkout + uses: nblockchain/conventions@master + with: + uses: actions/checkout@v${{ env.CHECKOUT_ACTION_VERSION }} + with: | + { + "fetch-depth": 0 + } + - name: Run git+githubCI workaround + run: | + # workaround for https://github.com/actions/runner/issues/2033 + git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Setup .NET + run: | + apt install --yes --no-install-recommends ca-certificates + apt install --yes --no-install-recommends dotnet-sdk-${{ env.DOTNET_VERSION }} + - name: Check single commit matches PR title and description + env: + PR_COMMITS: ${{ github.event.pull_request.commits }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_DESCRIPTION: ${{ github.event.pull_request.body }} + run: dotnet fsi scripts/checkSingleCommitPR.fsx diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..dbb82bda0 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +dotnet fsi scripts/wrapLatestCommitMsg.fsx "$1" diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 000000000..174fb5736 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/ReadMe.md b/ReadMe.md index 41739bbf1..259f62a50 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -9,7 +9,7 @@ This is a repository that contains several useful things that other [tarsgate](h * [F# scripts compilation](scripts/compileFSharpScripts.fsx). * [EOF without EOL detection](scripts/eofConvention.fsx). * [Mixed line-endings detection](scripts/mixedLineEndings.fsx). - * [Auto-wrap the latest commit message](scripts/wrapLatestCommitMsg.fsx). + * [Auto-wrap commit message body and enforce title max length](scripts/wrapLatestCommitMsg.fsx). * [Detect non-verbose flags (e.g. `dotnet build -c Debug` instead of `dotnet build --configuration Debug`) being used in scripts or YML CI files (there are exceptions, e.g. `env -S`)](scripts/nonVerboseFlagsInGitHubCIAndScripts.fsx). * Use of unpinned versions: * [Use of `-latest` suffix in `runs-on:` GitHubCI tags](scripts/unpinnedGitHubActionsImageVersions.fsx). @@ -31,4 +31,3 @@ More things to come: * Missing important triggers such as push or pull_request, workflow_dispatch, schedule. * Branch filtering on push trigger (only acceptable one is '**', otherwise '*' doesn't match to branch names with slashes in them). - GitHub comment auto-responder? E.g. to answer to comments that end with "not working" or "doesn't work" or "does not work", asking for more details. -- wrapLatestCommitMsg.fsx script to fail for obvious requirements that can't be automated (e.g. title max length) diff --git a/commitlint.config.ts b/commitlint.config.ts index 79a7e2150..9144ee37d 100644 --- a/commitlint.config.ts +++ b/commitlint.config.ts @@ -60,6 +60,7 @@ export default { "proper-issue-refs": [RuleConfigSeverity.Error, "always"], "too-many-spaces": [RuleConfigSeverity.Error, "always"], "commit-hash-alone": [RuleConfigSeverity.Error, "always"], + "reject-em-dash": [RuleConfigSeverity.Error, "always"], "title-uppercase": [RuleConfigSeverity.Error, "always"], // disabled because most of the time it doesn't work, due to https://github.com/conventional-changelog/commitlint/issues/3404 @@ -89,6 +90,12 @@ export default { return Plugins.commitHashAlone(rawStr); }, + "reject-em-dash": ({ raw }: { raw: any }) => { + const rawStr = extractStringFromCommitlintParam("raw", raw); + + return Plugins.rejectEmDash(rawStr); + }, + "empty-wip": ({ header }: { header: any }) => { const headerStr = extractStringFromCommitlintParam( "header", diff --git a/commitlint/plugins.ts b/commitlint/plugins.ts index 41926d260..fdf7818c3 100644 --- a/commitlint/plugins.ts +++ b/commitlint/plugins.ts @@ -539,6 +539,16 @@ export abstract class Plugins { ]; } + public static rejectEmDash(rawStr: string) { + const offence = rawStr.includes("—"); + + return [ + !offence, + `Please replace em-dashes (—) with a normal dash (-) if they are meant as bullet points or word-unions, or use parentheses if they are meant as a real em-dash.` + + Helpers.errMessageSuffix, + ]; + } + public static trailingWhitespace(rawStr: string) { let offence = false; diff --git a/commitlint/tests/plugins.test.ts b/commitlint/tests/plugins.test.ts index 43df0aac9..c631d2741 100644 --- a/commitlint/tests/plugins.test.ts +++ b/commitlint/tests/plugins.test.ts @@ -786,6 +786,51 @@ http://foo.bar/baz`; expect(output1.toString().includes("EOL")).toBe(true); }); +test("reject-em-dash1", () => { + const commitMsgWithEmDashAsBullet = `foo: this is only a title + +This is a bullet list of things: +— Foo. +— Bar.`; + + const rejectEmDash1 = runCommitLintOnMsg(commitMsgWithEmDashAsBullet); + expect(rejectEmDash1.status).not.toBe(0); + + const commitMsgWithEmDashAsWordUnion = `foo: this is only a title + +Foo — bar baz.`; + + const rejectEmDash1Prime = runCommitLintOnMsg( + commitMsgWithEmDashAsWordUnion + ); + expect(rejectEmDash1Prime.status).not.toBe(0); + + const commitMsgWithEmDashInTitle = `foo: this is — only a title + +Bla blah bla.`; + + const rejectEmDash1DoublePrime = runCommitLintOnMsg( + commitMsgWithEmDashInTitle + ); + expect(rejectEmDash1DoublePrime.status).not.toBe(0); +}); + +test("reject-em-dash2", () => { + const commitMsgWithoutAnyDash = `foo: this is only a title + +Bla blah bla.`; + + const rejectEmDash2 = runCommitLintOnMsg(commitMsgWithoutAnyDash); + expect(rejectEmDash2.status).toBe(0); + + const commitMsgWithNormalDash = `foo: this is only a title + +Foo - bar baz.`; + + const rejectEmDash2Prime = runCommitLintOnMsg(commitMsgWithNormalDash); + expect(rejectEmDash2Prime.status).toBe(0); +}); + test("footer-refs-validity6", () => { const commitMsgWithUrlContainingAnchor = `foo: blah blah diff --git a/package.json b/package.json index cfa3464e4..2e96c7485 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,12 @@ "devDependencies": { "prettier": "2.8.3", "@types/node": "^25.2.0", - "vitest": "^1.3.1", - "husky": "^9.0.0" + "vitest": "^1.3.1" }, "scripts": { "format": "(npx --no-install prettier --version || (echo '\nPlease install `prettier` via `npm install` first; if this problem persists, try `npm rebuild && npm install`' >&2 && exit 1)) && npx --no-install prettier --quote-props=consistent --write './**/*.{yml,ts}'", "format:check": "(npx --no-install prettier --version || (echo '\nPlease install `prettier` via `npm install` first; if this problem persists, try `npm rebuild && npm install`' >&2 && exit 1)) && npx --no-install prettier --quote-props=consistent --check './**/*.{yml,ts}'", "test": "vitest --globals=true", - "prepare": "husky" + "prepare": "npx tsx scripts/git-hooks-setup.ts" } } diff --git a/scripts/checkSingleCommitPR.fsx b/scripts/checkSingleCommitPR.fsx new file mode 100755 index 000000000..8ad81117e --- /dev/null +++ b/scripts/checkSingleCommitPR.fsx @@ -0,0 +1,123 @@ +#!/usr/bin/env -S dotnet fsi + +open System + +#r "nuget: Fsdk, Version=0.6.1--date20260403-0728.git-c9a0eae" +#r "nuget: Mono.Unix, Version=7.1.0-final.1.21458.1" +#r "nuget: YamlDotNet, Version=16.1.3" + +#load "../src/FileConventions/Library.fs" + +open Fsdk +open Fsdk.Process + +let prTitle = + Environment.GetEnvironmentVariable "PR_TITLE" + |> Option.ofObj + |> Option.defaultValue String.Empty + +let prDescription = + Environment.GetEnvironmentVariable "PR_DESCRIPTION" + |> Option.ofObj + |> Option.defaultValue String.Empty + +let numCommitsStr = Environment.GetEnvironmentVariable "PR_COMMITS" +let commitSha = Environment.GetEnvironmentVariable "PR_HEAD_SHA" + +if String.IsNullOrEmpty numCommitsStr then + Console.Error.WriteLine( + "Error: PR_COMMITS environment variable is not set. This script is meant to be run only by GitHubActions triggers, not locally" + ) + + Environment.Exit 1 + +if String.IsNullOrEmpty commitSha then + Console.Error.WriteLine( + "Error: PR_HEAD_SHA environment variable is not set." + ) + + Environment.Exit 1 + +let numCommits = int numCommitsStr + +if numCommits <> 1 then + Console.WriteLine( + sprintf "PR has %i commits. Skipping single-commit check." numCommits + ) + + Environment.Exit 0 + +let commitMessage = + Process + .Execute( + { + Command = "git" + Arguments = sprintf "show --format=%%B --no-patch %s" commitSha + }, + Echo.Off + ) + .UnwrapDefault() + .Trim() + +let commitLines = commitMessage.Split([| '\n' |], StringSplitOptions.None) + +let commitTitle = commitLines.[0].Trim() + +let commitDescription = + let bodyLineStartsAt = 2 + let hasBody = (commitLines.Length > bodyLineStartsAt) + + if hasBody then + String + .Join(Environment.NewLine, commitLines |> Seq.skip bodyLineStartsAt) + .Trim() + else + String.Empty + +let normalizedCommitTitle = FileConventions.RemoveAllWhitespace commitTitle +let normalizedPrTitle = FileConventions.RemoveAllWhitespace prTitle +let titleMatches = normalizedCommitTitle = normalizedPrTitle + +let normalizedCommitDesc = FileConventions.RemoveAllWhitespace commitDescription +let normalizedPrDesc = FileConventions.RemoveAllWhitespace prDescription +let descMatches = normalizedCommitDesc = normalizedPrDesc + +if not titleMatches || not descMatches then + Console.Error.WriteLine( + "Error: For single-commit PRs, the commit message must match the PR title and description." + ) + + Console.Error.WriteLine(String.Empty) + + if not titleMatches then + Console.Error.WriteLine("Title mismatch:") + + Console.Error.WriteLine( + sprintf " Commit msg title (normalized): %s" normalizedCommitTitle + ) + + Console.Error.WriteLine( + sprintf " PR title (normalized): %s" normalizedPrTitle + ) + + if not descMatches then + Console.Error.WriteLine("Description mismatch:") + + Console.Error.WriteLine( + sprintf " Commit msg body (normalized): %s" normalizedCommitDesc + ) + + Console.Error.WriteLine( + sprintf " PR description (normalized): %s" normalizedPrDesc + ) + + Console.Error.WriteLine(String.Empty) + + Console.Error.WriteLine( + "Please sync the PR title and description with the commit message." + ) + + Environment.Exit 1 +else + Console.WriteLine("Commit message matches PR title and description.") + Environment.Exit 0 diff --git a/scripts/git-hooks-setup.ts b/scripts/git-hooks-setup.ts new file mode 100644 index 000000000..ce2233470 --- /dev/null +++ b/scripts/git-hooks-setup.ts @@ -0,0 +1,5 @@ +import { execSync } from "node:child_process"; + +if (!process.env.CI) { + execSync("git config core.hooksPath .husky", { stdio: "inherit" }); +} diff --git a/scripts/wrapLatestCommitMsg.fsx b/scripts/wrapLatestCommitMsg.fsx index a36869cae..0e575fc7f 100755 --- a/scripts/wrapLatestCommitMsg.fsx +++ b/scripts/wrapLatestCommitMsg.fsx @@ -2,32 +2,47 @@ open System.IO open System -open System.Text.RegularExpressions -open System.Linq #r "nuget: Mono.Unix, Version=7.1.0-final.1.21458.1" #r "nuget: YamlDotNet, Version=16.1.3" #load "../src/FileConventions/Library.fs" -#r "nuget: Fsdk, Version=0.6.1--date20260403-0728.git-c9a0eae" +let filePath = + if fsi.CommandLineArgs.Length < 2 then + eprintfn "Usage: dotnet fsi wrapLatestCommitMsg.fsx " + Environment.Exit 1 + failwith "unreachable" + else + fsi.CommandLineArgs.[1] + +let allLines = File.ReadAllLines filePath + +let messageLines = + allLines + |> Seq.takeWhile(fun line -> not(line.StartsWith "#")) + |> Seq.toList -open Fsdk -open Fsdk.Process +let commentLines = + allLines + |> Seq.skipWhile(fun line -> not(line.StartsWith "#")) + |> Seq.toList let commitMsg = - Fsdk - .Process - .Execute( - { - Command = "git" - Arguments = "log -1 --format=%B" - }, - Echo.Off - ) - .UnwrapDefault() + String + .Join(Environment.NewLine, messageLines) .Trim() +// TODO: we should maybe just rather commit-lint instead of having these ad-hoc failures +let ExitProcWithError() = + Environment.Exit 1 + +if FileConventions.HasEmDash commitMsg then + eprintfn + "Error: em-dash character (—) detected in commit message. Please replace it with a normal dash (-) if it is meant as a bullet points or a word-union, or use parentheses if it is meant as a real em-dash." + + ExitProcWithError() + let header, maybeBody = let singleEolToJustSeparateLines = 1u @@ -37,34 +52,36 @@ let header, maybeBody = if lines.Length = 1 then commitMsg, None else - let body = String.Join(Environment.NewLine, lines.Skip 2) + let body = String.Join(Environment.NewLine, Seq.skip 2 lines) lines.[0], Some body +let headerMaxLength = 50 + +if header.Length > headerMaxLength then + eprintfn + $"Error: commit message title exceeds {headerMaxLength} characters (found {header.Length})." + + eprintfn $"Title: {header}" + ExitProcWithError() + let maxCharsPerLine = 64 let maybeWrappedBody = match maybeBody with - | Some body -> Some(FileConventions.WrapText body maxCharsPerLine) + | Some body -> Some(FileConventions.SafeWrapText body maxCharsPerLine) | _ -> None -let EscapeDoubleQuotes(text: string) = - Regex.Replace(text, @"([^\\])""", @"$1\""") - let newCommitMsg = match maybeWrappedBody with | Some wrappedBody -> header + Environment.NewLine + Environment.NewLine + wrappedBody | _ -> header -Fsdk - .Process - .Execute( - { - Command = "git" - Arguments = - $"commit --amend --message \"{EscapeDoubleQuotes newCommitMsg}\"" - }, - Echo.Off - ) - .UnwrapDefault() - .Trim() +let outputLines = + if not(String.IsNullOrWhiteSpace newCommitMsg) then + newCommitMsg.Split([| Environment.NewLine |], StringSplitOptions.None) + |> Seq.toList + else + List.Empty + +File.WriteAllLines(filePath, outputLines @ commentLines) diff --git a/src/FileConventions.Test/WrapTextTests.fs b/src/FileConventions.Test/WrapTextTests.fs index 1e74ba21f..bb226d7a6 100644 --- a/src/FileConventions.Test/WrapTextTests.fs +++ b/src/FileConventions.Test/WrapTextTests.fs @@ -6,6 +6,9 @@ open NUnit.Framework open FileConventions +// because WrapText is marked as deprecated as opposed to SafeWrapText +#nowarn "0044" + [] let WrapTextTest1() = let characterCount = 64 @@ -129,3 +132,217 @@ let WrapTextTest6() = + "```" Assert.That(WrapText commitMsg characterCount, Is.EqualTo commitMsg) + +[] +let WrapTextTest7() = + let characterCount = 64 + let text = "foo: bar" + let fixedText = "foo: bar" + + Assert.That(WrapText text characterCount, Is.EqualTo fixedText) + +[] +let WrapTextTest8() = + let characterCount = 64 + + let text = + "Fixed bug (a title of less than 50 chars) + +This is a bullet list of things: +* Foo. +* Bar." + + Assert.That(WrapText text characterCount, Is.EqualTo text) + +[] +let WrapTextTest9() = + let characterCount = 64 + + let text = + "Fixed bug (a title of less than 50 chars) + +This is a bullet list of things: +- Foo. +- Bar." + + Assert.That(WrapText text characterCount, Is.EqualTo text) + +[] +let WrapTextTest10() = + let characterCount = 64 + + let text = + "Fixed bug (a title of less than 50 chars) + +This is some text in **BOLD** that shouldn't be wrapped." + + Assert.That(WrapText text characterCount, Is.EqualTo text) + +[] +let WrapTextTest11() = + let characterCount = 64 + + let text = + "Fixed bug (a title of less than 50 chars) + +This is some text in +**BOLD** that should be wrapped." + + let expectedText = + "Fixed bug (a title of less than 50 chars) + +This is some text in **BOLD** that should be wrapped." + + Assert.That(WrapText text characterCount, Is.EqualTo expectedText) + +[] +let WrapTextTest12() = + let characterCount = 64 + + let text = + "Fixed bug (a title of less than 50 chars) + +This text's for a multiplication 2 +* 4 equals 8." + + let expectedText = + "Fixed bug (a title of less than 50 chars) + +This text's for a multiplication 2 * 4 equals 8." + + Assert.That(WrapText text characterCount, Is.EqualTo expectedText) + +[] +let WrapTextTest13() = + let characterCount = 64 + + let text = + "Fixed bug (a title of less than 50 chars) + +This is a bullet list of things: +1. Foo. +2. Bar." + + Assert.That(WrapText text characterCount, Is.EqualTo text) + +[] +let WrapTextTest14() = + let characterCount = 64 + + let text = + "change wrapLastCommMsg postCommit->commitMsg hook + +New .husky/commit-msg hook: +- Replaces the old .husky/post-commit hook. +- Receives the commit message file path ($1) and passes it to +the F# script. +- Because it's a commit-msg hook, if the script +exits with a non-zero code, Git aborts the commit (unlike +post-commit, which runs too late). + +Updated scripts/wrapLatestCommitMsg.fsx: +- Reads the commit message from the file path passed as an +argument (instead of git log -1 --format=%B). +- Strips Git +comment lines (# ...) before processing, then preserves them +when writing back. +- Validates the title length against the same +limit as your commitlint policy (headerMaxLineLength = 50). If +the title is too long, it prints an error to stderr and exits +with code 1, blocking the commit. +- Still wraps body paragraphs +to 64 chars using the existing FileConventions.SafeWrapText +logic. +- Writes the result directly back to the commit message +file, so no git commit --amend loop is needed." + + let expectedText = + "change wrapLastCommMsg postCommit->commitMsg hook + +New .husky/commit-msg hook: +- Replaces the old .husky/post-commit hook. +- Receives the commit message file path ($1) and passes it to +the F# script. +- Because it's a commit-msg hook, if the script exits with a +non-zero code, Git aborts the commit (unlike post-commit, which +runs too late). + +Updated scripts/wrapLatestCommitMsg.fsx: +- Reads the commit message from the file path passed as an +argument (instead of git log -1 --format=%B). +- Strips Git comment lines (# ...) before processing, then +preserves them when writing back. +- Validates the title length against the same limit as your +commitlint policy (headerMaxLineLength = 50). If the title is +too long, it prints an error to stderr and exits with code 1, +blocking the commit. +- Still wraps body paragraphs to 64 chars using the existing +FileConventions.SafeWrapText logic. +- Writes the result directly back to the commit message file, so +no git commit --amend loop is needed." + + Assert.That(WrapText text characterCount, Is.EqualTo expectedText) + +[] +let WrapTextTest15() = + let characterCount = 64 + + let textWithAsterisksAndNoColon = + "Fixed bug (a title of less than 50 chars) + +* Foo. +* Bar." + + Assert.That( + WrapText textWithAsterisksAndNoColon characterCount, + Is.EqualTo textWithAsterisksAndNoColon + ) + + let textWithDashesAndNoColon = + "Fixed bug (a title of less than 50 chars) + +- Foo. +- Bar." + + Assert.That( + WrapText textWithDashesAndNoColon characterCount, + Is.EqualTo textWithDashesAndNoColon + ) + + let textWithNumberBulletsAndNoColon = + "Fixed bug (a title of less than 50 chars) + +1. Foo. +2. Bar." + + Assert.That( + WrapText textWithNumberBulletsAndNoColon characterCount, + Is.EqualTo textWithNumberBulletsAndNoColon + ) + +#warnon "0044" + +[] +let HasEmDashTest1() = + let textWithEmDash = "This — is a test" + Assert.That(HasEmDash textWithEmDash, Is.True) + +[] +let HasEmDashTest2() = + let textWithoutEmDash = "This is a test" + Assert.That(HasEmDash textWithoutEmDash, Is.False) + +[] +let HasEmDashTest3() = + let textWithNormalDash = "This - is a test" + Assert.That(HasEmDash textWithNormalDash, Is.False) + +[] +let RemoveAllWhitespaceTest() = + let text = + " Hello -world\t\r\n" + + "Foo \n* bar\r" + + Environment.NewLine + + "baz " + + Assert.That(RemoveAllWhitespace text, Is.EqualTo "Hello-worldFoo*barbaz") diff --git a/src/FileConventions/Library.fs b/src/FileConventions/Library.fs index 6fac0339e..dbec8746a 100644 --- a/src/FileConventions/Library.fs +++ b/src/FileConventions/Library.fs @@ -250,22 +250,81 @@ let SplitIntoWords(text: string) = Seq.toList words let private WrapParagraph (text: string) (maxCharsPerLine: int) : string = - let words = SplitIntoWords text + let words = + SplitIntoWords text + |> List.filter(fun word -> not(String.IsNullOrEmpty word.Text)) let rec processWords (currentLine: string) (wrappedText: string) (remainingWords: List) + (inBulletList: bool) : string = let isColonBreak (currentLine: string) (textAfter: Text) = currentLine.EndsWith ":" && Char.IsUpper textAfter.Text.[0] + let isBulletChar(singleChar: char) = + singleChar = '*' || singleChar = '-' + + let isNumericBullet(text: string) = + let minLengthForNumberPlusPeriod = 2 + + text.Length >= minLengthForNumberPlusPeriod + && text.EndsWith "." + && not(String.IsNullOrEmpty(text.TrimEnd '.')) + && text.TrimEnd '.' |> Seq.forall Char.IsDigit + + let isBulletText(text: string) = + (text.Length = 1 && isBulletChar text.[0]) || isNumericBullet text + + let lineStartsWithBullet(line: string) = + if String.IsNullOrWhiteSpace line then + false + else + let trimmed = line.TrimStart() + let firstSpace = trimmed.IndexOf ' ' + + let firstWord = + if firstSpace < 0 then + trimmed + else + trimmed.Substring(0, firstSpace) + + isBulletText firstWord + + let isBulletBreak(currentLine: string) = + currentLine.EndsWith ":" || lineStartsWithBullet currentLine + match remainingWords with | [] -> (wrappedText + currentLine).Trim() | word :: rest -> + let nowInBulletList = + inBulletList + || (isBulletText word.Text && isBulletBreak currentLine) + match currentLine, word with - | "", _ -> processWords word.Text wrappedText rest + | "", _ -> + let enteredBulletList = isBulletText word.Text + + processWords + word.Text + wrappedText + rest + (inBulletList || enteredBulletList) + // Bullet list point + | _, + { + Type = PlainText + Text = text + } when + isBulletText text && (isBulletBreak currentLine || inBulletList) + -> + processWords + text + (wrappedText + currentLine + Environment.NewLine) + rest + nowInBulletList | _, { Type = PlainText @@ -274,7 +333,11 @@ let private WrapParagraph (text: string) (maxCharsPerLine: int) : string = <= maxCharsPerLine && not(isColonBreak currentLine word) -> - processWords (currentLine + " " + word.Text) wrappedText rest + processWords + (currentLine + " " + word.Text) + wrappedText + rest + nowInBulletList | _, { Type = PlainText @@ -283,6 +346,7 @@ let private WrapParagraph (text: string) (maxCharsPerLine: int) : string = word.Text (wrappedText + currentLine + Environment.NewLine) rest + nowInBulletList | _, _ -> processWords String.Empty @@ -292,8 +356,9 @@ let private WrapParagraph (text: string) (maxCharsPerLine: int) : string = + word.Text + Environment.NewLine) rest + nowInBulletList - processWords String.Empty String.Empty words + processWords String.Empty String.Empty words false // This function will extract paragraphs and will ignore the paragraphs inside a // code block. Each paragraph is determined by two consecutive new lines. @@ -360,7 +425,7 @@ let ExtractParagraphs(text: string) = List.rev <| processLines lines List.Empty List.Empty false -let WrapText (text: string) (maxCharsPerLine: int) : string = +let internal WrapTextInternal (text: string) (maxCharsPerLine: int) : string = let wrappedParagraphs = ExtractParagraphs text |> Seq.map(fun paragraph -> WrapParagraph paragraph maxCharsPerLine) @@ -370,6 +435,28 @@ let WrapText (text: string) (maxCharsPerLine: int) : string = wrappedParagraphs ) +[] +let WrapText (text: string) (maxCharsPerLine: int) : string = + WrapTextInternal text maxCharsPerLine + +let RemoveAllWhitespace(text: string) : string = + String.filter (Char.IsWhiteSpace >> not) text + +let SafeWrapText (text: string) (maxCharsPerLine: int) : string = + let wrappedText = WrapTextInternal text maxCharsPerLine + + let sanityCheck (originalText: string) (wrappedText: string) = + if RemoveAllWhitespace originalText <> RemoveAllWhitespace wrappedText then + failwith "WrapText func didn't work, please report this bug" + + sanityCheck text wrappedText + + wrappedText + +let HasEmDash(text: string) : bool = + let emDashChar = '\u2014' + text.Contains emDashChar + let private GetVersionsMapFromFiles (fileInfos: seq) (versionRegexPattern: string)