String.Format for Visual FoxPro

[Originally Published in FoxRockX, September 2010]

If you haven’t used C#’s String.Format() method, it’s a real treat. There’s nothing natively like it in VFP, but we can certainly roll our own equivalent. This will make our code easier to read and maintain. This article will show why you want to do that, and how to do it.

I try to do as much work as I can in Visual FoxPro, but there are times when another tool is right for the job and this can expose us to different features and philosophies of other languages. Recently I was working on an Access conversion to SQL Server/ASP.Net for a client, and I used C# for the web page logic. In my opinion C# is an easy transition for Visual FoxPro programmers, as the syntax is similar enough to make you feel comfortable.
As I was working in C#, I began falling in love with a particular method: String.Format(). This command is like Visual FoxPro’s TRANSFORM() command, but supercharged.

For example, suppose you wanted to print the following line on the bottom of a receipt:

Joe, your total today, August 16th, 2009, was $150.00 on 2 items. Thanks, Joe!

In Visual FoxPro, you might do something like this:
ALLT(cUser)+", your total today, " + ;
CMONTH(dDate)+" "+TRANSFORM(DAY(dDate))+;
", "+TRANSFORM(YEAR(dDate))+", was " + ;
ALLT(TRANSFORM(nTotal,"@$#,##9.99")) + ;
" on " + TRANSFORM(iItems)+". Thanks " ;
+ ALLT(cUser)+"!"

Not only is that a little bit hard to read, it’s also not very flexible. For example, if your client now wanted the receipt to say

Joe, you spent $150.00 on 2 items today, 08/16/09 at 08:30am. Thanks, Joe!

you’d have to do quite a bit of cutting and pasting to move things around. (Remember, these are very simple examples. Real world stuff would be much more difficult.)

You may be thinking, “How about TEXTMERGE? That’s a pretty sweet function, and would make this much better.” Yes, TEXTMERGE is better. That cuts it down to
TEXTMERGE([<>, your total today, ;
<>, was ;
<>
on <>. Thanks,
<>])
But it still leaves the issue of easily switching date formats and ensuring you get the month, day, and year in the right order no matter which country you’re in. String.Format does all that for you. Here’s the equivalent C# method call:
String.Format("{0}, your total today, {1:d} was {2:c} on {3} items. Thanks, {0}.", cUser, dDate, nTotal, iItems)
Now I don’t know about you, but I think that’s pretty easy to read. Each {} is a reference to the parameter (zero-based) that follows the string, and it may contain an optional formatting code after the colon (as parameters 1 and 2 do in the example above). Those formatting codes are well defined in the C# spec, and outlined on MSDN (http://msdn.microsoft.com/en-us/library/az4se3k1.aspx), though you can probably figure them out just by looking at the large CASE statement in the code below. I also like the way I can easily re-use parameters, such as {0} above, when I want the same thing printed multiple times.

I would love to have this power and flexibility available to me as a Visual FoxPro programmer, so I Googled the phrase “Visual FoxPro String.Format” and up came an article by Jürgen Wondzinski (aka wOOdy) in the MSDN Code Gallery (http://code.msdn.microsoft.com/FoxPro/Release/ProjectReleases.aspx?ReleaseId=442) that starts us down the road:

********************************************
FUNCTION StringFormat
********************************************
* Mimics the String.Format() Method of NET
********************************************
LPARAMETERS cString, vPara0, vPara1, vPara2,;
vPara3, vPara4, vPara5, vPara6, vPara7, ;
vPara8, vPara9
LOCAL nCount, cCount, cReturn, cSearch, ;
cFormat
cReturn = cString
FOR nCount = 1 TO OCCURS("{", cString)
cSearch = STREXTRACT(cString, "{", "}", ;
nCount, 4)
cFormat = STREXTRACT(cSearch, ":", "}")
cCount = CHRTRAN(STRTRAN(cSearch, ;
cFormat,""), "{:}","")
IF EMPTY(cFormat)
cReturn = STRTRAN(cReturn, cSearch, ;
TRANSFORM(EVALUATE("vPara"+cCount)))
ELSE
cReturn = STRTRAN(cReturn, cSearch, ;
TRANSFORM(EVALUATE("vPara"+cCount),;
cFormat))
ENDIF
ENDFOR
RETURN cReturn

This clever code parses the parameters perfectly, but it doesn’t translate all of the formatting codes that .NET’s String.Format() has. It’s also procedural code, and I like everything to be a class. Let’s take care of that first, rewriting the code as a class. Unlike Visual FoxPro, C#’s strings are objects, so I’m going to create a class called String and have Format() be a public method of that class to maintain consistency. To do that we just wrap the function above in a DEFINE CLASS STRING AS Custom … ENDDEFINE, and change the name from StringFormat to just Format.

Now we can call it similarly to C#:
String=CREATEOBJECT("STRING")
? String.Format("{0}, your total today,
{1:MM/dd/yyyy}, was {2:@$} on {3} items.
Thanks, {0}!", cUser, dDate, nTotal,
iItems)

Pretty slick. Now let’s make it so our Format() method handles all of the same formatting codes that C# does. In order to do this, we’ll have to know what the VARTYPE() of each parameter is, because the same code means different things for different variable types. For example {0:f} means print a fixed point if the first parameter’s a number, but print the full date if the variable is a date or datetime. So we’ll modify a bit of wOOdy’s code above, doing a test for vartype() and calling a specific method for dates and datetimes and another for numbers:
IF EMPTY(cFormat)
cReturn = STRTRAN(cReturn, cSearch, ;
TRANSFORM(EVALUATE("vPara"+cCount)))
ELSE
xParam = EVALUATE("vPara"+cCount)
DO CASE
CASE INLIST(VARTYPE(xParam),'D','T')
cReturn = STRTRAN(cReturn, cSearch, ;
This.DateFormat(xParam, cFormat))
CASE INLIST(VARTYPE(xParam),'N','Y')
cReturn = STRTRAN(cReturn, cSearch, ;
This.NumericFormat(xParam, cFormat))
OTHERWISE
cReturn = STRTRAN(cReturn, cSearch, ;
TRANSFORM(xParam,cFormat) )
ENDCASE
ENDIF

Now we just need to add these new methods to our class, and we’re done. The first method we’ll add, DateFormat, takes a date or datetime parameter and converts it based on the format code that was sent in. If the first parameter is of type date rather than datetime, the time portion will be assumed to be midnight (00:00:00).
One twist to this is that they may send either one of the predefined format codes, such as ‘M’ to just return the ‘month-day’ pattern, ‘October 16’, or they may explicitly send in a custom format, such as ‘MMM dd’ to get an Excel-like ‘Oct 16’. To handle this we’re actually going to break the DateFormat method into two methods: DateFormat which spells out the predefined format codes into their explicit formats, and ParseDataFormat that interprets that format. I made these functions PROTECTED because I didn’t want anyone to explicitly call them from an instantiated String object.

PROTECTED FUNCTION DateFormat
LPARAMETERS dtConvert, cFormat
LOCAL cDate, cCentury, dConvert, cResult
cResult = ""
IF VARTYPE(dtConvert)="D"
* Default time to midnight
dConvert = dtConvert
dtConvert = DTOT(dConvert)
ELSE
dConvert = TTOD(dtConvert)
ENDIF
IF LEN(cFormat)=1
IF INLIST(cFormat, 'r', 'u', 'U')
* Adjust time to GMT
DECLARE INTEGER GetTimeZoneInformation;
IN kernel32 ;
STRING @lpTimeZoneInformation
LOCAL cTimeZone, iBiasSeconds
cTimeZone = REPL(Chr(0), 172)
GetTimeZoneInformation(@cTimeZone)
iBiasSeconds = 60 * ;
INT( ASC(SUBSTR(cTimeZone, 1,1)) + ;
BITLSHIFT(ASC(SUBSTR(cTimeZone,2,1)), 8)+;
BITLSHIFT(ASC(SUBSTR(cTimeZone,3,1)),16)+;
BITLSHIFT(ASC(SUBSTR(cTimeZone,4,1)),24))
dtConvert = dtConvert - iBiasSeconds
dConvert = TTOD(dtConvert)
ENDIF
DO CASE
CASE cFormat='d'
* Short date
* eg 10/12/2002
cResult=’MM/dd/yyyy’
CASE cFormat='D'
* Long date
* eg. December 10, 2002. Can't use @YL
cFormat='MMM dd, yyyy'
CASE cFormat='f'
* Full date & time
* eg December 10, 2002 10:11 PM
cFormat='MMMM dd, yyyy hh:mm tt'
CASE cFormat='F'
* Full date & time (long)
* eg December 10, 2002 10:11:29 PM
cFormat='MMMM dd, yyyy hh:mm:ss tt'
CASE cFormat='g'
* Default date & time
* eg 10/12/2002 10:11 PM
cFormat='dd/MM/yyyy hh:mm tt'
CASE cFormat='G'
* Default date & time (long)
* eg 10/12/2002 10:11:29 PM
cFormat='dd/MM/yyyy hh:mm tt'
CASE cFormat='M'
* Month day pattern
* eg December 10
cFormat='MMMM dd'
CASE cFormat='r'
* RFC1123 date string
* Tue, 10 Dec 2002 22:11:29 GMT
cFormat='ddd, dd MMM yyyy hh:mm:ss GMT'
CASE cFormat='s'
* Sortable date string
* 2002-12-10T22:11:29
cResult = TTOC(dtConvert,3)
CASE cFormat='t'
* Short time
* eg 10:11 PM
cFormat='hh:mm tt'
CASE cFormat='T'
* Long time
* eg 10:11:29 PM
cFormat='hh:mm:ss tt'
CASE cFormat='u'
* Universal sortable, local time
* eg 2002-12-10 22:13:50Z
cFormat='yyyy-MM-dd hh:mm:ddZ'
CASE cFormat='U'
* Universal sortable, GMT
* eg December 11, 2002 3:13:50 AM
cFormat="MMMM dd, yyyy hh:mm:ss tt"
CASE cFormat='Y'
* Year month pattern
* eg December, 2002
cFormat="MMMM, yyyy"
ENDCASE
ENDIF
IF EMPTY(cResult) AND LEN(cFormat)>1
cResult=;
This.ParseDateFormat(cFormat, dtConvert)
ENDIF
RETURN cResult

PROTECTED FUNCTION ParseDateFormat
LPARAMETERS cFormat, dtVar
cFormat=;
STRT(cFormat,"hh", PADL(HOUR(dtVar),2,'0'))
cFormat=;
STRT(cFormat,"mm", PADL(MINU(dtVar),2,'0'))
cFormat=;
STRT(cFormat,"ss", PADL(SEC(dtVar),2,'0'))
cFormat=;
STRT(cFormat,"MMMM", CMONTH(dtVar))
cFormat=;
STRT(cFormat,"MMM", LEFT(CMONTH(dtVar),3))
cFormat=;
STRT(cFormat,"MM", PADL(MONT(dtVar),2,'0'))
cFormat=;
STRT(cFormat,"ddd", LEFT(CDOW(dtVar),3))
cFormat=;
STRT(cFormat,"dd", PADL(DAY(dtVar),2,'0'))
cFormat=;
STRT(cFormat,"yyyy", TRANS(YEAR(dtVar)))
cFormat=;
STRT(cFormat,"yy", RIGH(TRANS(YEAR(dtVar)),2))
cFormat=;
STRT(cFormat,"tt", ;
IIF(HOUR(dtVar)<12,"AM","PM"))
RETURN cFormat

Notice when I use formatting codes ‘r’, ‘u’, and ‘U’, I have to convert the local time to GMT time, which requires me to know the time zone I’m currently in and its difference from GMT. That code is based on some examples on the great new2news.com site.

Let’s make our call again to see how this looks:
String=CREATEOBJECT("STRING")
? String.Format("{0}, your total today, ;
"{1:MM/dd/yyyy}, was {2:@$} on {3} " + ;
"items. Thanks, {0}!" ;
, cUser, dDate, nTotal, iItems)

And we might get something like

“Joe, your total today, 10/18/2009, was $150.00 on 2 items. Thanks, Joe!”

If we switch around our formatting code, we get different results. Eg.
String=CREATEOBJECT("STRING")
? String.Format("{0}, your total today at
{1:t}, was {2:@$} on {3} items. Thanks,
{0}!", cUser, dDate, nTotal, iItems)

// Uses a standard format
? String.Format("{0}, your total today was
{2}", cUser, dDate, nTotal, iItems)

// Note you don't have to use all vars.

? String.Format("{0}, your total today,
{1:ddd MMM dd, yyyy}, was {2:@$} on {3}
items. Thanks, {0}!",
cUser, dDate, nTotal, iItems)

// Uses a custom format

Results in

“Joe, your total at 12:30 PM was $150.00 on 2 items. Thanks, Joe!”
“Joe, your total today was $150.00”
“Joe, your total today, Mon Oct 19, 2009, was $150.00 on 2 items. Thanks, Joe!”

I’ve added the analogous NumericFormat() and ParseNumericFormat() methods to complete our transition of C#’s String.Format to VFP. All of that code is on the companion download.

I hope you’re beginning to see the power and flexibility of String.Format. With this now available to you in Visual FoxPro, you’ll not only make your own code more readable and maintainable, but you’ll also be able to make the transition to C# a little easier because you’ll be used to this style of syntax.
Future enhancements might not only including any additional formatting codes that we haven’t implement yet (more obscure ones) into Format(), but also start adding all of the C#’s String methods into our class. These would encapsulate existing procedural VFP functions, such as LOWER(), UPPER(), AT(), etc. into class based code so we could have C#-like functions such as String.ToLower(), String.Contains(), and String.IndexOf(). That way we would have one-stop shopping for all of our String functionality in Visual FoxPro, along with Intellisense so you would know all the things you can do with a string.

The code for this class is available at https://github.com/eselje/FoxTypes


Posted

in

,

by

Tags:

Comments

3 responses to “String.Format for Visual FoxPro”

  1. Matt Slay Avatar

    Eric – Very nice! I knew you had worked on this, and I’m creating some new reports that need various date formats, and this code made it super-easy to achieve what we wanted. Thanks.

  2. Matt Slay Avatar

    I added this guard code to the DateFormat method to handle nulls being passed:

    If IsNull(dtConvert)
    Return “”
    Endif

  3. Eric Avatar

    Hey I’m glad you like it Matt. This was the class I used as an example for Unit Testing at this year’s Southwest Fox 2016 conference, which exposed a lot more people to it.

    The (updated) class is available at https://github.com/eselje/FoxTypes. Send me a pull request for your change! 😉

    Eric

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.