VBScript will be retired and eliminated from future versions of Windows, I wanna build a better alternative!
It runs the familiar VBScript-style language, but as a single self-contained native binary, cross-platform, with no dependency on the Windows Script Host or COM.
Directive is a small dynamic scripting language, one C++17 file (directive.cpp,
no external dependencies) that compiles to a native executable.
It stays source-compatible with the classic language where it can,
while giving it a cleaner, more explicit object model of its own.
It keeps the parts of the classic language that are pleasant to write — Dim,
If/Select, For/Do, Sub/Function, Class, On Error/Err — and gives it a
cleaner, more explicit object model:
Newcreates native engine objects (Dictionary,List,RegExp,FileSystem,Sound,Shell, …). They work on every platform.CreateObjectis reserved for real COM/ActiveX automation on Windows.- The host object is
Directive:Directive.Echo,Directive.StdOut.Write,Directive.StdIn.ReadLine,Directive.Quit.
| Task | Directive |
|---|---|
| dictionary / map | New Dictionary |
| file & folder access, text streams | New FileSystem |
| regular expressions | New RegExp |
| shell: run / exec / environment / registry | New Shell |
| dynamic list | New List |
| desktop automation | New Mouse, New Screen, New Keyboard, New Clipboard, New Sound |
| real COM / ActiveX (Windows only) | CreateObject("Excel.Application"), … |
| console output | Directive.Echo |
| standard streams | Directive.StdOut / Directive.StdIn |
| exit the script | Directive.Quit |
So New = in-process, cross-platform, engine-provided; CreateObject = the
operating system's COM, Windows-only. The split keeps the two worlds from blurring.
Each engine object has one canonical name (no aliases), and functions are
grouped by responsibility: Dictionary, List, RegExp, FileSystem, Sound
(playback + WAV combining), Mouse, Screen, Keyboard, Clipboard, Shell.
One file, no build system.
Console build (console-style: Directive.Echo and the StdOut/StdIn
streams use the terminal). This is the portable default and the only mode on
Linux/macOS.
# Linux / macOS
g++ -std=c++17 -O2 directive.cpp -o directive
# Windows, MSVC (x64 Native Tools prompt)
cl /std:c++17 /EHsc /O2 directive.cpp /Fe:directive.exe /link /STACK:16777216
# Windows, MinGW-w64 — link statically so the .exe is self-contained (no DLLs to ship)
g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ -Wl,--stack,16777216 directive.cpp -o directive.exe -lole32 -loleaut32 -luuid -lwinmm -lgdi32 -luser32 -ladvapi32 -lshell32GUI build (GUI-style: MsgBox, InputBox, and Directive.Echo show real
windows). Add -DDIRECTIVE_GUI and a windowed subsystem:
# Windows, MSVC
cl /std:c++17 /EHsc /O2 /DDIRECTIVE_GUI directive.cpp /Fe:directivew.exe /link /SUBSYSTEM:WINDOWS /STACK:16777216
# Windows, MinGW-w64 (static)
g++ -std=c++17 -O2 -DDIRECTIVE_GUI -static -static-libgcc -static-libstdc++ -Wl,--stack,16777216 directive.cpp -o directivew.exe -mwindows -lole32 -loleaut32 -luuid -lwinmm -lgdi32 -luser32 -ladvapi32 -lshell32MinGW users: use
-static. Without it the.exedepends onlibstdc++-6.dll/libgcc_s_seh-1.dll, which won't exist on a clean machine and the program will fail to start. Static linking bundles everything into one file. (MSVC links its runtime differently and doesn't need this.)
The GUI build also calls AttachConsole at startup, so if you launch it from a
cmd window Directive.StdOut still writes there; double-clicked, it just shows
dialogs.
./directive script.ds # run a file
./directive script.ds a b c # run a file, passing arguments
./directive -e "Directive.Echo 6*7" # inline
./directive - # read from stdinThere are two builds, a console one and a windowed one:
directive.exe— console.Directive.Echoand theDirective.StdOut/StdIn/StdErrstreams go to the terminal.MsgBoxandInputBoxstill pop up real dialogs — they are language functions, not host-specific ones. Use this for terminals, pipes, and automation; the shell waits for it.directivew.exe— windowed. Double-click it, or associate.dswith it:Directive.Echopops up a message box, andMsgBox/InputBoxare dialogs. Two things it does better than the classic windowed host — launch it from a console andDirective.StdOutstill writes to that console rather than failing with an invalid-handle error, andDirective.Echowrites to the console instead of popping up. (It's a GUI-subsystem binary, so the shell does not wait for it; for clean, ordered console output preferdirective.exe.)
directive.exe |
directivew.exe |
|
|---|---|---|
Directive.Echo |
console | console if launched from one, else popup |
Directive.StdOut / StdIn / StdErr |
console | console when attached (else discarded) |
MsgBox / InputBox |
dialog | dialog |
Anything after the script path is exposed to the script through
Directive.Arguments, a 0-based collection (the host is always named
Directive):
Directive.Echo "count: " & Directive.Arguments.Count
Directive.Echo "first: " & Directive.Arguments(0)
Dim f
For Each f In Directive.Arguments
Directive.Echo f
NextTo make drag-and-drop work on Windows, associate the .ds extension
with directive.exe "%1" %* (or directivew.exe "%1" %*). The %* forwards the
dropped file paths, so dragging files onto a script delivers their paths as
Directive.Arguments. See examples/13_arguments.ds.
Errors are reported in the same shape as the Windows Script Host dialog — script path, line, column, message, an HRESULT-style code, and a source:
Script: /path/to/script.ds
Line: 3
Char: 1
Error: Division by zero
Code: 800A000B
Source: Directive runtime error
(The fields are tab-aligned into a column, like the Windows Script Host dialog.)
The Code is the the classic form form 0x800A0000 + errorNumber, so it lines up
with the numbers you already know: type mismatch 800A000D (13), division by zero
800A000B (11), subscript out of range 800A0009 (9), object required 800A01A8
(424), undefined variable 800A01F4 (500). Compilation errors carry
Source: Directive compilation error and point at the offending token; runtime
errors point at the statement being executed. In the console build this prints to
stderr; in the GUI build it appears in a message box.
New List is a genuine doubly-linked list:
Dim L : Set L = New List
L.Add "alpha"
L.Insert 1, "beta" ' by index
L.Swap 0, 1
L.Remove "beta" ' by value; returns True/False
L.RemoveAt 0 ' by index
x = L(0) ' Item(index), 0-based; also L(0) = "new"
L.Reverse
L.Sort
For Each item In L : Directive.Echo item : NextMembers: Add/Append/Push, Insert(i, v), Remove(v), RemoveAt(i),
Item(i) (get/set, default), Count, Clear, Contains(v), IndexOf(v),
Swap(i, j), Reverse, Sort, First, Last, ToArray, and For Each.
Directive.Echo a, b, …— writes a line (console: stdout; GUI: a message box)Directive.StdOut/Directive.StdErr—.Write,.WriteLine,.WriteBlankLines(n)Directive.StdIn—.ReadLine,.Read(n),.ReadAll,.AtEndOfStreamDirective.Quit [code],Directive.Sleep msDirective.ScriptName— the script's file name;Directive.ScriptFullName— its absolute path;Directive.ScriptPath(aliasScriptDir) — the folder containing it. Handy for locating files relative to the script (-e/stdin scripts report the current directory).
A native Include pulls another file's definitions into the current script:
Include "lib/strutil.ds" ' path is relative to THIS file's folder
directive.echo TitleCase("hello world")Unlike the old ExecuteGlobal-plus-read-the-file trick, Include happens at
parse time, so everything the included file defines (Const, Function, Sub,
Class) is visible everywhere in the including file — including above the
Include line. Details:
- Paths resolve relative to the including file's directory (nested includes
resolve relative to their own location), so a library can
Includeits siblings without caring where the top-level script lives. - Each file is included at most once. Diamond includes (A pulls in B and C, both of which pull in D) and circular includes (A ↔ B) are handled by an include guard — no duplicate-definition errors, no infinite loops.
- Top level only —
Includeinside aSub/Function/Classis a parse error. Parse errors inside an included file name the file.
New FileSystem gives you the full object model, not just loose functions:
Dim fso : Set fso = New FileSystem
Dim folder : Set folder = fso.GetFolder("demo")
Directive.Echo folder.Name & " has " & folder.Files.Count & " files"
Dim f
For Each f In folder.Files
Directive.Echo f.Name & " — " & f.Size & " bytes, modified " & f.DateLastModified
Next
Dim doc : Set doc = fso.GetFile("demo\notes.txt")
doc.Copy "demo\backup.txt"
Set ts = doc.OpenAsTextStream(1) : Directive.Echo ts.ReadLine : ts.Close- FileSystem:
FileExists,FolderExists,CreateFolder,DeleteFile,DeleteFolder,CopyFile,CopyFolder,MoveFile,MoveFolder,CreateTextFile,OpenTextFile,GetFile,GetFolder,GetSpecialFolder,GetBaseName,GetFileName,GetExtensionName,GetParentFolderName,BuildPath,GetAbsolutePathName,GetTempName. - Folder:
Path,Name,ParentFolder,Drive,Size,Type,Attributes,DateLastModified(etc.),IsRootFolder,Files,SubFolders,Delete,Copy,Move,CreateTextFile. - File:
Path,Name,ParentFolder,Drive,Size,Type,Attributes,DateLastModified(etc.),Delete,Copy,Move,OpenAsTextStream. - Files / SubFolders:
Count,Item(name), andFor Each.
On non-Windows, \ in paths is treated as a separator so ordinary scripts work.
Unicode text files. Text files default to UTF-8 (no BOM). Pass the
unicode argument to CreateTextFile(path, overwrite, True) — or format = -1
to OpenTextFile / OpenAsTextStream — to write UTF-16LE with a BOM, which
is what the classic form calls "Unicode". Reading auto-detects the BOM and decodes
UTF-8, UTF-16LE, or UTF-16BE either way (emoji and other astral characters
included), so you usually don't pass a format at all. See
examples/15_unicode.ds.
AutoIt-style desktop automation in single-purpose objects, plus a cross-platform
WAV combiner folded into Sound.
' --- Mouse (Windows) ---
Dim m : Set m = New Mouse
m.Move 400, 300 ' instant; add a 3rd arg for smooth motion, e.g. m.Move 400,300,5
m.Click "left" ' Click([button],[x],[y],[count]); button = left/right/middle
m.Click "left", 400, 300, 2 ' move there, then double-click
m.Down "left" : m.Up "left" ' press/release separately (drag)
m.Wheel "down", 3 ' scroll
directive.echo "cursor at " & m.X & "," & m.Y ' also m.GetPos -> [x, y]
' --- Screen (Windows) ---
Dim s : Set s = New Screen
directive.echo Hex(s.PixelColor(100, 100)) ' 0xRRGGBB, like AutoIt PixelGetColor
directive.echo s.Width & "x" & s.Height
' --- Keyboard (Windows) — symmetric with Mouse ---
Dim kb : Set kb = New Keyboard
kb.Send "Hello{ENTER}" ' SendKeys syntax: {ENTER} {TAB} {F5} {LEFT 5} ...
kb.Send "^a" ' Ctrl+A (+ = Shift, ^ = Ctrl, % = Alt)
kb.Send "+(abc)" ' hold Shift over the whole group -> ABC
kb.Send "50% off {not a key}", 1 ' raw mode (2nd arg=True/1): typed literally,
' like AutoIt's Send($keys, 1) -- same as kb.Type
kb.Type "verbatim text" ' literal text, no special-key parsing
kb.Down "shift" : kb.Up "shift" ' hold/release a key by name or character
' --- Clipboard (Windows) — an object now (was ClipPut/ClipGet) ---
Dim clip : Set clip = New Clipboard
clip.Set "hello from Directive" ' returns True on success
directive.echo clip.Get ' reads clipboard text back
clip.Clear ' empties the clipboard
' --- Sound playback (Windows) ---
Dim snd : Set snd = New Sound
snd.Play "chime.wav" ' async by default (returns immediately)
snd.Play "chime.wav", True ' True = synchronous (waits until finished)
snd.Stop ' stop async playback
snd.Beep 1000, 250 ' frequency Hz, duration ms
' --- Sound WAV combining: concatenate WAV files into one (all platforms) ---
Dim w : Set w = New Sound
w.Create "out.wav"
w.Append "a.wav" ' first file sets the format
w.Append "b.wav" ' must match channels / sample-rate / bit-depth
directive.echo "combined length: " & w.Duration & " sec"
w.Close ' writes the RIFF/WAVE header and closesThe WAV side of Sound streams each input's PCM straight into the output and
patches the header on Close — direct concatenation of same-format uncompressed
PCM only, so mismatched files raise an error rather than silently corrupting the
output.
Platform note.
Mouse,Screen,Keyboard,Clipboard, andSoundplayback (Play/Stop/Beep) are Windows-only (Win32 input, GDI, andwinmm); on Linux/macOS they raise a clear "requires the Windows build" error.Sound's WAV combining (Create/Append/Duration/Close) is pure file I/O and works everywhere. See the verification note under Known limitations.
New Shell is a native replacement for the classic Windows shell, so scripts that shell
out, read the environment, or touch the registry keep working even if Microsoft
retires the Windows Script Host objects. The process and environment features are
cross-platform; registry and special-folder lookups are Windows-only.
Dim sh : Set sh = New Shell
' Run a program; waitOnReturn = True blocks and returns the exit code
Dim rc : rc = sh.Run("notepad.exe", 1, True)
' Exec: run a command and capture its output (cross-platform)
Dim e : Set e = sh.Exec("git status")
Do Until e.StdOut.AtEndOfStream
directive.echo e.StdOut.ReadLine
Loop
directive.echo "exit code: " & e.ExitCode
' Environment + expansion (cross-platform)
directive.echo sh.Environment("PROCESS")("PATH")
directive.echo sh.ExpandEnvironmentStrings("Temp is %TEMP%")
directive.echo sh.CurrentDirectory
' Registry + special folders (Windows only; raise 429 elsewhere)
sh.RegWrite "HKCU\Software\MyApp\Setting", "on", "REG_SZ"
directive.echo sh.RegRead("HKCU\Software\MyApp\Setting")
directive.echo sh.SpecialFolders("Desktop")
' Create a desktop shortcut, then focus a window (Windows only)
Dim lnk : Set lnk = sh.CreateShortcut(sh.SpecialFolders("Desktop") & "\MyApp.lnk")
lnk.TargetPath = "C:\Windows\System32\notepad.exe"
lnk.Description = "Launch Notepad"
lnk.Save
If sh.AppActivate("Notepad") Then directive.echo "focused Notepad"
' Popup: a message box that returns the button code
Dim answer : answer = sh.Popup("Continue?", 0, "Confirm", 4) ' 4 = dsYesNoRun takes (command, [windowStyle], [waitOnReturn]); windowStyle is ignored
off-Windows. Exec returns an object with .StdOut (a readable stream:
.ReadAll / .ReadLine / .AtEndOfStream), .ExitCode, and .Status.
Environment([type]) is a collection — env(name) reads or (via assignment)
writes a variable, .Count counts them, and For Each yields NAME=VALUE.
CreateShortcut(path) returns a shortcut object whose .TargetPath,
.Arguments, .WorkingDirectory, .IconLocation, and .Description you set
before calling .Save; AppActivate(title) focuses a top-level window by
(partial) title.
Platform note.
Run,Exec,Environment,ExpandEnvironmentStrings,CurrentDirectory, andPopupwork everywhere.RegRead/RegWrite/RegDelete(viaadvapi32),SpecialFolders(viashell32),CreateShortcut(viaIShellLink), andAppActivateare Windows-only and raise error 429 on other platforms. The cross-platform parts are tested on Linux; the registry round-trip,SpecialFolders,CreateShortcut, and theClipboardobject were also exercised through the Windows binary under Wine. (Only keystroke delivery, real window focus, and audio output can't be tested here — no desktop/audio.)
VBScript has no console API whatsoever, which is exactly why batch files reach for
color and cls. Directive adds a Console object that works on every platform:
the Win32 console API on Windows (honoured by both conhost and Windows Terminal),
ANSI escapes on Linux and macOS. When output is redirected every escape is
suppressed, so directive report.ds > report.txt still produces clean text.
Dim c : Set c = New Console
c.Cls
c.WriteLine " BUILD PASSED ", dsBrightWhite, dsGreen ' one-shot colour
c.Foreground = dsBrightCyan ' or persistent
c.WriteLine "17 tests, 0 failures"
c.Reset
c.CursorVisible = False ' self-overwriting progress
Dim p
For p = 0 To 100 Step 25
c.ClearLine
c.Write " working: " & p & "%"
Directive.Sleep 100
Next
c.CursorVisible = True
c.KeepOpen ' batch PAUSE equivalentColours use cmd.exe's color numbering (dsBlue=1, dsRed=4, +8 bright), so
dsYellow is the brown one and dsBrightYellow is vivid. See
examples/17_console.ds for the full tour and API_REFERENCE.md for every member.
The batch PAUSE problem: a double-clicked script gets a console that closes the
instant it finishes. Two members cover it, and neither can hang an automated run
because both require stdin to actually be a console:
| Waits when... | |
|---|---|
Console.Pause |
always, while a console is attached (the batch PAUSE) |
Console.KeepOpen |
only when this script owns the console — i.e. it was double-clicked or dragged onto, so the window would vanish. Running from an existing prompt, piping, and the GUI build are all unaffected. |
Ownership is detected with GetConsoleProcessList() == 1. On Linux and macOS
KeepOpen is deliberately a no-op, since the terminal window belongs to your
shell rather than to the script. Every example in examples/ ends with it.
Console.ReadKey reads a single key with no Enter, returning the same names
Keyboard.Send accepts ("a", "{UP}", "^a") so a key can be read and replayed.
VBScript types a whole number as the narrowest that fits — Integer, then Long — then falls back to a Double, which silently stops holding whole numbers past 2^53. Directive adds one more rung to that same ladder, automatically:
Integer (16-bit) -> Long (32-bit) -> LongLong (64-bit) -> Double
Directive.Echo 9007199254740992 + 1 ' 9007199254740993 exact
Directive.Echo CDbl("9007199254740992") + 1 ' 9.00719925474099E+15 what VBScript gives
Directive.Echo 3000000000 * 3000000000 ' 9000000000000000000Arithmetic widens but never wraps — past 64 bits it promotes to Double, exactly as
VBScript promotes past 32 — and every surrounding VBScript rule is untouched.
CLngLng converts explicitly. See examples/19_bignumbers.ds, the numeric-type
table in API_REFERENCE.md, and tests/EXPECTED_DIFFS.md for the one visible
consequence.
VBScript formats numbers and dates through the Windows locale; Directive asks the
same OS, so on your machine CStr(2.5) gives 2,5 if that is what VBScript gives,
and CDbl("1.5") is 15 where the dot is a thousands separator. Date is a real
subtype too, so "Started: " & Now prints a date rather than a serial number.
SetLocale(1033) pins invariant formatting when a file has to be machine-readable,
and SetLocale(0) restores the machine default. See the locale and date tables in
API_REFERENCE.md.
New TextWindow is a resizable window with a multiline edit control and OK/Cancel.
It fills the gap between MsgBox (too small, cannot be copied from comfortably) and
writing a temporary file just to read something:
Dim w : Set w = New TextWindow
w.Title = "Results"
w.WriteLine "anything, including caf" & ChrW(233) & " and 100" & ChrW(8364)
w.ReadOnly = True
w.Show ' user can scroll, select, copyLeave ReadOnly off and it becomes a large InputBox — prefill Text, and Show
returns True for OK or False for Cancel, with the edited Text read back after.
It works in both builds and, being a real window, is immune to the console
codepage. Windows only. See examples/20_textwindow.ds.
Everything dynamic you'd expect: Option Explicit, Dim/Public/Private,
Const, all operators (Mod, \, ^, &, Is, And/Or/Not/Xor/Eqv/Imp),
If/ElseIf/Else (block and single-line), For/Next (+Step), For Each,
Do/Loop (all forms), While/Wend, Select Case (lists, Is, To ranges),
With, Exit, Sub/Function with ByRef/ByVal, Class with
Property Get/Let/Set, Public Default members, Me, Class_Initialize/
Class_Terminate, #...# date literals, On Error Resume Next/Err, and
~90 built-in functions (strings, conversion, math, dates, arrays, type checks,
plus Eval, GetRef, Erase, MonthName/WeekdayName, FormatNumber/
FormatCurrency/FormatPercent, DatePart, DateValue/TimeValue,
ScriptEngine; clipboard access is the New Clipboard object).
Constants use the Directive-branded ds* prefix: dsCrLf, dsTab,
dsNewLine, dsString, dsYesNo, dsInformation, dsTextCompare, and so on.
There is no vb* prefix — porting a classic script is a straight vb→ds
rename on constant names (vbCrLf → dsCrLf).
It also supports [bracket identifiers] — any text in [ ] is a literal name
(even a keyword, spaces, or a leading digit), e.g. Dim [hello world], [End] = 1,
obj.[Next]. Handy for COM members whose names collide with keywords.
See examples/. Highlights: 05_list.ds, 06_dictionary.ds,
07_files.ds (File/Folder objects), 09_console_io.ds,
11_automation.ds (Mouse/Screen/Keyboard/Clipboard/Sound),
12_include.ds (native Include pulling in lib/strutil.ds),
13_arguments.ds (command-line args), 14_shell.ds (the Shell object),
15_unicode.ds (UTF-8 / UTF-16 text files),
16_bracket_identifiers.ds ([bracket] names).
./directive examples/05_list.ds- New built-in function → add a lambda in
Interpreter::registerBuiltins(). - New
New-able object → implement theIObjectinterface and add a case inInterpreter::evalNew().
Pipeline is Lexer → recursive-descent Parser → tree-walking Interpreter, all
in one file.
- Objects —
Setvs plain assignment.Set x = objis the explicit form. Directive is lenient about a missingSet: plainx = objalso stores the object reference, whereas a stricter language would try to read the object's default property. So correctSet-using code behaves identically; only code that deliberately relied on the stricter rule differs. (Not a bug — a superset.) - Arrays copy on assignment —
b = agivesbits own copy, so changingb(0)doesn't toucha(0). The copy is shallow: if elements are objects, the references are copied (the objects aren't cloned). Class_Terminateruns deterministically — when youSet x = Nothing, reassign the last reference, a local goes out of scope at end of aSub/Function, or the script ends. The one case where it can't run is an unbroken reference cycle (objects holdingSetreferences to each other): the cycle is never collected, so it neither terminates nor frees — the same standard reference-counting behavior. Break cycles (Set a.other = Nothing) if you need the terminators.- Dates are stored as OLE serial doubles.
#...#date literals,DateValue/TimeValue, and all the date functions work, but because there's no distinct Date subtype,TypeNameof a date reportsDoubleandIsDate(#...#)isFalse(the literal is already a number).Rndisn't bit-identical to any legacy generator. - File/Folder
DateCreated/DateLastAccessedreport the last-modified time (portablestd::filesystemexposes only that);Attributesare approximated on non-Windows. Privateclass members aren't access-controlled. They work correctly inside the class, but reading one from outside returnsEmptyrather than raising an error the way a stricter language would. TreatPrivateas a convention here, not an enforced boundary.- Leading-dot argument to a paren-less call. Because the lexer discards
whitespace,
Directive.Echo .Memberinside aWithparses as member chaining (Directive.Echo.Member) and fails. Parenthesize the argument instead:Directive.Echo(.Member). - Verification status of the automation objects.
Sound's WAV combining and theClipboardobject are tested (natively where applicable and through the Windows.exeunder Wine);Shell.CreateShortcutis also Wine-verified (it produces a real.lnk).Mouse,Screen,Keyboard,Soundplayback, andShell.AppActivateare compile- and link-verified for Windows but not fully runtime-tested — the build environment has no display, mouse, or audio device, so keystroke delivery, real window focus, and audio can't be exercised here. Give those a real-Windows smoke test before relying on them.
Directive is a capable hobby interpreter, tested with the bundled examples, a set of targeted stress tests, and an AddressSanitizer/UBSan/LeakSanitizer pass. It has not been fuzzed at scale or security-audited to production-runtime standards. Concretely:
- It is not a security sandbox. It deliberately exposes powerful
capabilities —
FileSystem(read/write/delete files),CreateObject(arbitrary COM on Windows, including shells), OS automation (Mouse/Screen/Keyboard/Clipboard), andInclude(reads arbitrary files). A.dsscript can do anything the user running it can do, so treat an untrusted script exactly like an untrusted.exe— don't run it. (Directive isn't wired into browsers, Office, or email, so it isn't an ambient attack surface; but the language itself is just as capable as any general-purpose script host.) - Reference cycles leak. Objects are reference-counted; if two objects hold
Setreferences to each other (a.other = b : b.other = a) and you drop all outside references, that cluster won't be freed until the process exits. This is a standard reference-counting limitation. Harmless for short-lived scripts (the OS reclaims everything on exit); avoid unbroken cycles in long-running processes. - Recursion is bounded at ~2000 nested calls; deeper raises a catchable
Out of stack space (code
800A001C) instead of crashing. The shipped Windows builds also link with a 16 MB stack. Normal recursion is unaffected. - Use-after-close is safe (writing to a closed
TextStreamis a no-op, not a crash), and deeply nestedSelect/For/Do/If/Withand class bodies behave correctly. - Bottom line: fine for personal automation, build scripts, and trusted internal
tooling — the niches where you'd have reached for
.vbs. Don't put it in front of untrusted input or use it as a security boundary.
MIT.