Fuente: handbook/ en el repositorio. Cada bloque de código compila: el CI corre ray check sobre cada uno.
Herramienta de terminal
Un programa de línea de comandos es el entregable más simple de raylang: un binario de menos de un megabyte que arranca en milisegundos. Este capítulo construye notes, una herramienta de notas para la terminal, y con ella las reglas de un buen ciudadano de la terminal: argumentos y opciones, entrada por tubería, salida para personas y para programas, y códigos de salida.
El proyecto completo está en examples/apps/notes-cli, con sus
tests. Los bloques de raylang están copiados de él y el CI comprueba que sigan siéndolo.
notes add "Compras" --tag casa < lista.txt
notes list --tag casa
notes search leche --json | jq '.[].title'
notes rm 3 --yes
1. El proyecto
ray new notes-cli && cd notes-cli
No hace falta ningún paquete: todo lo que usa está en la biblioteca estándar. El código se reparte en cuatro módulos:
| Módulo | Qué hace |
|---|---|
flags.ray |
ordena los argumentos en comando, posicionales y opciones |
store.ray |
las notas, un archivo Markdown por nota |
output.ray |
lo que se imprime: tabla, colores y JSON |
main.ray |
los comandos y los códigos de salida |
Las notas se guardan como archivos de texto que cualquier otra herramienta puede leer:
# Compras
tags: casa, finde
leche, pan
2. Argumentos y opciones
args() devuelve los argumentos como un arreglo. raylang no trae una librería de opciones, y una
herramienta con unos pocos comandos no la necesita: cincuenta líneas los ordenan.
/// A parsed command line: `notes add "Title" --tag work --json`.
pub struct Parsed {
command: string,
positional: [string],
options: Map<string, [string]>,
}
/// Sorts `argv` into command, positionals and options. `value_options` are the options that
/// take a value (`--tag work` or `--tag=work`); any other `--name` is a switch. A bare `--`
/// ends the options: everything after it is positional.
pub fn parse(argv: [string], value_options: [string]) -> Result<Parsed, string> {
var command = "";
var positional: [string] = [];
var options: Map<string, [string]> = Map.new();
var only_positional = false;
var i = 0;
while (i < argv.len()) {
let a = argv[i];
i = i + 1;
if (only_positional || !a.starts_with("--")) {
if (command == "") {
command = a;
} else {
positional.push(a);
}
continue;
}
if (a == "--") {
only_positional = true;
continue;
}
var name = a.substring(2, a.len());
var values = options.get_or(name, []);
match (name.index_of("=")) {
// --tag=work
Option.Some(eq) => {
let value = name.substring(eq + 1, name.len());
name = name.substring(0, eq);
values = options.get_or(name, []);
values.push(value);
},
Option.None => {
if (value_options.contains(name)) {
// --tag work
if (i >= argv.len()) {
return Result.Err("option --" + name + " needs a value");
}
values.push(argv[i]);
i = i + 1;
}
},
}
options.insert(name, values);
}
Result.Ok(Parsed { command: command, positional: positional, options: options })
}
Tres detalles que los usuarios esperan: --tag casa y --tag=casa valen lo mismo, una opción
se puede repetir, y un -- suelto termina las opciones, para poder buscar un texto que empieza
por --.
Una opción mal escrita debe ser un error, no un silencio:
/// Rejects options the command does not know, so a typo is an error and not a silent no-op.
pub fn only(p: Parsed, known: [string]) -> Result<int, string> {
for (name, _) in p.options {
if (!known.contains(name)) {
return Result.Err("unknown option --" + name);
}
}
Result.Ok(0)
}
3. Entrada por tubería
El cuerpo de una nota llega por la entrada estándar cuando viene de una tubería o un archivo.
term.is_tty(0) dice si la entrada es una terminal: si lo es, no hay nada que leer, y el programa
no debe quedarse esperando.
// Everything on stdin, when it is a pipe or a file. With a terminal on stdin there is nothing to
// read and the program must not sit waiting.
fn piped_input() -> string {
if (term.is_tty(0)) {
return "";
}
var lines: [string] = [];
while (true) {
match (input()) {
Option.Some(line) => lines.push(line),
Option.None => break,
}
}
lines.join("\n")
}
input() devuelve una línea, o None al final de la entrada.
4. Salida para personas y para programas
Los colores solo tienen sentido cuando hay una persona mirando. La regla es: la salida es una
terminal, y el usuario no ha pedido lo contrario con NO_COLOR.
/// Whether to colour the output: a terminal on stdout, and the user has not opted out.
pub fn colours() -> bool {
term.is_tty(1) && env("NO_COLOR").is_none()
}
fn paint(code: string, text: string, on: bool) -> string {
if (on) { "\u{1B}[" + code + "m" + text + "\u{1B}[0m" } else { text }
}
Al redirigir a un archivo o a otra orden, term.is_tty(1) es falso y la salida queda limpia. La
tabla se ajusta al ancho de la terminal con term.size(), y usa term.width y term.fit, que
cuentan celdas y no caracteres: un emoji o un carácter chino ocupan dos.
Para que otro programa use la salida, --json. El texto se compone con una cadena de comilla
invertida y cada valor entra con ${…}:
/// The notes as a JSON array, for scripts.
pub fn as_json(notes: [Note]) -> string {
var items: [string] = [];
for n in notes {
let tags = json.render_arr(json.list(n.tags));
items.push(
`{"id": ${n.id}, "title": ${quote(n.title)}, "tags": ${tags}, "body": ${quote(n.body)}}`
);
}
"[" + items.join(", ") + "]"
}
5. Errores y códigos de salida
Un script decide qué hacer según el código de salida, así que cada final tiene el suyo:
| Código | Significa |
|---|---|
| 0 | hecho |
| 1 | lo pedido no existe (una nota, o una búsqueda sin resultados) |
| 64 | la línea de comandos está mal |
| 74 | error de disco |
El código es el entero que devuelve main. Los errores viajan como valores de un tipo propio, que
distingue las dos clases de fallo:
// Why a command failed. The two kinds end differently: a wrong command line prints the help and
// exits 64; a disk error exits 74.
enum Failure {
Usage(string),
Io(string),
}
fn bad_usage(e: string) -> Failure {
Failure.Usage(e)
}
fn io_error(e: string) -> Failure {
Failure.Io(e)
}
Cada comando propaga con ?, y main decide el final en un solo sitio:
fn main() -> int {
let p = match (flags.parse(args(), ["tag"])) {
Result.Ok(p) => p,
Result.Err(e) => return usage(e),
};
if (flags.has(p, "help")) {
print(HELP);
return 0;
}
match (run(p, store.default_dir())) {
Result.Ok(code) => code,
Result.Err(Failure.Usage(e)) => usage(e),
Result.Err(Failure.Io(e)) => {
eprint("notes: " + e);
IO_ERROR
},
}
}
Los mensajes de error van a la salida de errores con eprint, para que no se mezclen con los
datos en una tubería. La búsqueda sigue la convención de grep: sin resultados, sale con 1.
if (p.command == "list" || p.command == "search") {
flags.only(p, ["tag", "json"]).map_err(bad_usage)?;
let text = if (p.command == "search") { p.positional.join(" ") } else { "" };
if (p.command == "search" && text == "") {
return Result.Err(Failure.Usage("search takes the text to look for"));
}
let tag = flags.values(p, "tag").join("");
let found = store.filter(store.all(dir), tag, text);
if (flags.has(p, "json")) {
print(output.as_json(found));
} else if (found.len() > 0) {
print(output.table(found, output.colours()));
}
// Like grep: a search that finds nothing is exit code 1, so `notes search x && …` works.
return Result.Ok(if (p.command == "search" && found.len() == 0) { NOT_FOUND } else { 0 });
}
6. Preguntar antes de borrar
notes rm pregunta, salvo que se pase --yes. Si no hay una terminal no hay a quién preguntar:
la respuesta es no, y el mensaje sugiere --yes. Así un script nunca se queda colgado.
// Asks a yes/no question on the terminal. Without a terminal there is nobody to ask: the answer
// is no, and the caller tells the user about --yes.
fn confirm(question: string) -> bool {
if (!term.is_tty(0)) {
return false;
}
let _ = io.write(question + " [y/N] ");
let _ = io.flush();
match (input()) {
Option.Some(answer) => answer.trim().to_lower() == "y",
Option.None => false,
}
}
io.write escribe sin salto de línea y io.flush hace que la pregunta aparezca antes de esperar
la respuesta. Para una contraseña, term.read_hidden lee sin mostrar lo que se teclea, y
term.read_key lee tecla a tecla para menús interactivos.
7. Tests
Los tests cubren las piezas sin lanzar el programa: el parser de argumentos, los archivos y la salida. Cada uno usa su propia carpeta temporal.
ray test
8. El binario
ray build --native --release -o notes
./notes add "Primera nota" < /dev/null
El binario de notes ocupa unos 800 KB y no depende de nada instalado. Arranca en unos 3 ms, medido
en un MacBook Pro M3 Pro, así que se puede llamar en un bucle de shell sin que se note.
--target compila para otra plataforma, y el capítulo Distribuir cubre cómo
publicarlo.
Siguiente paso
LLM y MCP: otra herramienta de terminal, esta vez un agente que habla con un modelo y usa herramientas.