feat: part of the story api exposed via c bindings

This commit is contained in:
Brett Broadhurst 2026-04-13 12:10:01 -06:00
parent a065e5bf46
commit 3afbbb6ec2
Failed to generate hash of commit
6 changed files with 748 additions and 16 deletions

142
build.zig
View file

@ -1,8 +1,7 @@
const std = @import("std"); const std = @import("std");
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) !void {
const use_llvm = true; const use_llvm = true;
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
const mod = b.addModule("ink", .{ const mod = b.addModule("ink", .{
@ -21,11 +20,71 @@ pub fn build(b: *std.Build) void {
}), }),
// Added because of a switch on a corrupt value on git hash // Added because of a switch on a corrupt value on git hash
// a8a6ce8d0f14c1dd5b3ea42e46f3226bcbe11a87 // a8a6ce8d0f14c1dd5b3ea42e46f3226bcbe11a87
.use_llvm = true, .use_llvm = use_llvm,
.use_lld = use_llvm,
});
const lib = b.addLibrary(.{
.linkage = .static,
.name = "ink",
.root_module = b.createModule(.{
.root_source_file = b.path("src/c_api.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "ink", .module = mod },
},
}),
.use_llvm = use_llvm,
.use_lld = use_llvm,
}); });
const pc: *std.Build.Step.InstallFile = pc: {
const file = b.addWriteFile("libink.pc", b.fmt(
\\prefix={s}
\\includedir=${{prefix}}/include
\\libdir=${{prefix}}/lib
\\
\\Name: libink
\\URL: https://codeberg.org/haxolotl/ink
\\Description: TODO
\\Version: 0.1.0
\\Cflags: -I${{includedir}}
\\Libs: -L${{libdir}} -link
, .{b.install_prefix}));
break :pc b.addInstallFileWithDir(
file.getDirectory().path(b, "libink.pc"),
.prefix,
"share/pkgconfig/libink.pc",
);
};
const c_header = b.addInstallFileWithDir(
b.path("include/ink.h"),
.header,
"ink/ink.h",
);
const examples = try buildExamples(b, target, optimize, lib);
b.getInstallStep().dependOn(&lib.step);
b.getInstallStep().dependOn(&c_header.step);
b.getInstallStep().dependOn(&pc.step);
b.installArtifact(lib);
b.installArtifact(exe); b.installArtifact(exe);
for (examples) |example_exe| {
b.getInstallStep().dependOn(&b.addInstallArtifact(
example_exe,
.{
.dest_dir = .{
.override = .{
.custom = "bin/example",
},
},
},
).step);
}
const run_step = b.step("run", "Run the app"); const run_step = b.step("run", "Run the app");
const run_cmd = b.addRunArtifact(exe); const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step); run_step.dependOn(&run_cmd.step);
@ -42,16 +101,8 @@ pub fn build(b: *std.Build) void {
}); });
const run_mod_tests = b.addRunArtifact(mod_tests); const run_mod_tests = b.addRunArtifact(mod_tests);
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
.use_llvm = use_llvm,
.use_lld = use_llvm,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run tests"); const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step); test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
const run_cover = b.addSystemCommand(&.{ const run_cover = b.addSystemCommand(&.{
"kcov", "kcov",
@ -63,3 +114,72 @@ pub fn build(b: *std.Build) void {
const cover_step = b.step("cover", "Generate test coverage report"); const cover_step = b.step("cover", "Generate test coverage report");
cover_step.dependOn(&run_cover.step); cover_step.dependOn(&run_cover.step);
} }
fn buildExamples(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
c_lib_: ?*std.Build.Step.Compile,
) ![]const *std.Build.Step.Compile {
const alloc = b.allocator;
var steps: std.ArrayList(*std.Build.Step.Compile) = .empty;
defer steps.deinit(alloc);
var dir = try std.fs.cwd().openDir(try b.build_root.join(
b.allocator,
&.{"examples"},
), .{ .iterate = true });
defer dir.close();
var it = dir.iterate();
while (try it.next()) |entry| {
const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue;
if (index == 0) continue;
const name = entry.name[0..index];
const is_zig = std.mem.eql(u8, entry.name[index + 1 ..], "zig");
const exe: *std.Build.Step.Compile = if (is_zig) exe: {
const exe = b.addExecutable(.{
.name = name,
.root_module = b.createModule(.{
.root_source_file = b.path(b.fmt(
"examples/{s}",
.{entry.name},
)),
.target = target,
.optimize = optimize,
}),
});
exe.root_module.addImport("ink", b.modules.get("ink").?);
break :exe exe;
} else exe: {
const c_lib = c_lib_ orelse return error.UnsupportedPlatform;
const exe = b.addExecutable(.{
.name = name,
.root_module = b.createModule(.{
.target = target,
.optimize = optimize,
}),
});
exe.linkLibC();
exe.addIncludePath(b.path("include"));
exe.addCSourceFile(.{
.file = b.path(b.fmt(
"examples/{s}",
.{entry.name},
)),
.flags = &[_][]const u8{
"-Wall",
"-Wextra",
"-pedantic",
"-std=c99",
"-D_POSIX_C_SOURCE=199309L",
},
});
exe.linkLibrary(c_lib);
break :exe exe;
};
try steps.append(alloc, exe);
}
return steps.toOwnedSlice(alloc);
}

330
examples/ink-c-driver.c Normal file
View file

@ -0,0 +1,330 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
#include <ink.h>
struct option {
char *name;
int id;
bool has_args;
};
struct ink_source {
uint8_t *bytes;
size_t length;
};
enum {
OPTION_UNKNOWN = -2,
OPTION_OPERAND = -3
};
enum {
OPT_COLORS = 1000,
OPT_COMPILE_ONLY,
OPT_USE_STDIN,
OPT_DUMP_AST,
OPT_DUMP_IR,
OPT_DUMP_STORY,
OPT_VM_TRACING,
OPT_HELP,
};
static const struct option cli_options[] = {
{"--colors", OPT_COLORS, false},
{"--compile-only", OPT_COMPILE_ONLY, false},
//{"--dump-ast", OPT_DUMP_AST, false},
//{"--dump-ir", OPT_DUMP_AST, false},
//{"--dump-story", OPT_DUMP_STORY, false},
//{"--trace", OPT_VM_TRACING, false},
{"--stdin", OPT_USE_STDIN, false},
{"--help", OPT_HELP, false},
{"-h", OPT_HELP, false},
{0},
};
static const char *usage_msg =
"Usage: %s [OPTION]... [FILE]\n"
"Load and execute an Ink story.\n\n"
" -h, --help Print this message\n"
" --colors Enable color output\n"
" --compile-only Compile the story without executing\n"
" --stdin Read source file from standard input\n"
"\n";
static const struct option *_g_option_opts;
static char *_g_arg_ptr;
static char **_g_option_argv;
static char *option_operand;
static char *option_unknown_opt;
static void print_usage(const char *name)
{
fprintf(stderr, usage_msg, name);
}
static void option_setopts(const struct option *opts, char **argv)
{
_g_option_argv = argv;
_g_option_opts = opts;
}
static int option_nextopt(void)
{
if (*_g_option_argv != NULL) {
++_g_option_argv;
} else {
return 0;
}
char **optstr_p = _g_option_argv;
if (*optstr_p == NULL) {
return 0;
}
const struct option *p = _g_option_opts;
while (p->id != 0) {
if (!strcmp(*optstr_p, p->name)) {
if (p->has_args) {
_g_arg_ptr = optstr_p[1];
++_g_option_argv;
}
return p->id;
} else if (**optstr_p != '-') {
option_operand = *optstr_p;
return OPTION_OPERAND;
}
++p;
}
option_unknown_opt = *optstr_p;
return OPTION_UNKNOWN;
}
static char *option_nextarg(void)
{
char *arg = _g_arg_ptr;
char *arg_p = arg;
while (*arg_p != '\0' && *arg_p != ',') {
++arg_p;
}
if (*arg_p == '\0') {
_g_arg_ptr = arg_p;
return arg;
}
_g_arg_ptr = arg_p + 1;
*arg_p = '\0';
return arg;
}
#define INK_SOURCE_BUF_MAX 1024
static const char *INK_FILE_EXT = ".ink";
static const size_t INK_FILE_EXT_LENGTH = 4;
static int ink_read_file(const char *file_path, uint8_t **bytes, size_t *length)
{
size_t sz = 0, nr = 0;
uint8_t *b = NULL;
FILE *const fp = fopen(file_path, "rb");
if (!fp) {
fprintf(stderr, "Could not read file '%s'\n", file_path);
return -1;
}
fseek(fp, 0u, SEEK_END);
sz = (size_t)ftell(fp);
fseek(fp, 0u, SEEK_SET);
b = malloc(sz + 1);
if (!b) {
fclose(fp);
return -1;
}
nr = fread(b, 1u, sz, fp);
if (nr < sz) {
fprintf(stderr, "Could not read file '%s'.\n", file_path);
fclose(fp);
free(b);
return -1;
}
b[nr] = '\0';
*bytes = b;
*length = sz;
fclose(fp);
return 0;
}
static void ink_source_free(struct ink_source *s)
{
free(s->bytes);
s->bytes = NULL;
s->length = 0;
}
static int ink_source_load_stdin(struct ink_source *s)
{
uint8_t *tmp;
char b[INK_SOURCE_BUF_MAX];
s->bytes = NULL;
s->length = 0;
while (fgets(b, INK_SOURCE_BUF_MAX, stdin)) {
const size_t len = s->length;
const size_t buflen = strlen(b);
tmp = realloc(s->bytes, len + buflen + 1);
if (!tmp) {
ink_source_free(s);
return -1;
}
s->bytes = tmp;
memcpy(s->bytes + len, b, buflen);
s->length += buflen;
s->bytes[s->length] = '\0';
}
if (ferror(stdin)) {
ink_source_free(s);
return -1;
}
return 0;
}
static int ink_source_load(const char *file_path, struct ink_source *s)
{
const char *ext;
const size_t namelen = strlen(file_path);
s->bytes = NULL;
s->length = 0;
if (namelen < INK_FILE_EXT_LENGTH) {
return -1;
}
ext = file_path + namelen - INK_FILE_EXT_LENGTH;
if (!(strncmp(ext, INK_FILE_EXT, INK_FILE_EXT_LENGTH) == 0)) {
return -1;
}
return ink_read_file(file_path, &s->bytes, &s->length);
}
int main(int argc, char *argv[])
{
bool compile_only = false;
bool use_stdin = false;
int opt = 0;
int rc = -1;
int flags = 0;
const char *filename = NULL;
struct ink_source source;
struct ink_story *story = NULL;
option_setopts(cli_options, argv);
while ((opt = option_nextopt())) {
switch (opt) {
case OPT_COLORS:
flags |= INK_F_USE_COLOR;
break;
case OPT_DUMP_AST:
flags |= INK_F_DUMP_AST;
break;
case OPT_DUMP_IR:
flags |= INK_F_DUMP_IR;
break;
case OPT_DUMP_STORY:
flags |= INK_F_DUMP_CODE;
break;
case OPT_COMPILE_ONLY:
compile_only = true;
break;
case OPT_USE_STDIN:
use_stdin = true;
break;
case OPTION_UNKNOWN:
fprintf(stderr, "Unrecognised option %s.\n\n", option_unknown_opt);
print_usage(argv[0]);
return EXIT_FAILURE;
case OPT_HELP:
print_usage(argv[0]);
return EXIT_SUCCESS;
case OPTION_OPERAND:
filename = option_operand;
break;
default:
print_usage(argv[0]);
return EXIT_FAILURE;
}
}
if (filename == NULL || *filename == '\0') {
if (use_stdin) {
rc = ink_source_load_stdin(&source);
filename = "<STDIN>";
} else {
print_usage(argv[0]);
return EXIT_FAILURE;
}
} else {
rc = ink_source_load(filename, &source);
}
if (rc < 0) {
fprintf(stderr, "Error while loading source file.\n");
return rc;
}
story = ink_open();
if (!story) {
goto out;
}
const struct ink_story_options opts = {
.source_bytes = source.bytes,
.source_length = source.length,
.filename = (uint8_t *)filename,
.filename_length = strlen(filename),
.flags = flags,
};
rc = ink_load_story_options(story, &opts);
if (rc < 0) {
goto out;
}
if (!compile_only) {
while (ink_story_can_continue(story)) {
const char *line = NULL;
size_t linelen = 0;
size_t choice_index = 0;
while (ink_story_continue(story, &line, &linelen)) {
if (line) {
printf("%.*s\n", (int)linelen, line);
}
}
while (ink_story_choice_next(story, &line, &linelen)) {
printf("%zu: %.*s\n", ++choice_index, (int)linelen, line);
}
if (choice_index > 0) {
printf("> ");
scanf("%zu", &choice_index);
ink_story_choose(story, choice_index);
}
}
}
out:
ink_close(story);
ink_source_free(&source);
return EXIT_SUCCESS;
}

122
include/ink.h Normal file
View file

@ -0,0 +1,122 @@
#ifndef INK_API_H
#define INK_API_H
/**
* @defgroup ink_api API
*
* This module documents the exported functions that form the public API
* of libink.
*
* @{
*/
#ifdef __cplusplus
extern "C" {
#endif
#if defined(_WIN32) || defined(__CYGWIN__)
#ifdef BUILDING_INKLIB
#define INK_API __declspec(dllexport)
#else
#define INK_API __declspec(dllimport)
#endif
#else
#if defined(__GNUC__) || defined(__clang__)
#define INK_API __attribute__((visibility("default")))
#else
#define INK_API
#endif
#endif
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/**
* @struct ink_story
*
* @brief Opaque type representing a story context.
*
* This forward declaration hides the internal structure of an `ink_story`.
* Clients of the API should manipulate `ink_story` objects only through the
* provided functions.
*/
typedef struct ink_story ink_story;
struct ink_story_options {
const uint8_t *filename;
size_t filename_length;
const uint8_t *source_bytes;
size_t source_length;
int flags;
};
enum ink_flags {
INK_F_RESERVED_1 = (1 << 0),
INK_F_RESERVED_2 = (1 << 1),
INK_F_RESERVED_3 = (1 << 2),
INK_F_RESERVED_4 = (1 << 3),
INK_F_USE_COLOR = (1 << 4),
INK_F_DUMP_AST = (1 << 5),
INK_F_DUMP_IR = (1 << 6),
INK_F_DUMP_CODE = (1 << 7),
INK_F_RESERVED_9 = (1 << 8),
INK_F_RESERVED_10 = (1 << 9),
INK_F_RESERVED_11 = (1 << 10),
INK_F_RESERVED_12 = (1 << 11),
INK_F_RESERVED_13 = (1 << 12),
INK_F_RESERVED_14 = (1 << 13),
INK_F_RESERVED_15 = (1 << 14),
INK_F_RESERVED_16 = (1 << 15),
};
/**
* @brief Open a story context.
*
* @returns a new story context
*/
INK_API struct ink_story *ink_open(void);
/**
* Close a story context.
*/
INK_API void ink_close(struct ink_story *story);
/**
* Load an Ink story with extended options.
*
* @returns a non-zero value on error.
*/
INK_API int ink_load_story_options(struct ink_story *story,
const struct ink_load_options *options);
/**
* Determine if the story can continue.
*
* @returns a boolean value, indicating if the story can continue.
*/
INK_API bool ink_story_can_continue(struct ink_story *story);
/**
* Advance the story and output content, if available.
*
* @returns a non-zero value on error.
*/
INK_API int ink_story_continue(struct ink_story *story, uint8_t **line,
size_t *linelen);
INK_API int ink_story_choice_next(struct ink_story *story, uint8_t **line,
size_t *linelen);
/**
* Select a choice by its index.
*
* @returns a non-zero value on error.
*/
INK_API int ink_story_choose(struct ink_story *story, size_t index);
#ifdef __cplusplus
}
#endif
#endif

View file

@ -38,6 +38,7 @@ constants_pool: []const Value = &.{},
/// Interned string bytes. /// Interned string bytes.
string_bytes: []const u8 = &.{}, string_bytes: []const u8 = &.{},
dump_writer: ?*std.Io.Writer = null, dump_writer: ?*std.Io.Writer = null,
internal_counter: usize = 0,
pub const default_knot_name: [:0]const u8 = "$__main__$"; pub const default_knot_name: [:0]const u8 = "$__main__$";
@ -372,23 +373,23 @@ pub fn deinit(story: *Story) void {
story.* = undefined; story.* = undefined;
} }
fn currentFrame(vm: *Story) *CallFrame { pub fn currentFrame(vm: *Story) *CallFrame {
assert(vm.call_stack_top > 0); assert(vm.call_stack_top > 0);
return &vm.call_stack[vm.call_stack_top - 1]; return &vm.call_stack[vm.call_stack_top - 1];
} }
fn peekStack(vm: *Story, offset: usize) ?Value { pub fn peekStack(vm: *Story, offset: usize) ?Value {
if (vm.stack_top <= offset) return null; if (vm.stack_top <= offset) return null;
return vm.stack[vm.stack_top - offset - 1]; return vm.stack[vm.stack_top - offset - 1];
} }
fn pushStack(vm: *Story, value: Value) !void { pub fn pushStack(vm: *Story, value: Value) !void {
if (vm.stack_top >= vm.stack.len) return error.StackOverflow; if (vm.stack_top >= vm.stack.len) return error.StackOverflow;
vm.stack[vm.stack_top] = value; vm.stack[vm.stack_top] = value;
vm.stack_top += 1; vm.stack_top += 1;
} }
fn popStack(vm: *Story) ?Value { pub fn popStack(vm: *Story) ?Value {
if (vm.stack_top == 0) return null; if (vm.stack_top == 0) return null;
const stack_top = vm.stack_top; const stack_top = vm.stack_top;
@ -489,7 +490,7 @@ fn divertValue(vm: *Story, value: Value, args_count: u8) !void {
} }
// Diverts are essentially tail calls. // Diverts are essentially tail calls.
fn divert(vm: *Story, knot: *Object.Knot, args_count: u8) !void { pub fn divert(vm: *Story, knot: *Object.Knot, args_count: u8) !void {
assert(knot.code.args_count == args_count); assert(knot.code.args_count == args_count);
if (vm.call_stack_top == 0) if (vm.call_stack_top == 0)
return vm.call(knot, args_count); return vm.call(knot, args_count);

158
src/c_api.zig Normal file
View file

@ -0,0 +1,158 @@
const std = @import("std");
const ink = @import("ink");
const Story = ink.Story;
const Module = ink.Module;
var global_allocator: std.heap.DebugAllocator(.{}) = .init;
var stdout_buffer: [4096]u8 align(std.heap.page_size_min) = undefined;
pub export fn ink_open() callconv(.c) ?*Story {
const gpa = global_allocator.allocator();
const story = gpa.create(Story) catch |err| switch (err) {
error.OutOfMemory => return null,
};
story.* = .{
.gpa = gpa,
.arena = .init(gpa),
.is_exited = false,
.can_advance = false,
.stack_top = 0,
.call_stack_top = 0,
.output_marker = 0,
.choice_selected = null,
.output_buffer = .empty,
.output_scratch = .empty,
.current_choices = .empty,
.variable_observers = .empty,
.globals = .empty,
.stack = &.{},
.call_stack = &.{},
.code_chunks = .empty,
.gc_objects = .{},
.constants_pool = &.{},
.string_bytes = &.{},
.dump_writer = null,
};
return story;
}
pub export fn ink_close(story: *Story) callconv(.c) void {
defer _ = global_allocator.deinit();
const gpa = story.gpa;
story.deinit();
gpa.destroy(story);
}
pub const InkLoadOpts = extern struct {
filename: [*]const u8,
filename_length: usize,
source_bytes: [*]const u8,
source_length: usize,
flags: i32,
};
fn loadStory(story: *Story, options: *const InkLoadOpts) !void {
const gpa = story.gpa;
var arena_allocator = std.heap.ArenaAllocator.init(gpa);
defer arena_allocator.deinit();
const arena = arena_allocator.allocator();
const stderr = std.fs.File.stderr();
var stderr_writer = stderr.writer(&stdout_buffer);
var comp = try Module.compile(gpa, arena, .{
.source_bytes = options.source_bytes[0..options.source_length :0],
.filename = options.filename[0..options.filename_length],
.dump_writer = null,
.dump_use_color = false,
.dump_ast = false,
.dump_ir = false,
});
defer comp.deinit();
if (comp.errors.items.len > 0) {
for (comp.errors.items) |err| {
try comp.renderError(&stderr_writer.interface, err);
}
return error.LoadFailed;
}
// TODO: Make this configureable.
const stack_size = 128;
const eval_stack_ptr = try gpa.alloc(Story.Value, stack_size);
errdefer gpa.free(eval_stack_ptr);
const call_stack_ptr = try gpa.alloc(Story.CallFrame, stack_size);
errdefer gpa.free(call_stack_ptr);
story.can_advance = false;
story.stack = eval_stack_ptr;
story.call_stack = call_stack_ptr;
errdefer story.deinit();
try comp.setupStoryRuntime(gpa, story);
if (story.getKnot(Story.default_knot_name)) |knot| {
try story.pushStack(.{ .object = &knot.base });
try story.divert(knot, 0);
}
}
pub export fn ink_load_story_options(
story: *Story,
options: *const InkLoadOpts,
) callconv(.c) c_int {
loadStory(story, options) catch |err| {
std.debug.print("{any}\n", .{@errorName(err)});
return -1;
};
return 0;
}
pub export fn ink_story_can_continue(story: *Story) callconv(.c) bool {
return !story.is_exited and story.can_advance;
}
pub export fn ink_story_continue(
story: *Story,
line: *?[*]const u8,
linelen: *usize,
) callconv(.c) c_int {
const result = story.advance() catch return -1;
if (result) |slice| {
line.* = slice.ptr;
linelen.* = slice.len;
return 1;
} else {
line.* = null;
linelen.* = 0;
return 0;
}
}
// FIXME: The internal counter may be a bad idea...
pub export fn ink_story_choice_next(
story: *Story,
line: *?[*]const u8,
linelen: *usize,
) callconv(.c) c_int {
if (story.internal_counter < story.current_choices.items.len) {
const choice = &story.current_choices.items[story.internal_counter];
line.* = choice.content.ptr;
linelen.* = choice.content.len;
story.internal_counter += 1;
return 1;
} else {
line.* = null;
linelen.* = 0;
return 0;
}
}
// FIXME: The internal counter may be a bad idea...
pub export fn ink_story_choose(story: *Story, index: usize) callconv(.c) c_int {
story.selectChoiceIndex(index) catch {
return -1;
};
story.internal_counter = 0;
return 0;
}

View file

@ -2,6 +2,7 @@ const std = @import("std");
const tokenizer = @import("tokenizer.zig"); const tokenizer = @import("tokenizer.zig");
pub const Story = @import("Story.zig"); pub const Story = @import("Story.zig");
pub const Ast = @import("Ast.zig"); pub const Ast = @import("Ast.zig");
pub const Module = @import("compile.zig").Module;
pub const max_src_size = std.math.maxInt(u32); pub const max_src_size = std.math.maxInt(u32);