Gerbil Scheme FFI Example Using the C Language Raptor RDF Library

The Foreign Function Interface (FFI) in Gerbil Scheme provides a powerful bridge to leverage existing C libraries directly within Scheme programs. This allows developers to extend Scheme applications with highly optimized, domain-specific functionality written in C, while still enjoying the high-level abstractions and rapid development style of Scheme. In this chapter, we will explore an end-to-end example of integrating the Raptor RDF parsing and serialization library into Gerbil Scheme, showing how to bind C functions and expose them as Scheme procedures.

Raptor is a mature C library for parsing, serializing, and manipulating RDF (Resource Description Framework) data in a variety of syntaxes, including RDF/XML, Turtle, N-Triples, and JSON-LD. By accessing Raptor from Gerbil Scheme, we open the door to semantic web applications, linked data processing, and graph-based reasoning directly within a Scheme environment. This example illustrates the mechanics of building FFI bindings, handling C-level memory and data types, and translating them into idiomatic Scheme representations. For reference, the official Raptor API documentation is available here: Raptor2 API Reference.

We will walk through the process step by step: setting up the Gerbil Scheme FFI definitions, mapping C functions and structs into Scheme, and writing test programs that parse RDF data and extract triples. Along the way, we will highlight practical issues such as error handling, memory management, symbol exporting, and resource cleanup. By the end of this chapter, you should have both a working Gerbil Scheme binding to Raptor and a general blueprint for integrating other C libraries into your Scheme projects. For background on Gerbil Scheme’s FFI itself, consult the Gerbil Documentation: FFI.

Implementation of a FFI Bridge Library for Raptor

The library is located in the file gerbil_scheme_book/source_code/RaptorRDF_FFI/ffi.ss. This code demonstrates how to use the Foreign Function Interface (FFI) to integrate with the Raptor RDF C library. It provides three levels of Scheme-accessible procedures: a low-level raptor-parse-file->ntriples that returns raw N-Triples text, a high-level parse-rdf-file with error handling, and a structured parse-rdf-file->triples that returns parsed Scheme lists. This example highlights the practical use of FFI in Gerbil Scheme: exposing a C function to Scheme, managing memory safely across the boundary, and translating RDF data into representations that Scheme programs can process directly.

The C FFI Layer

The first part of ffi.ss embeds C code inline using Gerbil’s begin-ffi / c-declare mechanism:

 1 (export raptor-parse-file->ntriples   ;; low-level (returns #f on error)
 2         parse-rdf-file                ;; high-level (raises on error)
 3         parse-rdf-file->triples)      ;; structured (list of s/p/o lists)
 4 
 5 (import :std/foreign)
 6 
 7 (begin-ffi (raptor-parse-file->ntriples)
 8   (c-declare #<<'C'
 9 #include <raptor2.h>
10 #include <string.h>
11 #include <stdlib.h>
12 
13 #ifndef RAPTOR_STRING_ESCAPE_FLAG_NTRIPLES
14 #define RAPTOR_STRING_ESCAPE_FLAG_NTRIPLES 0x4
15 #endif
16 
17 /* Write one triple to the iostream in N-Triples and a newline */
18 static void triples_to_iostr(void* user_data, raptor_statement* st) {
19   raptor_iostream* iostr = (raptor_iostream*)user_data;
20 
21   raptor_term_escaped_write(st->subject, RAPTOR_STRING_ESCAPE_FLAG_NTRIPLES, iostr);
22   raptor_iostream_write_byte(' ', iostr);
23   raptor_term_escaped_write(st->predicate, RAPTOR_STRING_ESCAPE_FLAG_NTRIPLES, iostr);
24   raptor_iostream_write_byte(' ', iostr);
25   raptor_term_escaped_write(st->object, RAPTOR_STRING_ESCAPE_FLAG_NTRIPLES, iostr);
26   raptor_iostream_write_byte(' ', iostr);
27   raptor_iostream_write_byte('.', iostr);
28   raptor_iostream_write_byte('\n', iostr);
29 }
30 
31 /* Parse `filename` with syntax `syntax_name` and return N-Triples as char*.
32    The caller receives a standard malloc'd copy; the Raptor buffer is freed
33    here to avoid leaking memory. */
34 static char* parse_file_to_ntriples(const char* filename, const char* syntax_name) {
35   raptor_world *world = NULL;
36   raptor_parser* parser = NULL;
37   unsigned char *uri_str = NULL;
38   raptor_uri *uri = NULL, *base_uri = NULL;
39   raptor_iostream *iostr = NULL;
40   void *out_string = NULL;
41   size_t out_len = 0;
42   char *result = NULL;
43 
44   world = raptor_new_world();
45   if(!world) return NULL;
46   if(raptor_world_open(world)) { raptor_free_world(world); return NULL; }
47 
48   /* Where triples go: a string iostream that materializes on free */
49   iostr = raptor_new_iostream_to_string(world, &out_string, &out_len, NULL);
50   if(!iostr) { raptor_free_world(world); return NULL; }
51 
52   parser = raptor_new_parser(world, syntax_name ? syntax_name : "guess");
53   if(!parser) { raptor_free_iostream(iostr); raptor_free_world(world); return NULL; }
54 
55   raptor_parser_set_statement_handler(parser, iostr, triples_to_iostr);
56 
57   uri_str = raptor_uri_filename_to_uri_string((const unsigned char*)filename);
58   if(!uri_str) { raptor_free_parser(parser); raptor_free_iostream(iostr); raptor_free_world(world); return NULL; }
59 
60   uri = raptor_new_uri(world, uri_str);
61   base_uri = raptor_uri_copy(uri);
62 
63   /* Parse file; on each triple our handler appends to iostr */
64   raptor_parser_parse_file(parser, uri, base_uri);
65 
66   /* Clean up parser/URIs; free iostr LAST to finalize string */
67   raptor_free_parser(parser);
68   raptor_free_uri(base_uri);
69   raptor_free_uri(uri);
70   raptor_free_memory(uri_str);
71 
72   raptor_free_iostream(iostr); /* this finalizes out_string/out_len */
73 
74   /* Copy the raptor-allocated string into standard malloc'd memory,
75      then free the raptor buffer to avoid a memory leak. */
76   if (out_string) {
77     result = strdup((char*)out_string);
78     raptor_free_memory(out_string);
79   }
80 
81   raptor_free_world(world);
82 
83   return result;  /* Gambit copies to Scheme string via char-string */
84 }
85 'C'
86   )
87 
88   ;; Scheme visible wrapper:
89   (define-c-lambda raptor-parse-file->ntriples
90     (char-string       ;; filename
91      char-string)      ;; syntax name, e.g., "turtle", "rdfxml", or "guess"
92     char-string
93     "parse_file_to_ntriples"))

The C portion begins by including the raptor2.h header and defining a callback function, triples_to_iostr, which takes RDF statements and writes them to a Raptor iostream in N-Triples format. This callback escapes subjects, predicates, and objects correctly and ensures triples are terminated with a period and newline, conforming to the N-Triples standard. The main work is performed in parse_file_to_ntriples, which initializes a Raptor world and parser, configures the statement handler to use the callback, and sets up an iostream that accumulates parsed triples into a string buffer. Error checks are in place at every step, ensuring resources such as the world, parser, URIs, and iostream are properly freed if initialization fails.

After setup, the parser processes the input file identified by its filename and syntax. Each RDF statement is converted into N-Triples and appended to the output string via the iostream. Once parsing is complete, the parser, URIs, iostream, and world are released.

An important detail in the memory management: Raptor’s raptor_new_iostream_to_string allocates its output buffer using an internal allocator, so the caller is responsible for freeing it with raptor_free_memory. The C function uses strdup to copy the Raptor-allocated string into standard malloc’d memory, then immediately frees the original with raptor_free_memory. This prevents a memory leak that would otherwise occur on every call. The copy is then returned to Gambit, which makes its own GC-managed copy into a Scheme string via the char-string return type convention.

On the Scheme side, the define-c-lambda form binds this C function as the procedure raptor-parse-file->ntriples, exposing it with the expected (filename syntax-name) -> ntriples-string interface.

The following architecture diagram shows the structure of this FFI example, illustrating how Gerbil Scheme calls through the FFI boundary into the C wrapper code, which in turn uses the Raptor2 library to parse RDF files and serialize triples into N-Triples format.

Architecture diagram for the Raptor RDF FFI example
Figure 6. Architecture diagram for the Raptor RDF FFI example

High-Level Scheme API

Below the FFI layer, ffi.ss provides several pure Scheme functions that make the library easier to use. These use only standard Gambit/Gerbil builtins and require no additional imports.

First, two small utility functions: char-find locates a character in a string starting from a given position, and split-lines splits a string on newline characters:

 1 ;; Find the position of the first occurrence of char CH in STR
 2 ;; starting at FROM.  Returns #f if not found.
 3 (def (char-find str ch (from 0))
 4   (let ((len (string-length str)))
 5     (let loop ((i from))
 6       (cond
 7         ((>= i len) #f)
 8         ((char=? (string-ref str i) ch) i)
 9         (else (loop (+ i 1)))))))
10 
11 ;; Split STR on newlines, returning a list of strings.
12 (def (split-lines str)
13   (let ((len (string-length str)))
14     (let loop ((start 0) (acc '()))
15       (let ((pos (char-find str #\newline start)))
16         (if pos
17             (loop (+ pos 1) (cons (substring str start pos) acc))
18             (reverse (cons (substring str start len) acc)))))))

The first convenience wrapper is parse-rdf-file, which adds a default syntax of “guess” and raises a clear error instead of returning a silent empty string when parsing fails:

1 ;; Parse an RDF file and return N-Triples as a string.
2 ;; SYNTAX can be "turtle", "rdfxml", "ntriples", or "guess" (default).
3 ;; Signals an error if parsing fails.
4 (def (parse-rdf-file filename (syntax "guess"))
5   (let ((result (raptor-parse-file->ntriples filename syntax)))
6     (if result
7         result
8         (error "Failed to parse RDF file" filename syntax))))

For working with the N-Triples output as structured data, three more helper functions parse individual lines and assemble them into Scheme lists. strip-trailing-dot removes the trailing “ .“ from N-Triples object fields, skip-spaces finds the first non-space character in a line, and parse-ntriple-line combines them to extract subject, predicate, and object from a single line:

 1 ;; Strip trailing " ." from an N-Triples object field.
 2 (def (strip-trailing-dot str)
 3   (let ((len (string-length str)))
 4     (if (and (>= len 2)
 5              (char=? (string-ref str (- len 1)) #\.)
 6              (char=? (string-ref str (- len 2)) #\space))
 7         (substring str 0 (- len 2))
 8         str)))
 9 
10 ;; Find the start of non-space content in LINE.  Returns #f if blank.
11 (def (skip-spaces line)
12   (let ((len (string-length line)))
13     (let loop ((i 0))
14       (cond
15         ((>= i len) #f)
16         ((char=? (string-ref line i) #\space) (loop (+ i 1)))
17         (else i)))))
18 
19 ;; Parse a single N-Triples line into (subject predicate object), or #f.
20 ;; Skips blank lines and comments.
21 (def (parse-ntriple-line line)
22   (let ((start (skip-spaces line)))
23     (cond
24       ((not start) #f)
25       ((char=? (string-ref line start) #\#) #f)
26       (else
27        (let ((s-end (char-find line #\space start)))
28          (and s-end
29               (let ((p-end (char-find line #\space (+ s-end 1))))
30                 (and p-end
31                      (let ((subj (substring line start s-end))
32                            (pred (substring line (+ s-end 1) p-end))
33                            (obj  (strip-trailing-dot
34                                   (substring line (+ p-end 1) (string-length line)))))
35                        (list subj pred obj))))))))))

Finally, parse-rdf-file->triples ties everything together: it parses a file, splits the resulting N-Triples into lines, and returns a list of (subject predicate object) triples as Scheme lists:

 1 ;; Parse an RDF file and return a list of (subject predicate object) lists.
 2 (def (parse-rdf-file->triples filename (syntax "guess"))
 3   (let ((nt-string (parse-rdf-file filename syntax)))
 4     (let loop ((lines (split-lines nt-string)) (acc '()))
 5       (if (null? lines)
 6           (reverse acc)
 7           (let ((parsed (parse-ntriple-line (car lines))))
 8             (if parsed
 9                 (loop (cdr lines) (cons parsed acc))
10                 (loop (cdr lines) acc)))))))

This layered design gives readers three entry points depending on their needs: raw N-Triples text from the low-level FFI call, a safe string-returning wrapper with error handling, or fully structured Scheme data ready for further processing.

Test Code

This Gerbil Scheme test code in the file test.ss exercises all three API levels by creating a minimal Turtle input, invoking the parser in multiple modes, testing the structured output, and verifying error behavior on nonexistent files. It uses call-with-output-file (a Gambit builtin) to write the temporary test file:

 1 ;; Test suite for ffi.ss: validates N-Triples output from Raptor2 FFI
 2 
 3 (import "ffi" :gerbil/gambit)
 4 (export main)
 5 
 6 (define (assert-equal expected actual label)
 7   (if (equal? expected actual)
 8       (begin (display "PASS ") (display label) (newline) #t)
 9       (begin
10         (display "FAIL ") (display label) (newline)
11         (display "Expected:\n") (display expected) (newline)
12         (display "Actual:\n") (display actual) (newline)
13         (exit 1))))
14 
15 (define (main . args)
16   (let* ((ttl-file "sample.ttl")
17          (ttl-content "@prefix ex: <http://example.org/> .\nex:s ex:p ex:o .\n")
18          (expected-nt "<http://example.org/s> <http://example.org/p> <http://example.org/o> .\n"))
19 
20     ;; Prepare sample Turtle file
21     (call-with-output-file ttl-file
22       (lambda (p) (display ttl-content p)))
23 
24     ;; 1. Parse with explicit syntax
25     (let ((nt1 (raptor-parse-file->ntriples ttl-file "turtle")))
26       (assert-equal expected-nt nt1 "turtle -> ntriples"))
27 
28     ;; 2. Parse with syntax guessing
29     (let ((nt2 (raptor-parse-file->ntriples ttl-file "guess")))
30       (assert-equal expected-nt nt2 "guess -> ntriples"))
31 
32     ;; 3. High-level API with default syntax
33     (let ((nt3 (parse-rdf-file ttl-file)))
34       (assert-equal expected-nt nt3 "parse-rdf-file (default syntax)"))
35 
36     ;; 4. Structured triples API
37     (let ((triples (parse-rdf-file->triples ttl-file)))
38       (assert-equal 1 (length triples) "parse-rdf-file->triples returns 1 triple")
39       (assert-equal '("<http://example.org/s>"
40                       "<http://example.org/p>"
41                       "<http://example.org/o>")
42                     (car triples)
43                     "parse-rdf-file->triples structure"))
44 
45     ;; 5. Error handling: nonexistent file returns empty string
46     (let ((bad (raptor-parse-file->ntriples "no-such-file.ttl" "turtle")))
47       (assert-equal "" bad "nonexistent file returns empty string"))
48 
49     ;; Clean up
50     (when (file-exists? ttl-file)
51       (delete-file ttl-file))
52 
53     (display "All tests passed.\n")))

The test imports the FFI wrapper and exports a main entry point. The assert-equal helper prints PASS/FAIL labels and exits with non-zero status on mismatch. In function main, a small Turtle document defines a simple triple using the ex: prefix; the corresponding expected N-Triples string is the fully expanded IRI form with a terminating period and newline.

The test proceeds in five phases: first it calls raptor-parse-file->ntriples with explicit “turtle” syntax; then repeats using “guess” to confirm auto-detection yields identical output; third, it tests the high-level parse-rdf-file wrapper with its default syntax; fourth, it exercises parse-rdf-file->triples and verifies that the returned Scheme list contains the correct subject, predicate, and object strings; finally, it confirms that attempting to parse a nonexistent file produces an empty string rather than crashing. After all assertions pass, the temporary file is deleted.

Build System

The Makefile uses pkg-config to auto-detect Raptor2’s include and library paths, with a Homebrew fallback for macOS systems where pkg-config may not be in the default search path. This single Makefile works on both macOS and Linux:

 1 ##### macOS and Linux — auto-detects raptor2 paths
 2 #
 3 # Requires: raptor2, pkg-config
 4 #   macOS:  brew install raptor pkg-config
 5 #   Linux:  sudo apt install libraptor2-dev pkg-config
 6 
 7 # Try pkg-config first; fall back to brew --prefix on macOS
 8 RAPTOR_PKG := $(shell pkg-config --exists raptor2 2>/dev/null && echo yes)
 9 ifeq ($(RAPTOR_PKG),yes)
10   CC_OPTS := $(shell pkg-config --cflags raptor2)
11   LD_OPTS := $(shell pkg-config --libs raptor2)
12 else
13   # Homebrew keg-only fallback (macOS)
14   RAPTOR_PREFIX := $(shell brew --prefix raptor 2>/dev/null)
15   ifdef RAPTOR_PREFIX
16     CC_OPTS := -I$(RAPTOR_PREFIX)/include/raptor2
17     LD_OPTS := -L$(RAPTOR_PREFIX)/lib -lraptor2
18   else
19     $(error raptor2 not found. Install with: brew install raptor  OR  sudo apt install libraptor2-dev)
20   endif
21 endif
22 
23 build:
24     gxc -cc-options "$(CC_OPTS)" -ld-options "$(LD_OPTS)" ffi.ss
25     gxc -cc-options "$(CC_OPTS)" -ld-options "$(LD_OPTS)" -exe -o test test.ss
26 
27 clean:
28     rm -f *.c *.scm *.o *.so test

The Makefile first checks whether pkg-config knows about raptor2. If so, it uses the standard —cflags and —libs flags. Otherwise, on macOS, it falls back to brew —prefix raptor to locate the Homebrew installation. If neither method finds Raptor, the build stops with a helpful error message showing the install command for both platforms.

Build and test output:

 1 $ make
 2 gxc -cc-options "..." -ld-options "..." ffi.ss
 3 gxc -cc-options "..." -ld-options "..." -exe -o test test.ss
 4 $ ./test
 5 PASS turtle -> ntriples
 6 PASS guess -> ntriples
 7 PASS parse-rdf-file (default syntax)
 8 PASS parse-rdf-file->triples returns 1 triple
 9 PASS parse-rdf-file->triples structure
10 PASS nonexistent file returns empty string
11 All tests passed.

Optional Practice Problems

  1. In-Memory Parsing: Modify the C and Scheme FFI logic in ffi.ss to add support for parsing RDF from an in-memory string buffer rather than reading it from a file path on disk.
  2. DataType and LangTag Handling: The current parse-ntriple-line splits on spaces. Extend it to properly handle and parse literals with language tags (like "apple"@en) or XML Schema datatypes (like "123"^^<http://www.w3.org/2001/XMLSchema#integer>).
  3. RDF Query Helper: Write a Scheme function using parse-rdf-file->triples that allows users to query and filter the resulting triples list by a specific subject or predicate URI.