Dab language
Provisional wordfreq reference program
Provisional wordfreq Bare CLI
Status: executable design specimen; not implemented and not yet canonical Dab 0.1 source.
Program: wordfreq
Layer: bare CLI
This public reference reproduces the accepted provisional program so language and standard-library discussions can critique complete code. It is a planned Dab 0.1 acceptance specimen, not proof of current prototype behavior or of a final API. The bounded target is described in the planned Dab 0.1 acceptance contract. The complete public specimen and its open decisions are recorded directly on this page.
The source deliberately proposes names for APIs that have not been finalized. Their presence below is not confirmation that the current prototype implements them.
Complete provisional source
class UsageError < Error
end
class InputError < Error
end
class CommandLine
public let @top : Int = 20
public let @files : const Set[String] = {,}
public let @help : Bool = false
public let @stdin : Bool = false
autoinitialize
end
class CommandLineParser
private writable var @top : Int? = nil
private let @files : Set[String] = {,}
private let @remaining : Array[String]
private writable var @option_mode = true
private writable var @stdin = false
initialize(values : const Array[out String])
@remaining = values.shallow_copy
end
mutable def parse_arguments : CommandLine
while !@remaining.empty?
let argument = @remaining.shift!
unless option_mode?
add_source!(argument)
next
end
case argument
when "-"
@stdin = true
when "--"
@option_mode = false
when "--help", "-h"
return CommandLine.new(help: true)
when "--top", "-n"
raise UsageError.new("missing value for #{argument}") if @remaining.empty?
@top = parse_top(@remaining.shift!)
when /^--top=/
@top = parse_top(argument.delete_prefix("--top="))
when /^-/
raise UsageError.new("unknown option: '#{argument}'")
else
add_source!(argument)
end
end
CommandLine.new(
top?: @top,
files: @files,
stdin: stdin? || @files.empty?
)
end
private def add_source!(argument : String)
@files.add!(argument)
end
private def parse_top(text : String) : Int
raise UsageError.new("--top may be specified only once") unless @top.nil?
let value : Int? = Int.parse(text)
raise UsageError.new("invalid value for --top: '#{text}'") if value.nil?
raise UsageError.new("--top must be non-negative: '#{text}'") if value < 0
value
end
end
class Counter
let @counts : Map[String, Int] = {}
def counts : const Map[String, Int]
@counts
end
def process!(input : InputStream)
let decoder = UTF8.strict_decoder
let segmenter = Unicode.default_word_segmenter
decoder.on_decode do |text|
segmenter.push!(text)
end
segmenter.on_segment do |segment|
count_segment!(segment)
end
segmenter.process do
decoder.process do
input.each_chunk do |bytes|
decoder.push!(bytes)
end
end
end
end
private def counted_segment?(segment : String)
segment.any? do |character|
character.letter? || character.number?
end
end
private def count_segment!(segment : String)
if counted_segment?(segment)
let word = segment.normalized_casefold
@counts[word] = 0 unless @counts.has?(word)
@counts[word] += 1
end
end
end
def sorted_counts(
counts : const Map[String, Int]
)
let rows = counts.entries
rows.sort! do |left, right|
if left.value > right.value
-1
elsif left.value < right.value
1
else
left.key.compare_unicode_scalars(right.key)
end
end
rows
end
def write_results!(
counts : const Map[String, Int],
limit : Int,
output : OutputStream
)
return if limit == 0
let rows = sorted_counts(counts)
var emitted = 0
for row in rows
break if emitted >= limit
output.write("#{row.value}\t#{row.key}\n")
emitted += 1
end
end
def write_help!(output : OutputStream)
output.write(<<~HELP)
Usage: wordfreq [options] [file...]
Count case-insensitive word segments using Unicode default boundaries.
Options:
-n N, --top N, --top=N output at most N words (default: 20)
-h, --help show this help and exit
Before '--', an argument named '-' reads standard input.
After '--', every argument is a literal filename, including '-'.
With no file arguments, standard input is read.
Standard input is processed before named files.
Named files may be processed in any order.
Repeated identical filename arguments are processed once.
Repeated '-' arguments before '--' select standard input once.
Input must be valid UTF-8.
Tied counts are ordered by normalized Unicode scalar sequence.
HELP
end
def process_standard_input!(
counter : Counter,
input : InputStream
)
begin
counter.process!(input)
rescue IOError | UnicodeError
raise InputError.new("cannot process standard input: #{error.message}")
end
end
def process_file!(
counter : Counter,
filename : String
)
begin
File.open(Path.new(filename)) do |file|
counter.process!(file)
end
rescue PathError | IOError | UnicodeError
raise InputError.new("cannot process '#{filename}': #{error.message}")
end
end
def write_usage_error!(
name : String,
usage_error : UsageError,
output : OutputStream
)
output.puts("#{name}: #{usage_error.message}")
output.puts("Try '#{name} --help' for more information.")
end
def write_processing_error!(
name : String,
processing_error : InputError | IOError,
output : OutputStream
)
output.puts("#{name}: #{processing_error.message}")
end
begin
let command_line = CommandLineParser.new(arguments).parse_arguments
if command_line.help?
write_help!(stdout)
return 0
end
let counter = Counter.new
if command_line.stdin?
process_standard_input!(
counter: counter,
input: stdin
)
end
for filename in command_line.files
process_file!(
counter: counter,
filename: filename
)
end
write_results!(
counts: counter.counts,
limit: command_line.top,
output: stdout
)
return 0
rescue usage_error : UsageError
write_usage_error!(program_name, usage_error, stderr)
return 2
rescue BrokenPipeError
raise
rescue processing_error : InputError | IOError
write_processing_error!(program_name, processing_error, stderr)
return 1
end
Behavior represented by the source
- Before
--, one or more explicit-arguments select the same borrowedstdinonce and are not retained inCommandLine.files. If the resulting file Set is empty, stdin is also selected by default. - After
--, every token is a literal filename, including-; a literal-is retained inCommandLine.files. - Repeated identical filename argument Strings collapse to one Set member;
lexical alternatives and aliases to the same filesystem object do not.
Repeated pre-terminator
-arguments collapse to one stdin selection. - Stdin is processed before every named file. Named files may be processed in any order, so the first reported failure among several bad named files is unspecified.
- Named files are opened and closed by the orchestration layer’s scoped
File.openblock. - One
Counterowns the aggregate map; eachprocess!(input)call consumes oneInputStream, whether stdin or an opened file, and adds its words to that shared result. - Options are recognized anywhere before
--. --top,--top=, and-nshare one non-negative integer limit and reject duplicates.- The nullable parsed limit is forwarded through the confirmed
top?: topconditional-keyword syntax, sonilselects the initializer’s declared default while0remains an explicitly supplied limit. --helpand-hreturn immediately without opening input or constructing the Unicode decoder and segmenter.- All files contribute to one
Map[String, Int]. - Input is decoded and segmented incrementally, including across stream chunk boundaries.
- Expected path, I/O, and Unicode input failures are reported with either the
filename or
standard inputas context. UnexpectedErrorsubclasses are not converted into ordinary processing failures. - A broken output pipe is rethrown to the CLI boundary, which terminates
silently with native
SIGPIPEbehavior on POSIX. - No output rows are written until every input has been consumed successfully.
- Rows use descending count and ascending Unicode-scalar key order.
- A zero output limit returns before materializing or sorting count entries.
- Usage failures return
2; input and processing failures return1; success returns0.
These behaviors are the accepted target for this specimen, not a statement that the source, APIs, or error wording are already implemented.
Provisional APIs exposed by the program
The program makes the following design proposals visible:
| Surface | Provisional contract used here |
|---|---|
| Integer parsing | Int.parse(text, base: 10) : Int? and strict Int.parse!(text, base: 10) : Int; omitting base selects decimal |
| String prefixes | start_with? and immutable delete_prefix |
| String Unicode | default Character iteration, canonical normalized_casefold, explicit low-level unicode_scalars, and compare_unicode_scalars |
| Character | one extended grapheme cluster; letter? and number? inspect its constituent Unicode properties |
| Map | has?, indexed map[key] access and assignment, compound indexed assignment, and entries returning a fresh mutable snapshot Array whose immutable elements expose key and value |
| Array | shallow_copy, shift!, empty?, any?, and comparator-based sort! |
| Set | the empty literal {,}, add!, empty?, and iteration over unique values |
| Input stream | each_chunk, yielding byte buffers in source order |
| UTF-8 decoder | UTF8.strict_decoder returns a stateful decoder; on_decode receives decoded String chunks, push! accepts bytes, and process do ... end validates and flushes the final chunk |
| Word segmentation | Unicode.default_word_segmenter; on_segment receives complete segments, push! accepts decoded text, and process do ... end flushes the final segment |
| Errors | PathError, IOError, BrokenPipeError < IOError, and UnicodeError; InputError adds source context while preserving the original error as its automatic cause, while a broken output pipe escapes silently to the CLI boundary |
| Files | scoped, read-only File.open(Path) do ... end |
| Output stream | write and puts, both raising on output failure |
The comparator passed to sort! follows the familiar negative/zero/positive
contract. compare_unicode_scalars is locale-independent and compares the NFC
keys by Unicode scalar value.
shallow_copy creates a new mutable Array while preserving references to the
same elements. That is sufficient here because String is immutable and the
parser mutates only the outer Array. Dab does not provide an ambiguous bare
copy alias. deep_copy is a distinct future graph-copy contract rather than
an alternate interpretation selected by context.
UTF8.strict_decoder returns the incremental decoder object itself. It does
not return or yield one Unicode code point at a time. Its registered on_decode
block receives decoded String chunks. Ordinary text iteration begins later
through the String itself; string.unicode_scalars is an explicit lower-level
view.
Both pipeline stages use process do ... end as a lifecycle boundary. Each
stage accepts pushes while its block runs, then validates and flushes exactly
once before returning. The downstream segmenter must therefore wrap the
upstream decoder: the decoder flushes its final text into a still-active
segmenter, and only then may the segmenter flush its final segment.
If a process body raises, the stage discards its unfinished state and
propagates that original error without attempting a normal final flush.
Cleanup must not replace the primary failure with a secondary decoder or
segmenter error.
for value in collection and collection.each do |value| consume the same
iteration protocol, but they are not syntactic synonyms. A for body is a
lexical loop body, so its break and next target that loop and return
returns from the enclosing callable. An each body is a closure under Dab’s
confirmed closure rules; its return returns only from that closure, and it
cannot use break or next to control the iterator. The Character predicate
uses any? because that directly expresses the required Boolean reduction.
Deliberately provisional choices
This specimen makes several concrete choices solely so it can be complete:
- encountering
--helpreturns immediately and ignores every later argument; - an attached short form such as
-n10is rejected as an unknown option; - before
--, one or more-arguments select a single stdin pass before the named files; after--,-is a literal filename; File.open(Path)means read-only binary input and its block argument conforms toInputStream;- exact diagnostic text is shown but is not yet a confirmed stable interface;
- decimal is the default for
Int.parseandInt.parse!; whether they accept leading+, non-ASCII digits, or surrounding whitespace still needs a decision; - the decoder and segmenter callback-registration APIs are proposals, including callback replacement rules; and
File.openstill exposes the known no-overloading question between scoped and escaping resource forms.
These are now reviewable lines of code rather than abstract questions. The planned Dab 0.1 acceptance contract gives the governing public design context.