- CupidScript
- Built-In Types
- Control Flow (Additional)
- Truthiness (Reminder)
- Multi-File Scripts
- Advanced Features
- Directory Overview
- Build Instructions
- Using CupidScript in C Hosts
- CupidFM Plugins
- fm.* API (Plugin Scripting API)
- Notes & Limitations
- CupidScript Standard Library & New Features
CupidFM supports plugin scripting via CupidScript, a lightweight, embeddable scripting language and VM written in C99. This document details plugin loading, the plugin architecture, the fm.* API exposed to scripts in CupidFM, and a comprehensive overview of the latest CupidScript language features.
CupidScript is a compact, embeddable scripting VM (C99), designed for fast integration. It provides a simple C API for host embedding, plugin scripting, and seamless extension with native functions.
- Core runtime: Lexer, parser, AST, virtual machine, and a minimal standard library.
- Sample CLI and main (
src/main.c): Demonstrates embedding, native API registration (e.g.,fm.*). - Public headers for C API (
src/cupidscript.h, etc). - Examples/tests covering all language features, host extension, and API use.
CupidScript: small, dynamic, with a modern feature set. Tree-walk interpreter; strong runtime error reporting; simple types.
let name = expr; // declaration
name = expr; // assignment
fn add(a, b) { return a + b; }
if (cond) { ... } else { ... }
while (cond) { ... }
return expr;- Operators:
||,&&,==,!=,<,<=,>,>=,+,-,*,/,%, unary!,- - Types/Values:
nil,true/false, int, float, string, bytes, list, map, set, tuple, strbuf, range, promise, function, native - Keywords:
let,const,fn,async,await,yield,class,struct,enum,self,super,if,else,while,switch,case,default,for,in,break,continue,return,throw,try,catch,finally,defer,match,import,export
CupidScript includes cutting-edge features found in modern scripting languages:
-
Tuples - Immutable, fixed-size value groupings
let point = (x: 10, y: 20); // named tuple let coords = (1, 2, 3); // positional tuple print(point.x, coords[0]);
-
Comprehensions - Concise syntax for transforming collections
let squares = [x * x for x in 1..=10]; let evens = [x for x in nums if x % 2 == 0]; let word_map = {word: len(word) for word in words}; let unique = #{x for x in list}; // set comprehension
-
Destructuring - Extract values into variables
let [a, b, ...rest] = [1, 2, 3, 4, 5]; let (x, y) = get_coords(); // function returning tuple
-
Pattern Matching -
matchexpressions for powerful branchinglet result = match value { 0 => "zero", 1..=5 => "small", _ => "large" };
-
Walrus Operator - Assign and test in one expression
if (result := compute()) { print("Success:", result); } while (line := read_line()) { process(line); }
-
Pipe Operator - Chain function calls fluently
let result = data |> filter(_) |> map(_) |> sum();
-
Arrow Functions - Concise function syntax
let add = fn(a, b) => a + b; let square = fn(x) => x * x;
-
Spread & Rest - Flexible argument handling
let all = [...list1, ...list2]; let merged = {...map1, ...map2}; fn sum(...nums) { return list_sum(nums); }
-
Sets - Unique collections with set operations
let s = #{1, 2, 3}; let union = set1 | set2; let intersection = set1 & set2; let difference = set1 - set2;
-
Classes & Inheritance - Object-oriented programming
class File { fn new(path) { self.path = path; } fn is_hidden() { return starts_with(self.path, "."); } } class ImageFile : File { fn is_image() { return ends_with(self.path, ".png"); } }
-
Structs - Lightweight data carriers
struct Point { x, y = 0 } let p = Point(5, 10);
-
Enums - Named integer constants
enum Color { Red, Green = 5, Blue } print(Color.Red); // 0
-
Async/Await - Asynchronous programming
async fn fetch_data(url) { let response = await http_get(url); return response; }
-
Generators - Lazy value sequences
fn range(n) { let i = 0; while (i < n) { yield i; i += 1; } }
-
String Interpolation - Embed expressions in strings
let name = "Alice"; print("Hello ${name}, you have ${count} messages");
-
Raw Strings - Backtick strings without escape processing
let path = `C:\Users\Frank\Documents`; let multiline = `line 1 line 2`;
- Anonymous Functions & Closures
let double = fn(x) { return x*2; }; let add_fn = fn(a,b) { return a+b; }; fn make_counter() { let n = 0; return fn() { n=n+1; return n; }; }
- First-Class Functions: Pass/return/assign functions; store in containers.
- Short-Circuit Logic:
&&,|| - String Concatenation: Use
+, e.g.,"foo" + 123 - Modern Errors: Stack traces, rich source location.
- Indexed map access (
m[k]), map key querying (keys(m)) - Optional trailing commas in lists/maps
- Improved error/stack reporting, with precise line/col info
- Defer statements - Execute code when leaving scope
fn process() { let f = open_file("data.txt"); defer close_file(f); // always called before returning // ... work with file ... }
- Const bindings - Immutable variable declarations
const PI = 3.14159; const MAX_SIZE = 100;
typeofreturns detailed type names ("native", "function", "tuple", "set", etc)fmtsupports more specifiers (%b,%vetc)assert_eq,assert_ne(testing stdlib)
These are the core language/stdlib behaviors implemented by the current lexer/parser/VM as documented in the CupidScript wiki.
-
List and map literals
let xs = [1, 2, 3]; let m = {"name": "Frank", "age": 30};
-
Map field access sugar (maps only)
let m = {"name": "Frank"}; print(m.name); // same as m["name"]
If the value is not a map,
obj.fieldis a runtime error. -
for ... inloops (iterate lists; maps iterate keys)for x in [10, 20, 30] { print(x); } for k in keys({"a": 1, "b": 2}) { print(k); }
-
C-style
for (init; cond; incr)loopsfor (i = 0; i < 10; i = i + 1) { print(i); }
-
Range operator
let nums = 0..5; // [0,1,2,3,4] let incl = 0..=5; // [0,1,2,3,4,5] for i in 1..=3 { print(i); }
Ranges work in both directions (ascending/descending) automatically.
-
Ternary expression
let max = a > b ? a : b;
-
Exceptions:
throwandtry/catchtry { throw "boom"; } catch (e) { print("caught:", e); }
-
Standardized error objects:
error,is_error,format_error, globalERRtry { throw error("Division by zero", "DIV_ZERO"); } catch (e) { print(format_error(e)); }
CupidScript supports a rich set of built-in types:
let xs = [1, 2, 3]; // list
let m = {"name": "Frank"}; // map
let s = #{1, 2, 3}; // set
let t = (x: 10, y: 20); // tuple (named)
let coords = (1, 2, 3); // tuple (positional)
let b = bytes([0x48, 0x65]); // bytes
let r = 1..10; // rangelet xs = list();
push(xs, 10);
push(xs, 20);
xs[1] = 99;
print(xs[0], xs[1], len(xs)); // 10 99 2- Index by integer (0-based)
- Negative or out-of-range returns
nil - Dynamic and mutable
- Supports spread:
[...list1, ...list2]
let m = map();
m["answer"] = 42;
print(m["answer"], keys(m)); // 42 ["answer"]
print(m.answer); // field access sugar- Generalized keys: Any value can be a key (string, int, bool, list, map, etc.)
- Key equality uses
==rules (int/float compare by value, strings by content) - Missing keys return
nil - Supports spread:
{...map1, ...map2}
let s = #{1, 2, 3};
s.add(4);
s.remove(2);
print(s.contains(3)); // true
print(s.size()); // 3
// Set operations
let union = set1 | set2;
let intersection = set1 & set2;
let difference = set1 - set2;
let symmetric_diff = set1 ^ set2;- Unique values (by
==) - Set operations:
|(union),&(intersection),-(difference),^(symmetric difference) - Literal:
#{1, 2, 3}or#{}for empty - Comprehensions:
#{x for x in list if condition}
// Positional tuple
let coords = (10, 20, 30);
print(coords[0], coords[1], coords[2]);
// Named tuple
let point = (x: 10, y: 20);
print(point.x, point.y);
// Destructuring
let (a, b, c) = coords;
let [x, y] = get_position();- Immutable - cannot modify after creation
- Access by index (positional) or field name (named)
- Perfect for returning multiple values from functions
let b = bytes([72, 101, 108, 108, 111]); // "Hello"
print(b[0]); // 72
b[0] = 104; // modify to "hello"- Mutable byte buffer for binary data
- Index returns int 0-255
- Out-of-range returns
nil
let r = 0..5; // [0,1,2,3,4] - exclusive end
let incl = 0..=5; // [0,1,2,3,4,5] - inclusive end
for i in 1..=10 { print(i); }- Works in both directions (ascending/descending)
- Can iterate with
for ... in
CupidScript supports:
break;/continue;insidewhile,for ... in, and C-styleforloops.return;/return expr;from inside any block.
Used by if, while, !, &&, ||:
nilis falseboolis its value- everything else is true (including
0)
load("path")— always executes (like#include)require("path")— only executes the first time (like JSrequire)- New: Loads use per-VM cache; relative to current file/script.
Additional module/path behavior (per wiki):
- The VM maintains a current-directory stack so relative module loads resolve relative to the calling script.
require_optional("path")behaves likerequire(), but returnsnilif the file is missing.require()returns a module exports map. Inside a required file, these globals are available:exports(map)__file__(string, resolved module path)__dir__(string, directory containing the module)
CupidScript provides built-in support for structured data formats:
let data = {"name": "Frank", "age": 30};
let json_str = json_encode(data);
let parsed = json_decode(json_str);let csv_data = csv_parse("name,age\nFrank,30\nAlice,25");
let csv_str = csv_format([{"name": "Frank", "age": 30}]);let yaml_str = "name: Frank\nage: 30";
let data = yaml_parse(yaml_str);
let yaml = yaml_format(data);let xml = "<root><item>test</item></root>";
let doc = xml_parse(xml);// Match checking
if (regex_is_match("[0-9]+", text)) {
print("Contains digits");
}
// Find first match
let email = regex_find("([a-z]+)@([a-z]+\\.[a-z]+)", text);
if (email != nil) {
print(email["match"]); // full match
print(email.groups[0]); // first capture group
}
// Find all matches
let nums = regex_find_all("[0-9]+", "x=7 y=42 z=105");
// Replace
let clean = regex_replace("[a-z]+@[a-z]+\\.[a-z]+", text, "<hidden>");// Event loop (background thread for true async)
event_loop_start();
// Async functions
async fn fetch_data(url) {
let response = await http_get(url);
return response;
}
// Multiple concurrent operations
let p1 = fetch_data("https://api.example.com/1");
let p2 = fetch_data("https://api.example.com/2");
let result1 = await p1;
let result2 = await p2;
// Sleep/timers
await sleep(1000); // milliseconds
event_loop_stop();Promise Helpers:
promise()- create new promiseresolve(p, value)- resolve promisereject(p, error)- reject promisepromise_all(promises)- wait for allpromise_race(promises)- wait for firstpromise_any(promises)- wait for first success
// HTTP requests (async)
let response = await http_get("https://api.github.com/users/octocat");
let data = json_decode(response);
// TCP sockets
let sock = await tcp_connect("example.com", 80);
await socket_send(sock, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n");
let response = await socket_recv(sock, 4096);
socket_close(sock);// Read/write files
let content = read_file("config.txt");
write_file("output.txt", "Hello, world!");
// Binary files
let data = read_file_bytes("image.png");
write_file_bytes("copy.png", data);
// Directory operations
let entries = list_dir(".");
mkdir("new_folder");
rm("old_file.txt");
rename("old.txt", "new.txt");
// File info
if (exists("file.txt") && is_file("file.txt")) {
print("File exists");
}
if (is_dir("folder")) {
print("Directory exists");
}
// Glob patterns (platform-specific)
let txt_files = glob("*.txt");
let all_cs = glob("**/*.cs", "src");// Current time
let ms = unix_ms();
let seconds = unix_s();
// Date/time maps
let now = datetime_now();
let utc = datetime_utc();
print("${now.year}-${now.month}-${now.day} ${now.hour}:${now.minute}");
// Convert from Unix timestamp
let dt = datetime_from_unix_ms(ms);src/cs_value.c,src/cs_lexer.c,src/cs_parser.c,src/cs_vm.c,src/cs_stdlib.c– core runtime, VM, and stdlibsrc/main.c– CLI/embedding demo and entry pointsrc/cupidscript.h(embedding API header)- Build system:
Makefile
Requires: C99 compiler (gcc/clang), POSIX tools.
make allOutputs:
bin/libcupidscript.a(static library)bin/cupidscript(CLI interpreter)
See README for explicit build commands if not using make.
Typical embedding workflow:
- Create and configure VM:
cs_vm* vm = cs_vm_new(); cs_register_stdlib(vm); cs_register_native(vm, "fm.notify", my_cb, NULL);
- Execute scripts:
cs_vm_run_file(vm, "script.cs");
- Invoke function from C:
cs_call(vm, "myfunc", argc, argv, &out);
- Query last error (as string):
const char* err = cs_vm_last_error(vm);
Refer to the header for refcounted string/lists API, type checking, etc.
CupidFM exposes a native scripting API (fm.*) to plugins via CupidScript.
Place .cs plugin scripts in designated directories and CupidFM will autoload them at startup.
Load order (per session):
~/.cupidfm/plugins~/.cupidfm/plugin(legacy)./cupidfm/plugins./cupidfm/plugin(legacy)./plugins
All .cs scripts in these are loaded.
Plugins = Any CupidScript .cs file with optional hooks:
fn on_load()— after file loads (like init)fn on_key(key)— after keypress (return true/false to block/pass)fn on_dir_change(new_cwd, old_cwd)— when the panel dir changesfn on_selection_change(new_name, old_name)— selection changedfn on_editor_open(path)— when a file is opened in the built-in editorfn on_editor_change(line, col, text)— when editor content changes (insertions/deletions)- n on_editor_save(path) when a file is saved in the editor
New:
- Plugin API can export additional custom entry points.
- Error in hook? Stacktrace with file/line/col will display in notification area.
Keys use string names, e.g.:
"^T"= Ctrl+T"F5""KEY_UP"/"KEY_DOWN""Tab"- Any printable char (e.g.,
"a")
Helpers for mapping:
fm.key_code(name)— string→intfm.key_name(code)— int→string
The on_editor_open(path) callback is triggered when a file is opened in CupidFM's built-in text editor.
Parameters:
path(string): Absolute path to the file being opened in the editor
Use Cases:
- Logging file access and editing history
- Auto-formatting or linting on open
- Setting editor configurations based on file type
- Tracking editing patterns and statistics
- Custom notifications or prompts based on file content
- Integration with external tools or version control
Example:
fn on_editor_open(path) {
fm.console(fmt("Opened in editor: %s", path));
// Auto-format JSON files
if (ends_with(path, ".json")) {
fm.notify("JSON file detected - remember to validate syntax!");
}
// Check for specific patterns
if (contains(path, "/config/")) {
fm.notify("Editing configuration file - be careful!");
}
}Full Example Plugin:
See editor_open_logger.cs for a complete example that:
- Logs all files opened in the editor
- Tracks statistics by file type
- Provides helpful tips based on file extension
- Binds Ctrl+E to show editing statistics
Note: This callback fires once when the editor window opens, not on subsequent edits or saves within the same editing session.
Related Functions:
- Use
fm.editor_get_path()to get the current editor file path at any time (not just when opening) - Use
fm.editor_active()to check if the editor is currently open
Signature: fn on_editor_change(line, col, text)
Triggered when editor content changes (text insertions or deletions).
Parameters:
line(int): Line number where change occurred (1-indexed)col(int): Column where change occurred (1-indexed)text(string): Text that was inserted/deleted
Example:
letchanges = 0;
fn on_editor_change(line, col, text) {
changes = changes + 1;
if (changes % 100 == 0) {
fm.notify("100 changes made!");
}
}See: editor_change_demo.cs
The on_editor_save(path) callback is triggered whenever a file is successfully saved in CupidFM's built-in text editor.
Signature: fn on_editor_save(path)
Parameters:
path(string): Absolute path to the file that was just saved
Use Cases:
- Tracking save frequency and editing patterns
- Auto-backup of important files after save
- Running post-save actions (linting, formatting, compilation)
- Logging save history
- Triggering notifications or external integrations
- Statistics gathering for productivity tracking
Example:
let total_saves = 0;
fn on_editor_save(path) {
total_saves = total_saves + 1;
let filename = path_basename(path);
fm.console(fmt("Saved: %s (save #%d)", filename, total_saves));
// Auto-backup for important files
let ext = path_ext(path);
if (ext == ".c" || ext == ".h") {
fm.console("Auto-backup: Important C file saved");
}
}Full Example Plugin:
See editor_save_demo.cs for a complete example that:
- Tracks total saves per session
- Maintains a history of recently saved files (last 20)
- Shows congratulatory messages every 5 saves
- Provides save statistics on Ctrl+D
- Auto-detects and logs important file types (.c, .h)
- Uses helper functions with
substr()for safe string operations
Key Features in Example:
- Uses
path_basename()to extract filename from full path - Uses
path_ext()for file extension checking - Demonstrates list management with spread operator
- Shows proper use of
slice()for list trimming - Includes statistics display with formatted output
Note: This callback fires after the file has been successfully written to disk. If the save operation fails, the callback will not be triggered.
Related Callbacks:
- Use
on_editor_open(path)to track when files are first opened - Use
on_editor_change(line, col, text)to track individual edits between saves
The on_editor_cursor_move(old_line, old_col, new_line, new_col) callback is triggered whenever the cursor position changes in CupidFM's built-in text editor.
Signature: fn on_editor_cursor_move(old_line, old_col, new_line, new_col)
Parameters:
old_line(int): Previous line number (1-indexed)old_col(int): Previous column number (1-indexed)new_line(int): New line number (1-indexed)new_col(int): New column number (1-indexed)
Use Cases:
- Tracking cursor movement patterns and statistics
- Implementing context-aware features based on cursor position
- Real-time position monitoring for collaborative editing
- Building cursor navigation analytics
- Creating position-based triggers or notifications
- Implementing smart suggestions based on cursor location
Example:
let move_count = 0;
fn on_editor_cursor_move(old_line, old_col, new_line, new_col) {
move_count = move_count + 1;
// Calculate movement distance
let line_delta = new_line - old_line;
let col_delta = new_col - old_col;
// Log significant jumps (more than 10 lines)
if (line_delta > 10 || line_delta < -10) {
fm.console(fmt("Large jump: %d -> %d", old_line, new_line));
}
}Full Example Plugin:
See cursor_tracker.cs for a complete example that:
- Tracks all cursor movements
- Calculates movement statistics
- Displays analytics via Ctrl+M keybinding
- Demonstrates practical use of the API
Note: This callback fires for all cursor movements including arrow keys, mouse clicks, page up/down, home/end, and any other navigation commands. It will not fire if the cursor position doesn't actually change.
Related Functions:
- Use
fm.editor_get_cursor()to get the current cursor position at any time - Use
fm.editor_set_cursor(line, col)to programmatically move the cursor - Use
fm.editor_active()to check if the editor is currently open
The fm.editor_get_path() function returns the absolute path of the file currently open in the editor, or nil if no file is open.
Returns:
string: Absolute path to the currently open filenil: If the editor is not currently open
Use Cases:
- Checking which file is being edited
- Building file-specific tools or commands
- Tracking editing context
- Copying file paths to clipboard
- Conditional behavior based on file location
Example:
fn on_key(key) {
if (key == "^P") {
let path = fm.editor_get_path();
if (path == nil) {
fm.notify("No file open in editor");
} else {
fm.popup("Current File", fmt("Editing: %s", path));
}
return true;
}
return false;
}Full Example Plugin:
See editor_path_watcher.cs for a complete example that:
- Binds Ctrl+P to show current editor file path
- Binds Ctrl+Shift+P to copy path to clipboard
- Tracks when files change in the editor
- Extracts and displays filename separately from full path
CupidFM hosts these native functions for scripts:
fm.notify(msg)fm.status(msg)(alias)fm.popup(title, msg)fm.console_print(msg)/fm.console(msg)- Append a line to CupidFM's in-app console log (open with
key_console, default^O).
- Append a line to CupidFM's in-app console log (open with
fm.prompt(title, initial) -> string|nil- Modal input box. Returns the entered string, or
nilif cancelled.
- Modal input box. Returns the entered string, or
fm.confirm(title, msg) -> bool- Modal yes/no box.
fm.menu(title, items) -> int- Modal menu.
itemsis a list of strings. Returns selected index, or-1if cancelled.
- Modal menu.
Async variants (callback-based):
fm.prompt_async(title, initial, cb) -> boolfm.confirm_async(title, msg, cb) -> boolfm.menu_async(title, items, cb) -> bool- Queues a modal UI action and calls
cb(result)after it completes. cbmay be a function value OR a function name string.
- Queues a modal UI action and calls
fm.cwd()— current directory (left pane)fm.selected_name()— selection (filename or"")fm.selected_path()fm.entries() -> list- Returns the current visible directory listing (search-filtered when
fm.search_active()is true). - Each entry is a map:
{name,is_dir,size,mtime,mode,mime}.
- Returns the current visible directory listing (search-filtered when
fm.cursor()— index or -1fm.count()— count of files/itemsfm.search_active()— true if search openfm.search_query()fm.pane()— string ("directory" or "preview")
CupidFM provides a comprehensive API for manipulating the built-in text editor from plugins. All editor functions use 1-indexed line and column numbers.
| Function | Returns | Description |
|---|---|---|
fm.editor_active() |
bool |
True if the built-in text editor is currently open |
fm.editor_get_path() |
string | nil |
Current editor file path, or nil if editor not open |
fm.editor_save() |
bool |
Saves the current editor buffer to disk. Returns false if not editing or on write error. Triggers on_editor_save(path) on success |
fm.editor_save_as(path) |
bool |
Saves the current editor buffer to path and updates the current editor file path on success. Triggers on_editor_save(path) |
fm.editor_close() |
bool |
Closes the editor. Returns false if the editor is not open. If there are unsaved changes, CupidFM will ask to confirm discard |
fm.editor_reload() |
bool |
Reloads the current editor file from disk. Returns false if the editor is not open. If there are unsaved changes, CupidFM will ask to confirm discard |
fm.editor_set_readonly(readonly) |
bool |
Sets editor read-only mode. When enabled, editing and saving are blocked |
fm.editor_line_count() |
int |
Total number of lines in the editor, or 0 if not open |
fm.editor_get_cursor() |
map | nil |
Cursor position {line: int, col: int} (1-indexed), or nil if not editing |
fm.editor_set_cursor(line, col) |
bool |
Sets cursor position (1-indexed). Returns true on success, false if not editing or invalid position |
fm.editor_get_selection() |
map | nil |
Selection bounds {start_line, start_col, end_line, end_col} (1-indexed), or nil if no selection |
| Function | Returns | Description |
|---|---|---|
fm.editor_get_content() |
string | nil |
Entire editor buffer text, or nil if editor not open |
fm.editor_get_line(line_num) |
string | nil |
Single line content (1-indexed), or nil if out of range |
fm.editor_get_lines(start, end) |
list | nil |
List of lines in range (1-indexed, inclusive), or nil if invalid |
Example:
if (fm.editor_active()) {
let path = fm.editor_get_path();
let line_count = fm.editor_line_count();
let cursor = fm.editor_get_cursor();
fm.notify(fmt("Editing: %s (%d lines) at line %d", path, line_count, cursor["line"]));
// Get entire file content as a single string
let content = fm.editor_get_content();
if (content != nil) {
let char_count = len(content);
fm.console(fmt("File has %d characters", char_count));
// Check if content contains a string (manual search)
let has_todo = false;
for (let i = 0; i < len(content) - 3; i = i + 1) {
if (substr(content, i, 4) == "TODO") {
has_todo = true;
break;
}
}
if (has_todo) {
fm.notify("File contains TODO items");
}
}
// Get specific lines as a list
let lines = fm.editor_get_lines(1, 10); // First 10 lines
if (lines != nil) {
for line in lines {
fm.console(line);
}
}
// Get a single line (using get_lines with same start and end)
let first_lines = fm.editor_get_lines(1, 1);
if (first_lines != nil && len(first_lines) > 0) {
fm.console(fmt("First line: %s", first_lines[0]));
}
// Process a range of lines
let cursor = fm.editor_get_cursor();
if (cursor != nil) {
let current = cursor["line"];
// Get 5 lines before and after cursor
let context = fm.editor_get_lines(current - 5, current + 5);
if (context != nil) {
fm.notify(fmt("Got %d lines of context", len(context)));
}
}
// Move cursor to line 5, column 10
if (fm.editor_set_cursor(5, 10)) {
fm.notify("Cursor moved to line 5, column 10");
}
}| Function | Parameters | Returns | Description |
|---|---|---|---|
fm.editor_insert_text(text) |
text: string |
bool |
Inserts text at current cursor position |
fm.editor_replace_text(...) |
start_line, start_col, end_line, end_col, text |
bool |
Replaces text in specified range with new text |
fm.editor_delete_range(...) |
start_line, start_col, end_line, end_col |
bool |
Deletes text in specified range |
fm.editor_uppercase_selection() |
- | bool |
Converts current selection to uppercase (efficient built-in) |
fm.editor_save()
- Saves the currently open editor buffer to the current editor file on disk
- Returns
trueon success,falseif the editor is not open or a write error occurs - On success, triggers
on_editor_save(path)callbacks (same as pressing the editor save key)
See plugins/examples/editor_save_api_demo.cs for a minimal example that binds ^S to fm.editor_save().
fm.editor_save_as(path)
- Saves the currently open editor buffer to
path - On success, updates the “current editor path” (so
fm.editor_get_path()returns the new path and subsequentfm.editor_save()writes to the new file) - Returns
trueon success,falseif the editor is not open or a write error occurs - On success, triggers
on_editor_save(path)callbacks
See plugins/examples/editor_save_as_api_demo.cs for a minimal Save As example.
fm.editor_close()
- Requests that CupidFM close the built-in editor
- Returns
falseif the editor is not open - If there are unsaved changes, CupidFM will prompt to confirm discard
See plugins/examples/editor_close_api_demo.cs for a minimal example binding ^Q to close the editor.
fm.editor_reload()
- Reloads the current editor file from disk into the editor buffer
- Returns
falseif the editor is not open - If there are unsaved changes, CupidFM will prompt to confirm discard before reloading
See plugins/examples/editor_reload_api_demo.cs for a minimal example binding ^R to reload.
fm.editor_set_readonly(readonly)
- Enables/disables editor read-only mode
- Returns
falseif the editor is not open - When enabled, interactive edits and editor saves are blocked, and editor-write APIs return
false
See plugins/examples/editor_readonly_api_demo.cs for a minimal toggle example (^_O).
fm.editor_replace_text(start_line, start_col, end_line, end_col, text)
- All coordinates are 1-indexed
textcan include newlines for multi-line replacements- Example:
fm.editor_replace_text(1, 1, 1, 10, "new text")replaces characters 1-10 on line 1 - See
plugins/examples/editor_find_replace_demo.csfor complete find-and-replace example
fm.editor_delete_range(start_line, start_col, end_line, end_col)
- All coordinates are 1-indexed
- Deletes text from
(start_line, start_col)to(end_line, end_col) - Example:
fm.editor_delete_range(1, 1, 1, 10)deletes characters 1-10 on line 1 - See
plugins/examples/editor_delete_operations_demo.csfor examples including word deletion, line deletion, etc.
fm.editor_uppercase_selection()
- Only works when editor is active and text is selected
- More efficient than manually getting/replacing text for case conversion
- See
plugins/examples/editor_text_manipulation_demo.csfor usage with Ctrl+U keybinding
fn on_load() {
fm.bind("^F", "find_and_replace");
}
fn find_and_replace(key) {
if (!fm.editor_active()) {
fm.notify("Editor not active");
return true;
}
let sel = fm.editor_get_selection();
if (sel == nil) {
fm.notify("No selection");
return true;
}
let find = fm.prompt("Find:", "");
if (find == nil || find == "") return true;
let replace = fm.prompt("Replace with:", "");
if (replace == nil) return true;
// Get selected text
let start_line = sel["start_line"];
let start_col = sel["start_col"];
let end_line = sel["end_line"];
let end_col = sel["end_col"];
let lines = fm.editor_get_lines(start_line, end_line);
// ... process and replace text ...
return true;
}(Async: these actions run after script hook completes.)
fm.set_search(query) -> bool- Sets CupidFM's fuzzy filter/search query. Passing
""clears search.
- Sets CupidFM's fuzzy filter/search query. Passing
fm.clear_search() -> bool- Clears CupidFM's fuzzy filter/search query.
fm.bind(key, func_name)— bind a key to your function (fn receives key string, return true to block)fm.key_name(code)fm.key_code(name)
fm.reload()— refresh/reload panel UIfm.exit()— exit CupidFM
(Async: these actions run after script hook completes.)
fm.cd(path)fm.select(name)fm.select_index(i)fm.open_selected() -> bool- Opens the selected entry (enters directory if
is_dir, otherwise opens editor for the selected file).
- Opens the selected entry (enters directory if
fm.enter_dir() -> bool- Enters the selected directory (no-op if selection is not a directory).
fm.parent_dir() -> bool- Navigates to the parent directory.
(Async: these actions run after script hook completes.)
These operations are applied by CupidFM and recorded in CupidFM's undo stack, so plugin-triggered
actions work with fm.undo() / fm.redo().
Path args:
-
For
path, you can pass either a single string OR a list of strings. -
Relative paths are resolved under
fm.cwd(). -
fm.copy(path, dst_dir) -> bool- Copy files/dirs into
dst_dir(destination name uses the source basename). pathmay be a string or a list of strings.
- Copy files/dirs into
-
fm.move(path, dst_dir) -> bool- Move files/dirs into
dst_dir. pathmay be a string or a list of strings.
- Move files/dirs into
-
fm.rename(path, new_name) -> bool- Rename/move a single path.
- If
new_nameis relative, it stays in the same parent dir aspath. - If
new_nameis absolute, it is used as the full destination path.
-
fm.delete(path) -> bool- Soft-delete by moving into CupidFM's per-session trash (undoable).
pathmay be a string or a list of strings.
-
fm.mkdir(name_or_path) -> bool- Create a directory (relative paths are under
fm.cwd()).
- Create a directory (relative paths are under
-
fm.touch(name_or_path) -> bool- Create an empty file (relative paths are under
fm.cwd()).
- Create an empty file (relative paths are under
Bulk helpers:
-
fm.selected_paths() -> list- Returns a list of selected paths (currently at most one: the current selection).
-
fm.each_selected(fn_or_name)- Calls your function once per selected path (currently at most once).
fn_or_namecan be a function value OR the name of a function as a string.
Undo/redo:
fm.undo() -> boolfm.redo() -> bool
Save as ~/.cupidfm/plugins/example.cs:
fn on_load() {
fm.notify("plugin loaded!");
fm.bind("^K", "go_parent");
fm.bind("^J", "select_readme");
fm.bind("^D", "trash_selected");
}
fn go_parent(key) {
fm.parent_dir();
return true;
}
fn select_readme(key) {
fm.select("README.md");
return true;
}
fn trash_selected(key) {
fm.delete(fm.selected_path());
return true;
}
fn on_key(key) {
return false; // let CupidFM handle
}
fn on_dir_change(new_cwd, old_cwd) {
fm.status(fmt("dir: %s -> %s", old_cwd, new_cwd));
}
// optional:
fn on_selection_change(new_name, old_name) {
// handle selection moved
}File: plugins/examples/api_demo.cs
- Logs the first few results from
fm.entries()withfm.console. - Drives fuzzy search from scripts via
fm.set_search(query)andfm.clear_search(). - Drives navigation using
fm.open_selected(),fm.enter_dir(), andfm.parent_dir(). - Binds the console-friendly commands to F8–F12 so you can "stress-test" the new helpers.
- Plugins run in their own VMs, synchronously.
- Side effects (cd, select) happen after event/handler returns.
- Errors/exceptions: shown in the notification bar; full stack traces included.
- API is intentionally minimal; expect (and request!) expansion.
Registered by calling cs_register_stdlib(vm):
- Core:
print,assert,assert_eq,assert_ne,typeof,getenv - Lists/Maps:
list,map,len,push,pop,mget,mset,mhas,keys - Lists/Maps (additional):
insert,remove,slice,values,items,map_values,mdel - Copy helpers:
copy,deepcopy - List helpers:
reverse,reversed,contains - String utils:
str_find,str_replace,str_split, string interpolation$(...) - String utils (additional):
str_trim,str_ltrim,str_rtrim,str_lower,str_upper,str_startswith,str_endswith,str_repeat - Path utils:
path_join,path_dirname,path_basename,path_ext - Formatting:
fmtsupports%d,%s,%b,%v,%% - Time/helpers:
now_ms,sleep - Multi-file:
load(path),require(path),require_optional(path) - Error handling:
error,is_error,format_error, globalERR - Test helpers:
assert_eq,assert_ne - New features:
- Anonymous functions/closures
- First-class functions (can pass/store/call)
- Precise error stacktraces with file:line:col
- String refcounting (safe for hosting APIs)
- Top-level
awaitsupport (host opt-in) - Plugin state via lists/maps
- Async actions (navigation, selection happen after handlers)
- Host can register arbitrary new natives (see API)
Parse/runtime errors provide file:line:col and a full stacktrace:
Runtime error at examples/stacktrace.cs:3:15: division by zero
Stack trace:
at inner (examples/stacktrace.cs:8:16)
at outer (examples/stacktrace.cs:11:7)
cs_vm_last_error(vm) gives the last error message.
CupidScript includes safety controls to keep scripts from hanging the host.
Host-level (C API) examples:
cs_vm_set_instruction_limit(vm, N)cs_vm_set_timeout(vm, ms)cs_vm_interrupt(vm)
Script-level helpers (per wiki):
set_instruction_limit(n)/get_instruction_limit()/get_instruction_count()set_timeout(ms)/get_timeout()
To add new host→script functions (native API):
static int my_native(cs_vm* vm, void* ud, int argc, const cs_value* argv, cs_value* out) {
// check, return result via *out (if used)
if (out) *out = cs_nil();
return 0; // return nonzero or use cs_error() on error
}
cs_register_native(vm, "my.native", my_native, NULL);You may pass/retain cs_value (function refs, data) between script and C.
Types: nil, bool, int, float, string, list, map, strbuf, function, native function
- Lists/maps:
list,push,pop,insert,remove,slice,reverse,reversed,contains,copy,deepcopy - Maps:
map,mget,mset,mhas,mdel,keys,values,items,map_values - Strings:
"..."(with\n,\t,\\,\"escapes), concatenation, indexing - Control flow:
for x in xs {},for (init; cond; incr) {}, ranges0..10/0..=10, ternaryc ? a : b - Errors:
throw,try/catch,error,is_error,format_error, globalERR - Functions/closures:
fn make_counter() { let n = 0; return fn() { n=n+1; return n; }; }
- String interpolation:
"count: $(n)"(inject vars into strings) - List/map destructuring:
let [a,b,...rest] = xs;(new) - Top-level await: (host opt-in)
await sleep(100);(new)
- Compact and fast stack/call VM.
- All plugin natives registered via public C API.
- String memory is refcounted.
- Plugin VMs are fully isolated (no data sharing by default).
- Modern scripting features and error handling.
CupidFM and CupidScript are licensed under the GNU General Public License v3.
See LICENSE or https://www.gnu.org/licenses/gpl-3.0.html for details.