Native Modules (Author Guide)
How to create third-party native modules (e.g. tish-polars, tish-egui) that extend Tish with Rust.
This document specifies the formal requirements for third-party native modules that extend Tish with Rust-based capabilities.
Overview
Third-party modules integrate with Tish via:
- Interpreter:
TishNativeModuletrait; globals registered at startup (notish:/cargo:specifiers in the same way as compile). - Compiled
tish:(npm native module):import { Egui } from 'tish:egui'— resolves a package undernode_modules(or workspace layout) whosepackage.jsonhastish.module,tish.crate,tish.export. The compiler adds that crate as a Cargo dependency and calls its export function (or a generated per-export wrapper whentish.generateNativeWrapperis true). - Compiled
cargo:(Cargo-only dependency):import { foo } from 'cargo:my_crate'— no npm package formy_crate. You declare the crate in project rootpackage.json→tish.rustDependenciesusing the same key as aftercargo:.tish buildmerges those entries into the ephemeralCargo.toml, then emits a generated native wrapper that maps each imported name tomy_crate::{snake_case}(args: &[Value]) -> Valueon that dependency crate. The crate must implement thosepub fnsymbols (typically pre-generated withtishlang-cargo-bindgenfrom the Tish workspace, bindgen-style). See Cargo-backed imports below.
Optional: set tish.generateNativeWrapper to true to use the same generated-wrapper style for tish: npm modules (per-export Rust symbols on the adapter crate instead of a single tish.export object).
Cargo-backed imports (cargo:)
Use this when your Rust code lives in a normal Cargo crate (workspace member or path dependency) and you do not want to publish an npm adapter package.
Requirements
tish build --target native --native-backend rustonly. Not supported fortish run, the bytecode VM,tish build --target js, or Cranelift/LLVM native targets (those paths reject external native imports).- Project root
package.jsonmust include atishobject withrustDependencies: an object whose keys are exactly the identifiers used aftercargo:(with-allowed in the key; the Rust crate name typically uses_). - Each value is either a version string (published on crates.io) or a JSON object that becomes a Cargo inline dependency table (e.g.
{ "path": "./crates/my-shim" }). Relativepathvalues are resolved from the project root (the directory containingpackage.json), not from the ephemeral build dir.
Tish source (example: pre-generated JSON helpers)
import { to_string, from_str } from 'cargo:tish_serde_json'
console.log(to_string({ hello: "world" }))package.json fragment
{
"tish": {
"rustDependencies": {
"tish_serde_json": { "path": "./crates/tish_serde_json" }
}
}
}The key must match cargo:…. Use a path to a small crate produced by tishlang-cargo-bindgen (workspace crate tishlang_cargo_bindgen, binary tishlang-cargo-bindgen), or hand-maintain the same pub fn contract. You can also use a crates.io version string when a published crate already exposes the Value ABI.
Pre-generating bindings (Phase 1)
The tool resolves the upstream Cargo package with cargo metadata, walks its src/**/*.rs, and classifies each requested pub fn by signature shape (via syn) — not by a hard-coded crate list. Supported shapes today include:
pub fn …(args: &[Value]) -> Value— thin forward to the dependency.pub fn …<T: Serialize + ?Sized>(v: &T) -> Result<String, _>— TishValueis converted viaserde_json::Value, then the realto_string-style API is called.pub fn …<'a, T: Deserialize<'a>>(s: &'a str) -> Result<T, _>— parse asserde_json::Value, then map into TishValue.
From the Tish language repo (typical: no --dependency — the tool infers the glue path from tish.rustDependencies, then the upstream from the glue Cargo.toml if it exists, otherwise from the project root Cargo.toml, using registry semver entries in [dependencies] / [dev-dependencies] and skipping tishlang_core / path-only deps):
cargo run -p tishlang_cargo_bindgen -- \
--project-root /path/to/your-app \
--exports to_string,from_strWith multiple path-based rustDependencies, add --crate-name matching the key (same as after cargo:). Bootstrap without a glue Cargo.toml: put the upstream crate in the app Cargo.toml (e.g. serde_json = "1" under [dev-dependencies]), or pass --dependency serde_json and --dependency-version 1.0 without --out-dir. Fully manual: --dependency, --out-dir, and optional --crate-name.
Optional --manifest-path resolves the upstream from an existing workspace instead of a temporary probe crate.
Prefer --tishlang-runtime-path (or TISHLANG_RUNTIME_PATH) so the generated glue crate uses the same tishlang_runtime / tishlang_core as tish build (which resolves the runtime from your project + checkout). --tishlang-runtime-version writes a crates.io tishlang_runtime line; that can compile but then tish build fails with two tishlang_core / Value types until the compiler also links the registry runtime. The tish-cargo-example npm run gen:bindings script uses the path. Phase 2 (planned): tish build may run this generator for you automatically.
Rust contract on the generated crate
After generation, tish build expects each Tish import name to exist as pub fn {snake}(args: &[Value]) -> Value on the generated crate (the one listed in rustDependencies). That crate calls into the real dependency (serde_json, etc.) using the patterns above.
For crates that only expose the Tish ABI directly, you can skip bindgen and implement those pub fn symbols by hand.
Working example
See tish-cargo-example (pre-generated crates/tish_serde_json) and in-repo tests crates/tish/tests/cargo_example_compile.rs (path shim fixture).
1. Trait Contract
TishNativeModule (Interpreter)
All native modules must implement TishNativeModule from tish_eval:
pub trait TishNativeModule: Send + Sync {
fn name(&self) -> &'static str;
fn register(&self) -> HashMap<Arc<str>, Value>;
}name()— Module identifier (e.g."Polars").register()— ReturnsHashMap<global_name, Value>of globals to inject.- Must be
Send + Syncfor thread safety.
Opaque Types
For values that wrap Rust types (e.g. DataFrames):
- Implement
tish_core::TishOpaquefor method dispatch. - Expose via
Value::Opaque(Arc::new(your_type)). - Methods are invoked via
get_method; returnValueor callNativeFncallbacks.
Native Functions
- Use
Value::Native(fn_ptr)orEvalValue::Native(fn_ptr)for callbacks. - Native functions receive
Valuearguments and returnValueorResult<Value, String>.
2. Version Compatibility
- Minimum tish version: Document in module's README or
peerDependencies. - Rust edition:
2021. - Cargo resolver:
"2"when using workspace. - MSRV: Match Tish's minimum supported Rust version.
3. Dependency Contract
Required Crates (Interpreter Path)
tish_core— Core types (Value,TishOpaque,NativeFn).tish_eval—TishNativeModule,Evaluator,Value.tish_parser— If parsing Tish source (optional).
Feature Alignment
Third-party tish_eval features must match what the module uses:
http— Forfetch, timers,serve.fs— ForreadFile,writeFile, etc.process— Forprocess.env, etc.regex— ForRegExp, etc.
Compiled Path
For compiled output support:
- Implement an export function (e.g.
egui_object()) that returnstish_core::Value(a module object built withtish_core::tish_module!or manually). - Add
package.jsonwithtish.module: true,tish.crate,tish.export(the Rust function name). - Tish resolves
tish:eguietc. via package lookup, generatesCargo.tomlwith the module crate as a dependency, and emits calls liketish_egui::egui_object(). - No changes to
tish_runtimeortish_compile— modules are fully external.
4. Package Layout (Standalone)
Cargo Layout
- Package name:
tish-<domain>(e.g.tish-polars). - Feature naming: Match tish's feature name (e.g.
polars). - Path assumption: Standalone modules may use
path = "../tish/crates/..."; document layout in README.
npm Package Layout
tish-polars/
├── package.json # name, version, tish.module, tish.feature, tish.crate
├── Cargo.toml
├── src/
│ └── lib.rs
package.json (tish module):
{
"name": "tish-polars",
"version": "0.1.0",
"tish": {
"module": true,
"crate": "tish-polars",
"export": "polars_object"
}
}tish.module: true— Identifies this package as a tish native module.tish.crate— Cargo crate name (used for the generated Cargo dependency).tish.export— Rust function name that returns the moduleValue(e.g.polars_object). Default:{module}_object(e.g.egui→egui_object).
5. Security and Stability
- Transitive deps: Avoid pulling heavy or unstable dependencies without justification.
- Pinning: Recommend caret or exact pins for tish crates in third-party
Cargo.toml. - No dynamic loading: Extensions are statically linked; no
.soplugins at runtime.
6. Registry and Resolution
- npm: Tish native modules are published to npm as regular packages.
- Convention:
tish-*prefix ortish.module: trueidentifies native modules. - Resolution: Use
dependenciesinpackage.json;npm installpopulatesnode_modules. tish tooling mapstish-*deps to Cargo features.