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)