From 5187aa13aa5cb9bed9eae1125e24218ab903a126 Mon Sep 17 00:00:00 2001
From: Mark Olesen <Mark.Olesen@esi-group.com>
Date: Wed, 21 Nov 2018 19:55:28 +0100
Subject: [PATCH] ENH: improve output formatting for usage information

- generalize output text wrapping, use for usage notes

- add -help-man option for generating manpage content for any OpenFOAM
  application or solver.

  bin/tools/foamCreateManpage as helper
---
 bin/tools/foamCreateCompletionCache           |  12 +-
 bin/tools/foamCreateManpage                   | 172 ++++++++++++++
 etc/config.sh/bash_completion                 |   8 +-
 src/OpenFOAM/Make/files                       |   1 +
 src/OpenFOAM/global/argList/argList.C         | 112 ++-------
 src/OpenFOAM/global/argList/argList.H         |   3 +
 src/OpenFOAM/global/argList/argListHelp.C     | 222 ++++++++++++++++++
 .../primitives/strings/stringOps/stringOps.C  | 110 ++++++++-
 .../primitives/strings/stringOps/stringOps.H  |  19 ++
 9 files changed, 565 insertions(+), 94 deletions(-)
 create mode 100755 bin/tools/foamCreateManpage
 create mode 100644 src/OpenFOAM/global/argList/argListHelp.C

diff --git a/bin/tools/foamCreateCompletionCache b/bin/tools/foamCreateCompletionCache
index 4a8f56a144b..cf1bcdef507 100755
--- a/bin/tools/foamCreateCompletionCache
+++ b/bin/tools/foamCreateCompletionCache
@@ -140,7 +140,8 @@ HEADER
 #   -opt1         descrip
 #   -opt2 <arg>   descrip
 #   -help-full
-# Terminate parsing on first appearance of -help-full
+# Ignore -help-man (internal option).
+# Terminate parsing on first appearance of -help-full.
 # - options with '=' (eg, -mode=ugo) are not handled very well at all.
 # - alternatives (eg, -a, -all) are not handled nicely either,
 #   for these treat ',' like a space to catch the worst of them.
@@ -148,12 +149,15 @@ extractOptions()
 {
     local appName="$1"
     local helpText=$($appName -help-full 2>/dev/null | \
-        sed -ne 's/^ *//; /^$/d; /^[^-]/d; /^--/d;' \
+        sed -ne 's/^ *//; /^$/d; /^[^-]/d; /^--/d; /^-help-man/d;' \
             -e 'y/,/ /; s/=.*$/=/;' \
             -e '/^-[^ ]* </{ s/^\(-[^ ]* <\).*$/\1/; p; d }' \
             -e 's/^\(-[^ ]*\).*$/\1/; p; /^-help-full/q;' \
             )
 
+    # After this bit of sed, we have "-opt1" or "-opt2 <"
+    # for entries without or with arguments.
+
     [ -n "$helpText" ] || {
         echo "Error calling $appName" 1>&2
         return 1
@@ -163,8 +167,8 @@ extractOptions()
     local argOpts=($(awk '/</ {print $1}' <<< "$helpText"))
 
     # Array of options without args, but skip the following:
-    #     -help-compat -help-full
-    local boolOpts=($(awk '!/</ && !/help-(compat|full)/ {print $1}' <<< "$helpText"))
+    #     -help-compat -help-full etc
+    local boolOpts=($(awk '!/</ && !/help-/ {print $1}' <<< "$helpText"))
 
     appName="${appName##*/}"
     echo "$appName" 1>&2
diff --git a/bin/tools/foamCreateManpage b/bin/tools/foamCreateManpage
new file mode 100755
index 00000000000..ed7ad21ff19
--- /dev/null
+++ b/bin/tools/foamCreateManpage
@@ -0,0 +1,172 @@
+#!/bin/sh
+#------------------------------------------------------------------------------
+# =========                 |
+# \\      /  F ield         | OpenFOAM: The Open Source CFD Toolbox
+#  \\    /   O peration     |
+#   \\  /    A nd           | Copyright (C) 2018 OpenCFD Ltd.
+#    \\/     M anipulation  |
+#------------------------------------------------------------------------------
+# License
+#     This file is part of OpenFOAM, licensed under GNU General Public License
+#     <http://www.gnu.org/licenses/>.
+#
+# Script
+#     foamCreateManpage
+#
+# Description
+#     Query OpenFOAM applications with -help-man to generate manpage content.
+#
+#------------------------------------------------------------------------------
+defaultOutputDir="$WM_PROJECT_DIR/doc/man1"
+
+usage() {
+    exec 1>&2
+    while [ "$#" -ge 1 ]; do echo "$1"; shift; done
+    cat<<USAGE
+
+Usage: ${0##*/} [OPTION] [appName .. [appNameN]]
+options:
+  -dir dir          Directory to process
+  -gzip             Compressed output
+  -o DIR            Write to alternative output directory
+  -version VER      Specify an alternative version
+  -h | -help        Print the usage
+
+Query OpenFOAM applications with -help-man for their manpage content
+and redirect to corresponding directory location.
+Default input:  \$FOAM_APPBIN only.
+Default output: $defaultOutputDir
+
+Uses the search directory if applications are specified.
+
+Copyright (C) 2018 OpenCFD Ltd.
+USAGE
+    exit 1
+}
+
+# Report error and exit
+die()
+{
+    exec 1>&2
+    echo
+    echo "Error encountered:"
+    while [ "$#" -ge 1 ]; do echo "    $1"; shift; done
+    echo
+    echo "See '${0##*/} -help' for usage"
+    echo
+    exit 1
+}
+
+#-------------------------------------------------------------------------------
+searchDirs="$FOAM_APPBIN"
+unset gzipFilter sedFilter outputDir
+
+while [ "$#" -gt 0 ]
+do
+    case "$1" in
+    -h | -help*)
+        usage
+        ;;
+    -d | -dir)
+        [ "$#" -ge 2 ] || die "'$1' option requires an argument"
+        searchDirs="$2"
+        [ -d "$searchDirs" ] || die "directory not found '$searchDirs'"
+        shift
+        ;;
+    -gz | -gzip)
+        gzipFilter="gzip"
+        ;;
+    -v | -version)
+        [ "$#" -ge 2 ] || die "'$1' option requires an argument"
+        version="$2"
+        sedFilter='s/OpenFOAM-[^\"]*/OpenFOAM-'"$version/"
+        shift
+        ;;
+    -o | -output)
+        [ "$#" -ge 2 ] || die "'$1' option requires an argument"
+        outputDir="$2"
+        shift
+        ;;
+    -*)
+        die "unknown option: '$1'"
+        ;;
+    *)
+        break
+        ;;
+    esac
+    shift
+done
+
+: ${outputDir:=$defaultOutputDir}
+
+# Verify that output is writeable
+if [ -e "$outputDir" ]
+then
+    [ -d "$outputDir" ] && [ -w "$outputDir" ] || \
+        die "Cannot write to $outputDir" "Not a directory, or no permission?"
+else
+    mkdir -p "$outputDir" || \
+        die "Cannot create directory: $outputDir"
+fi
+
+#-------------------------------------------------------------------------------
+
+# Pass through content, filter for version and/or gzip
+#
+
+tmpFile="$outputDir/${0##*/}"
+trap "rm -fv $tmpFile >/dev/null; exit 0" EXIT TERM INT
+
+process()
+{
+    local appName="$1"
+    local outFile="$outputDir/${appName##*/}.1"
+
+    rm -f "$outFile"*;
+
+    "$appName" -help-man 2>/dev/null >| $tmpFile;
+
+    if grep -F -q "SYNOPSIS" "$tmpFile" 2>/dev/null
+    then
+        cat "$tmpFile" | \
+            sed -e "${sedFilter:-p}" | "${gzipFilter:-cat}" \
+            >| "$outFile${gzipFilter:+.gz}"
+
+        echo "$outFile${gzipFilter:+.gz}" 1>&2
+    else
+        echo "Problem with $appName" 1>&2
+    fi
+}
+
+#------------------------------------------------------------------------------
+
+# Default to standard search directories
+[ "$#" -gt 0 ] || set -- ${searchDirs}
+
+echo "Generating manpages from OpenFOAM applications" 1>&2
+echo 1>&2
+
+for item
+do
+    if [ -d "$item" ]
+    then
+        # Process directory for applications - sort with ignore-case
+        echo "[directory] $item" 1>&2
+        choices="$(find $item -maxdepth 1 -executable -type f | sort -f 2>/dev/null)"
+        for appName in $choices
+        do
+            process $appName
+        done
+    elif command -v "$item" > /dev/null 2>&1
+    then
+        process $item
+    else
+        echo "No such file or directory: $item" 1>&2
+    fi
+done
+
+echo 1>&2
+echo "Done" 1>&2
+
+
+# -----------------------------------------------------------------------------
diff --git a/etc/config.sh/bash_completion b/etc/config.sh/bash_completion
index 0a89e3f643b..5fb8b874c9e 100644
--- a/etc/config.sh/bash_completion
+++ b/etc/config.sh/bash_completion
@@ -109,7 +109,7 @@ _of_complete_()
     local choices
 
     case ${prev} in
-    -help | -help-compat | -help-full | -doc | -doc-source)
+    -help | -help-compat | -help-full | -help-man | -doc | -doc-source)
         # These options are usage - we can stop now.
         COMPREPLY=()
         return 0
@@ -138,6 +138,7 @@ _of_complete_()
         #   -opt1         descrip
         #   -opt2 <arg>   descrip
         #   -help-full
+        # Ignore -help-man (internal option).
         # Terminate parsing on first appearance of -help-full
         # - options with '=' (eg, -mode=ugo) are not handled very well at all.
         # - alternatives (eg, -a, -all) are not handled nicely either,
@@ -145,12 +146,15 @@ _of_complete_()
         if [ -z "$choices" ]
         then
             local helpText=$($appName -help-full 2>/dev/null | \
-                sed -ne 's/^ *//; /^$/d; /^[^-]/d; /^--/d;' \
+                sed -ne 's/^ *//; /^$/d; /^[^-]/d; /^--/d; /^-help-man/d;' \
                     -e 'y/,/ /; s/=.*$/=/;' \
                     -e '/^-[^ ]* </{ s/^\(-[^ ]* <\).*$/\1/; p; d }' \
                     -e 's/^\(-[^ ]*\).*$/\1/; p; /^-help-full/q;' \
                     )
 
+            # After this bit of sed, we have "-opt1" or "-opt2 <"
+            # for entries without or with arguments.
+
             if [ -z "$helpText" ]
             then
                 echo "Error calling $appName" 1>&2
diff --git a/src/OpenFOAM/Make/files b/src/OpenFOAM/Make/files
index 90ba8622095..a9466aa28d9 100644
--- a/src/OpenFOAM/Make/files
+++ b/src/OpenFOAM/Make/files
@@ -2,6 +2,7 @@ global/global.Cver
 /* global/constants/constants.C in global.Cver */
 /* global/constants/dimensionedConstants.C in global.Cver */
 global/argList/argList.C
+global/argList/argListHelp.C
 global/clock/clock.C
 global/profiling/profiling.C
 global/profiling/profilingInformation.C
diff --git a/src/OpenFOAM/global/argList/argList.C b/src/OpenFOAM/global/argList/argList.C
index e6cd41aca35..245f5d67e5a 100644
--- a/src/OpenFOAM/global/argList/argList.C
+++ b/src/OpenFOAM/global/argList/argList.C
@@ -436,9 +436,7 @@ void Foam::argList::printOptionUsage
     const string& str
 )
 {
-    const auto strLen = str.length();
-
-    if (!strLen)
+    if (str.empty())
     {
         Info<< nl;
         return;
@@ -456,84 +454,7 @@ void Foam::argList::printOptionUsage
         ++start;
     }
 
-    const std::string::size_type textWidth = (usageMax - usageMin);
-
-    // Output with text wrapping
-    for (std::string::size_type pos = 0;  pos < strLen; /*ni*/)
-    {
-        // Potential end point and next point
-        std::string::size_type end  = pos + textWidth - 1;
-        std::string::size_type eol  = str.find('\n', pos);
-        std::string::size_type next = string::npos;
-
-        if (end >= strLen)
-        {
-            // No more wrapping needed
-            end = strLen;
-
-            if (std::string::npos != eol && eol <= end)
-            {
-                end = eol;
-                next = str.find_first_not_of(" \t\n", end); // Next non-space
-            }
-        }
-        else if (std::string::npos != eol && eol <= end)
-        {
-            // Embedded '\n' char
-            end = eol;
-            next = str.find_first_not_of(" \t\n", end);     // Next non-space
-        }
-        else if (isspace(str[end]))
-        {
-            // Ended on a space - can use this directly
-            next = str.find_first_not_of(" \t\n", end);     // Next non-space
-        }
-        else if (isspace(str[end+1]))
-        {
-            // The next one is a space - so we are okay
-            ++end;  // Otherwise the length is wrong
-            next = str.find_first_not_of(" \t\n", end);     // Next non-space
-        }
-        else
-        {
-            // Line break will be mid-word
-            auto prev = str.find_last_of(" \t\n", end);     // Prev word break
-
-            if (std::string::npos != prev && prev > pos)
-            {
-                end = prev;
-                next = prev + 1;  // Continue from here
-            }
-        }
-
-        // The next position to continue from
-        if (std::string::npos == next)
-        {
-            next = end + 1;
-        }
-
-        // Has a length
-        if (end > pos)
-        {
-            // Indent following lines. The first one was already done.
-            if (pos)
-            {
-                for (std::string::size_type i = 0; i < usageMin; ++i)
-                {
-                    Info<<' ';
-                }
-            }
-
-            while (pos < end)
-            {
-                Info<< str[pos];
-                ++pos;
-            }
-            Info<< nl;
-        }
-
-        pos = next;
-    }
+    stringOps::writeWrapped(Info, str, (usageMax-usageMin), usageMin);
 }
 
 
@@ -960,6 +881,11 @@ void Foam::argList::parse
             printUsage(false);
             quickExit = true;
         }
+        else if (options_.found("help-man"))
+        {
+            printMan();
+            quickExit = true;
+        }
 
         // Allow independent display of compatibility information
         if (options_.found("help-compat"))
@@ -1584,13 +1510,21 @@ bool Foam::argList::unsetOption(const word& optName)
 
 void Foam::argList::printNotes() const
 {
-    // Output notes directly - no automatic text wrapping
+    // Output notes with automatic text wrapping
     if (!notes.empty())
     {
         Info<< nl;
-        forAllConstIters(notes, iter)
+
+        for (const std::string& note : notes)
         {
-            Info<< iter().c_str() << nl;
+            if (note.empty())
+            {
+                Info<< nl;
+            }
+            else
+            {
+                stringOps::writeWrapped(Info, note, usageMax);
+            }
         }
     }
 }
@@ -1610,10 +1544,10 @@ void Foam::argList::printUsage(bool full) const
         }
 
         label i = 0;
-        forAllConstIters(validArgs, iter)
+        for (const std::string& argName : validArgs)
         {
             if (i++) Info<< ' ';
-            Info<< '<' << iter().c_str() << '>';
+            Info<< '<' << argName.c_str() << '>';
         }
 
         if (!argsMandatory_)
@@ -1677,6 +1611,12 @@ void Foam::argList::printUsage(bool full) const
         printOptionUsage(14, "Display compatibility options and exit");
     }
 
+    if (full)
+    {
+        Info<< "  -help-man";
+        printOptionUsage(11, "Display full help (manpage format) and exit");
+    }
+
     Info<< "  -help-full";
     printOptionUsage(12, "Display full help and exit");
 
diff --git a/src/OpenFOAM/global/argList/argList.H b/src/OpenFOAM/global/argList/argList.H
index 9979bdbac32..b287fc6d2d1 100644
--- a/src/OpenFOAM/global/argList/argList.H
+++ b/src/OpenFOAM/global/argList/argList.H
@@ -507,6 +507,9 @@ public:
         //- Print usage
         void printUsage(bool full=true) const;
 
+        //- Print usage as nroff-man format (Experimental)
+        void printMan() const;
+
         //- Display documentation in browser
         //  Optionally display the application source code
         void displayDoc(bool source=false) const;
diff --git a/src/OpenFOAM/global/argList/argListHelp.C b/src/OpenFOAM/global/argList/argListHelp.C
new file mode 100644
index 00000000000..04a99fa628a
--- /dev/null
+++ b/src/OpenFOAM/global/argList/argListHelp.C
@@ -0,0 +1,222 @@
+/*---------------------------------------------------------------------------*\
+  =========                 |
+  \\      /  F ield         | OpenFOAM: The Open Source CFD Toolbox
+   \\    /   O peration     |
+    \\  /    A nd           | Copyright (C) 2018 OpenCFD Ltd.
+     \\/     M anipulation  |
+-------------------------------------------------------------------------------
+License
+    This file is part of OpenFOAM.
+
+    OpenFOAM is free software: you can redistribute it and/or modify it
+    under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    OpenFOAM is distributed in the hope that it will be useful, but WITHOUT
+    ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+    FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+    for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with OpenFOAM.  If not, see <http://www.gnu.org/licenses/>.
+
+\*---------------------------------------------------------------------------*/
+
+#include "argList.H"
+#include "stringOps.H"
+
+// * * * * * * * * * * * * * * * Local Functions * * * * * * * * * * * * * * //
+
+namespace Foam
+{
+
+// manpage Footer
+static inline void printManFooter()
+{
+    Info<< ".SH \"SEE ALSO\"" << nl
+        << "Online documentation "
+        << "https://www.openfoam.com/documentation/" << nl
+        << ".SH COPYRIGHT" << nl
+        << "Copyright 2018 OpenCFD Ltd." << nl;
+}
+
+
+static void printManOption(const word& optName)
+{
+    Info<< ".TP" << nl << ".B \\-" << optName;
+
+    // Option has arg?
+    const auto optIter = argList::validOptions.cfind(optName);
+    if (optIter().size())
+    {
+        Info<< " <" << optIter().c_str() << '>';
+    }
+    Info<< nl;
+
+    // Option has usage information?
+
+    const auto usageIter = argList::optionUsage.cfind(optName);
+    if (usageIter.found())
+    {
+        stringOps::writeWrapped(Info, *usageIter, argList::usageMax, 0, true);
+    }
+    else
+    {
+        Info<< nl;
+    }
+}
+
+} // End namespace Foam
+
+
+// * * * * * * * * * * * * * * * Member Functions  * * * * * * * * * * * * * //
+
+void Foam::argList::printMan() const
+{
+    // .TH "<APPLICATION>" 1 "OpenFOAM-<version>" "source" "category"
+
+    Info
+        << ".TH" << token::SPACE
+        // All uppercase and quoted
+        << stringOps::upper(executable_) << token::SPACE
+        << "\"1\"" << token::SPACE
+        << token::DQUOTE << "OpenFOAM-v" << OPENFOAM << token::DQUOTE
+        << token::SPACE
+        << token::DQUOTE << "www.openfoam.com" << token::DQUOTE
+        << token::SPACE
+        << token::DQUOTE << "OpenFOAM Commands Manual" << token::DQUOTE
+        << nl;
+
+
+    // .SH NAME
+    // <application> \- part of OpenFOAM (The Open Source CFD Toolbox).
+    Info
+        << ".SH \"NAME\"" << nl
+        << executable_
+        << " \\- part of OpenFOAM (The Open Source CFD Toolbox)."
+        << nl;
+
+
+    // .SH SYNOPSIS
+    // .B command [OPTIONS] ...
+
+    Info
+        << ".SH \"SYNOPSIS\"" << nl
+        << ".B " << executable_ << " [OPTIONS]";
+
+    if (validArgs.size())
+    {
+        Info<< ' ';
+
+        if (!argsMandatory_)
+        {
+            Info<< '[';
+        }
+
+        label i = 0;
+        for (const std::string& argName : validArgs)
+        {
+            if (i++) Info<< ' ';
+            Info << '<' << argName.c_str() << '>';
+        }
+
+        if (!argsMandatory_)
+        {
+            Info<< ']';
+        }
+    }
+    Info<< nl;
+
+
+    // .SH DESCRIPTION
+    {
+        Info
+            << ".SH \"DESCRIPTION\"" << nl;
+
+        Info<< ".nf" << nl;
+
+        if (notes.empty())
+        {
+            Info<<"No description available\n";
+        }
+        else
+        {
+            Info<< nl;
+            for (const std::string& note : notes)
+            {
+                if (note.empty())
+                {
+                    Info<< nl;
+                }
+                else
+                {
+                    stringOps::writeWrapped(Info, note, usageMax, 0, true);
+                }
+            }
+        }
+        Info<< ".fi" << nl;
+    }
+
+
+    // .SH "OPTIONS"
+    Info
+        << ".SH \"OPTIONS\"" << nl;
+
+    for (const word& optName : validOptions.sortedToc())
+    {
+        // Normal options
+        if (!advancedOptions.found(optName))
+        {
+            printManOption(optName);
+        }
+    }
+
+    // Standard documentation/help options
+
+    Info<< ".TP" << nl << ".B \\-" << "doc" << nl
+        <<"Display documentation in browser" << nl;
+
+    Info<< ".TP" << nl << ".B \\-" << "doc-source" << nl
+        << "Display source code in browser" << nl;
+
+    Info<< ".TP" << nl << ".B \\-" << "help" << nl
+        << "Display short help and exit" << nl;
+
+    Info<< ".TP" << nl << ".B \\-" << "help-full" << nl
+        << "Display full help and exit" << nl;
+
+
+    // .SH "ADVANCED OPTIONS"
+    Info
+        << ".SH \"ADVANCED OPTIONS\"" << nl;
+
+    for (const word& optName : validOptions.sortedToc())
+    {
+        // Advanced options
+        if (advancedOptions.found(optName))
+        {
+            printManOption(optName);
+        }
+    }
+
+    // Compatibility information
+    if
+    (
+        argList::validOptionsCompat.size()
+      + argList::ignoreOptionsCompat.size()
+    )
+    {
+        Info<< ".TP" << nl << ".B \\-" << "help-compat" << nl
+            << "Display compatibility options and exit" << nl;
+    }
+
+    Info<< ".TP" << nl << ".B \\-" << "help-man" << nl
+        << "Display full help (manpage format) and exit" << nl;
+
+    // Footer
+    printManFooter();
+}
+
+
+// ************************************************************************* //
diff --git a/src/OpenFOAM/primitives/strings/stringOps/stringOps.C b/src/OpenFOAM/primitives/strings/stringOps/stringOps.C
index 5ed3841e3fb..85dd6c0c580 100644
--- a/src/OpenFOAM/primitives/strings/stringOps/stringOps.C
+++ b/src/OpenFOAM/primitives/strings/stringOps/stringOps.C
@@ -25,9 +25,10 @@ License
 
 #include "stringOps.H"
 #include "typeInfo.H"
-#include "OSspecific.H"
 #include "etcFiles.H"
 #include "StringStream.H"
+#include "OSstream.H"
+#include "OSspecific.H"
 #include <cctype>
 
 // * * * * * * * * * * * * * * * Local Functions * * * * * * * * * * * * * * //
@@ -80,7 +81,7 @@ static void expandLeadingTag(std::string& s, const char b, const char e)
     }
     else if (tag == "case")
     {
-        s = fileName(getEnv("FOAM_CASE"))/file;
+        s = fileName(Foam::getEnv("FOAM_CASE"))/file;
     }
     else if (tag == "constant" || tag == "system")
     {
@@ -1090,4 +1091,109 @@ void Foam::stringOps::inplaceUpper(std::string& s)
 }
 
 
+void Foam::stringOps::writeWrapped
+(
+    OSstream& os,
+    const std::string& str,
+    const std::string::size_type width,
+    const std::string::size_type indent,
+    const bool escape
+)
+{
+    const auto len = str.length();
+
+    std::string::size_type pos = 0;
+
+    // Handle leading newlines
+    while (str[pos] == '\n' && pos < len)
+    {
+        os << '\n';
+        ++pos;
+    }
+
+    while (pos < len)
+    {
+        // Potential end point and next point
+        std::string::size_type end  = pos + width - 1;
+        std::string::size_type eol  = str.find('\n', pos);
+        std::string::size_type next = string::npos;
+
+        if (end >= len)
+        {
+            // No more wrapping needed
+            end = len;
+
+            if (std::string::npos != eol && eol <= end)
+            {
+                // Manual '\n' break, next follows it (default behaviour)
+                end = eol;
+            }
+        }
+        else if (std::string::npos != eol && eol <= end)
+        {
+            // Manual '\n' break, next follows it (default behaviour)
+            end = eol;
+        }
+        else if (isspace(str[end]))
+        {
+            // Ended on a space - can use this directly
+            next = str.find_first_not_of(" \t\n", end);     // Next non-space
+        }
+        else if (isspace(str[end+1]))
+        {
+            // The next one is a space - so we are okay
+            ++end;  // Otherwise the length is wrong
+            next = str.find_first_not_of(" \t\n", end);     // Next non-space
+        }
+        else
+        {
+            // Line break will be mid-word
+            auto prev = str.find_last_of(" \t\n", end);     // Prev word break
+
+            if (std::string::npos != prev && prev > pos)
+            {
+                end = prev;
+                next = prev + 1;  // Continue from here
+            }
+        }
+
+        // The next position to continue from
+        if (std::string::npos == next)
+        {
+            next = end + 1;
+        }
+
+        // Has a length
+        if (end > pos)
+        {
+            // Indent following lines.
+            // The first one was already done prior to calling this routine.
+            if (pos)
+            {
+                for (std::string::size_type i = 0; i < indent; ++i)
+                {
+                    os <<' ';
+                }
+            }
+
+            while (pos < end)
+            {
+                const char c = str[pos];
+
+                if (escape && c == '\\')
+                {
+                    os << '\\';
+                }
+                os << c;
+
+                ++pos;
+            }
+            os << nl;
+        }
+
+        pos = next;
+    }
+}
+
+
 // ************************************************************************* //
diff --git a/src/OpenFOAM/primitives/strings/stringOps/stringOps.H b/src/OpenFOAM/primitives/strings/stringOps/stringOps.H
index 85414c90087..b31f9383223 100644
--- a/src/OpenFOAM/primitives/strings/stringOps/stringOps.H
+++ b/src/OpenFOAM/primitives/strings/stringOps/stringOps.H
@@ -49,6 +49,9 @@ SourceFiles
 namespace Foam
 {
 
+// Forward declarations
+class OSstream;
+
 /*---------------------------------------------------------------------------*\
                         Namespace stringOps Declaration
 \*---------------------------------------------------------------------------*/
@@ -382,6 +385,22 @@ namespace stringOps
         const StringType& str
     );
 
+    //- Output string with text wrapping.
+    //  Always includes a trailing newline, unless the string itself is empty.
+    //
+    //  \param os the output stream
+    //  \param str the text to be output
+    //  \param width the max-width before wrapping
+    //  \param indent indentation for continued lines
+    //  \param escape escape any backslashes on output
+    void writeWrapped
+    (
+        OSstream& os,
+        const std::string& str,
+        const std::string::size_type width,
+        const std::string::size_type indent = 0,
+        const bool escape = false
+    );
 
 } // End namespace stringOps
 
-- 
GitLab