From 634eb7a9a90b3543463c337c17ed51298e3af848 Mon Sep 17 00:00:00 2001 From: Brad Arant Date: Tue, 25 Aug 2026 10:24:10 -0700 Subject: [PATCH] jetc: add #api directive + jetc_api_ route metadata export Stage 1 of the APIServer plan: a compiled --shared .so currently exports only a bare jetc_render_(Global&, MString&) with no way for a host to discover what HTTP method+path it wants to serve. Adds a leading "#api METHOD /path" directive (one or more, parsed the same way jetc already strips a leading "#!" shebang line - not modeled as a new AST/tag-attribute concept, since jetc's parser has no existing notion of the enclosing tag's attributes to hang this off of). In --shared mode, JetCodegen now also emits a second exported function alongside the render entry point: struct JetApiRoute { const char *method; const char *path; }; extern "C" const JetApiRoute *jetc_api_(int *count) { ... } is derived the same way for both symbols (sanitizeIdent( baseNameNoExt(inputPath))), so a future host loader can find both by convention from just the .so's filename - no name needs to be embedded in the metadata itself, and the routes a module declares can never drift out of sync with the render function that implements them (the failure mode a sidecar manifest file would have). Malformed directives (missing method/path, path without a leading '/') are a hard compile error; #api directives present without --shared print a non-fatal warning (the metadata has nowhere to go without a host to load a .so, but the render-only executable/CGI path still works fine). Verified live, not just compiled: rebuilt jetc, compiled demo3.jet (a new worked example with two #api lines sharing one handler) via build-shared.sh into a real demo3.so, and added api_test.cpp (mirrors host_test.cpp's dlopen()/dlsym() pattern) confirming jetc_api_demo3 returns the expected two routes at runtime. Re-ran host_test against the same .so to confirm the render path is unaffected. Confirmed demo.jet/ demo2.jet (no #api lines) emit no JetApiRoute code at all - no regression for templates that don't declare routes. Also fixed a gitignore gap surfaced while committing this: an earlier "jetc/*.cpp" ignore rule (meant for generated *.compiled.cpp output) was a blanket pattern that silently hid this commit's own api_test.cpp from git status. Narrowed to "jetc/*.compiled.cpp" (the actual generated-file naming, per build-compiled.sh/build-shared.sh) and added explicit ignores for the host_test/api_test/demo3 binaries plus two known stale generated leftovers (demo.cpp/demo2.cpp) that predate this session. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 15 +++++++++---- jetc/JetCodegen.cpp | 17 ++++++++++++++- jetc/JetCodegen.h | 23 +++++++++++++++++++- jetc/api_test.cpp | 39 ++++++++++++++++++++++++++++++++++ jetc/demo3.jet | 4 ++++ jetc/jetc.cpp | 52 +++++++++++++++++++++++++++++++++++++-------- 6 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 jetc/api_test.cpp create mode 100644 jetc/demo3.jet diff --git a/.gitignore b/.gitignore index cac6794..acd4903 100644 --- a/.gitignore +++ b/.gitignore @@ -6,13 +6,20 @@ docs/JetCore.dvi docs/JetCore.log docs/JetCore.toc -# jetc build byproducts (compiled JET-to-C++ output, binaries, .so/.a artifacts) +# jetc build byproducts (compiled JET-to-C++ output, binaries, .so/.a artifacts). +# Generated .cpp files are always named "*.compiled.cpp" (see build-compiled.sh/ +# build-shared.sh) - deliberately not a blanket "*.cpp", which would also +# swallow real source files added to jetc/ later (bit us once already: an +# earlier blanket rule silently hid api_test.cpp from git status). jetc/jetc jetc/demo jetc/demo2 -jetc/*.cpp +jetc/demo3 +jetc/host_test +jetc/api_test +jetc/demo.cpp +jetc/demo2.cpp +jetc/*.compiled.cpp jetc/*.pic.o jetc/*.so jetc/libCoreUtils_pic.a -!jetc/demo.jet -!jetc/demo2.jet diff --git a/jetc/JetCodegen.cpp b/jetc/JetCodegen.cpp index e7617eb..3dc16cf 100644 --- a/jetc/JetCodegen.cpp +++ b/jetc/JetCodegen.cpp @@ -173,7 +173,8 @@ namespace jetc { } std::string Codegen::generate(const std::vector &top, const std::string &sourceFileComment, - bool sharedMode, const std::string &entrySymbol) { + bool sharedMode, const std::string &entrySymbol, + const std::vector &apiRoutes, const std::string &apiSymbol) { out.str(""); out.clear(); out << "// " << sourceFileComment << "\n"; @@ -187,6 +188,20 @@ namespace jetc { out << "#include \n\n"; if (sharedMode) { + if (!apiRoutes.empty()) { + // A plain C struct, not any CoreUtils/JetCore type - this is the + // one piece of the plugin ABI a host loader needs to know about + // independent of the rest of this same-ABI C++ boundary. + out << "struct JetApiRoute { const char *method; const char *path; };\n"; + out << "extern \"C\" const JetApiRoute *" << apiSymbol << "(int *count) {\n"; + out << " static const JetApiRoute routes[] = {\n"; + for (auto &r : apiRoutes) + out << " { " << cstr(r.method) << ", " << cstr(r.path) << " },\n"; + out << " };\n"; + out << " *count = " << apiRoutes.size() << ";\n"; + out << " return routes;\n"; + out << "}\n\n"; + } out << "extern \"C\" void " << entrySymbol << "(jet::Global &global, coreutils::MString &out) {\n"; out << " coreutils::ZString jetc_empty(\"\");\n"; out << " coreutils::MString jetc_scopeOut;\n"; diff --git a/jetc/JetCodegen.h b/jetc/JetCodegen.h index fab4d54..d8f8b15 100644 --- a/jetc/JetCodegen.h +++ b/jetc/JetCodegen.h @@ -4,9 +4,19 @@ #include "JetAST.h" #include #include +#include namespace jetc { + // One `#api METHOD /path` directive from the top of the source template + // (see jetc.cpp) - a compile-time-known route this template wants to + // serve once loaded as a .so. Purely descriptive data handed to Codegen; + // jetc itself never interprets the method/path beyond storing them. + struct ApiRoute { + std::string method; + std::string path; + }; + // Emits a standalone .cpp translation unit that renders a compiled JET // template. The static tag/text structure becomes real C++ control flow; // anything genuinely dynamic (variable values, expr/value comparisons, @@ -19,6 +29,8 @@ namespace jetc { // sharedMode == false (default): emits a static jetc_render() plus a // main() that runs it once, catches coreutils::Exception, and writes // the result to stdout - the existing CGI-style standalone program. + // apiRoutes/apiSymbol are ignored in this mode (there's no host to + // dlsym them). // // sharedMode == true: emits no main() at all. Instead the render // logic is exported as `extern "C" void (jet::Global @@ -36,8 +48,17 @@ namespace jetc { // template (see jetc.cpp) so multiple .so's can coexist even if a // host ever dlopen()s them RTLD_GLOBAL instead of keeping separate // handles. + // + // If apiRoutes is non-empty, sharedMode also emits a second exported + // function `extern "C" const JetApiRoute *(int *count)` + // returning a static array describing every `#api` directive the + // source declared - a host's directory watcher/loader dlsym()'s this + // (by the same filename-derived naming convention as entrySymbol) to + // learn what to register, without any separate manifest file that + // could drift out of sync with the render function it describes. std::string generate(const std::vector &top, const std::string &sourceFileComment, - bool sharedMode = false, const std::string &entrySymbol = "jetc_render"); + bool sharedMode = false, const std::string &entrySymbol = "jetc_render", + const std::vector &apiRoutes = {}, const std::string &apiSymbol = "jetc_api"); private: std::ostringstream out; diff --git a/jetc/api_test.cpp b/jetc/api_test.cpp new file mode 100644 index 0000000..b6c1b41 --- /dev/null +++ b/jetc/api_test.cpp @@ -0,0 +1,39 @@ +// Minimal dlsym() check for the jetc_api_ route-metadata export added +// alongside jetc_render_ in --shared mode (see JetCodegen.cpp). Proves +// a host can discover a compiled module's declared routes purely from the +// .so itself, with no separate manifest file - the actual point of putting +// routing metadata in an exported symbol instead of a sidecar file. +#include +#include +#include + +struct JetApiRoute { const char *method; const char *path; }; +typedef const JetApiRoute *(*ApiFn)(int *count); + +int main(int argc, char **argv) { + if (argc < 3) { + std::cerr << "usage: api_test " << std::endl; + return 1; + } + void *handle = dlopen(argv[1], RTLD_NOW); + if (!handle) { + std::cerr << "dlopen failed: " << dlerror() << std::endl; + return 1; + } + dlerror(); + ApiFn api = (ApiFn)dlsym(handle, argv[2]); + const char *err = dlerror(); + if (err) { + std::cerr << "dlsym failed: " << err << std::endl; + return 1; + } + + int count = -1; + const JetApiRoute *routes = api(&count); + std::cout << count << " route(s):" << std::endl; + for (int i = 0; i < count; ++i) + std::cout << " " << routes[i].method << " " << routes[i].path << std::endl; + + dlclose(handle); + return 0; +} diff --git a/jetc/demo3.jet b/jetc/demo3.jet new file mode 100644 index 0000000..cb2e6ff --- /dev/null +++ b/jetc/demo3.jet @@ -0,0 +1,4 @@ +#!../jet-2.0 +#api GET /hello +#api HEAD /hello +Hello, API world. diff --git a/jetc/jetc.cpp b/jetc/jetc.cpp index 6169a16..8124405 100644 --- a/jetc/jetc.cpp +++ b/jetc/jetc.cpp @@ -58,24 +58,58 @@ int main(int argc, char **argv) { src = (nl == std::string::npos) ? std::string() : src.substr(nl + 1); } + // Consume zero or more leading "#api METHOD /path" directive lines + // (right after the optional shebang), the same way the shebang itself + // is stripped - a plain directive line, not a new tag/attribute concept, + // since jetc's parser has no existing notion of the enclosing tag's + // attributes to hang this off of. Each becomes one route this template + // wants to serve once loaded as a .so (see JetCodegen's apiRoutes). + std::vector apiRoutes; + while (src.rfind("#api ", 0) == 0) { + size_t nl = src.find('\n'); + std::string line = (nl == std::string::npos) ? src : src.substr(0, nl); + src = (nl == std::string::npos) ? std::string() : src.substr(nl + 1); + + std::istringstream ls(line); + std::string tag, method, path; + ls >> tag >> method >> path; + if (method.empty() || path.empty() || path[0] != '/') { + std::cerr << "jetc: malformed #api directive (expected \"#api METHOD /path\"): " + << line << std::endl; + return 1; + } + for (auto &c : method) c = (char)std::toupper((unsigned char)c); + apiRoutes.push_back({method, path}); + } + try { jetc::Parser parser(src); auto nodes = parser.parseNodes(); jetc::Codegen codegen; - // Symbol is derived from the input filename (not the output path) so - // it stays stable and predictable for whatever builds and dlsym()'s - // the resulting .so - unique per template, so multiple compiled - // templates' .so's can coexist in one process even under + // Both symbols are derived from the input filename (not the output + // path) so they stay stable and predictable for whatever builds and + // dlsym()'s the resulting .so - unique per template, so multiple + // compiled templates' .so's can coexist in one process even under // dlopen(..., RTLD_GLOBAL) instead of requiring isolated handles. - std::string symbol = "jetc_render_" + sanitizeIdent(baseNameNoExt(inputPath)); - std::string cpp = codegen.generate(nodes, std::string("compiled from ") + inputPath, shared, symbol); + std::string baseIdent = sanitizeIdent(baseNameNoExt(inputPath)); + std::string symbol = "jetc_render_" + baseIdent; + std::string apiSymbol = "jetc_api_" + baseIdent; + if (!apiRoutes.empty() && !shared) + std::cerr << "jetc: warning: #api directive(s) present but --shared not specified - " + "route metadata will not be emitted" << std::endl; + std::string cpp = codegen.generate(nodes, std::string("compiled from ") + inputPath, shared, + symbol, apiRoutes, apiSymbol); std::ofstream outFile(outputPath); outFile << cpp; outFile.close(); - if (shared) - std::cout << "jetc: wrote " << outputPath << " (entry symbol: " << symbol << ")" << std::endl; - else + if (shared) { + std::cout << "jetc: wrote " << outputPath << " (entry symbol: " << symbol << ")"; + if (!apiRoutes.empty()) + std::cout << " (api symbol: " << apiSymbol << ", " << apiRoutes.size() << " route(s))"; + std::cout << std::endl; + } else { std::cout << "jetc: wrote " << outputPath << std::endl; + } } catch (std::exception &e) { std::cerr << "jetc: " << e.what() << std::endl; return 1;