Recipes
Short patterns for common plugin work. Copy what fits; ignore the rest. For the canonical bootstrap call into driver.LoadProgram see Getting started; for the curated Go-side surface see Reference β Driver API.
Read Options from --plugins-json
type PluginEntry struct {
Config map[string]any `json:"config"`
Name string `json:"name"`
Stage string `json:"stage"`
}
func parsePlugins(text string) ([]PluginEntry, error) {
var entries []PluginEntry
if err := json.Unmarshal([]byte(text), &entries); err != nil {
return nil, err
}
return entries, nil
}The Config map carries plugin-owned options (mode, calls, text, β¦). For the wire contract β verbatim passthrough, deterministic check-first ordering, what to ignore β see Plugin protocol β --plugins-json.
Auto-discovery
The rule and its three invariants (explicit wins; name must match dependency string; only directly-listed packages) live in Concepts β Auto-discovery. Two patterns to copy:
// Explicit β for fixtures, tests, unambiguous wiring.
{ "compilerOptions": { "plugins": [{ "transform": "my-ttsc-plugin" }] } }// Auto-discovery β in your plugin's package.json.
{ "ttsc": { "plugin": { "transform": "my-ttsc-plugin" } } }Typed Config
For structured options, marshal the config map back into a typed struct:
type Config struct {
Text string `json:"text"`
Calls []string `json:"calls"`
}
func decodeConfig(raw map[string]any) (Config, error) {
var cfg Config
bytes, _ := json.Marshal(raw)
err := json.Unmarshal(bytes, &cfg)
return cfg, err
}Validate after decoding. Error loudly on wrong types.
Multiple Modes
One binary can support several modes:
func runModes(value string, plugins []PluginEntry) (string, error) {
var (
prefix *PluginEntry
uppercase bool
suffix *PluginEntry
)
for _, plugin := range plugins {
mode := stringOption(plugin.Config, "mode")
switch mode {
case "prefix":
entry := plugin
prefix = &entry
case "uppercase":
uppercase = true
case "suffix":
entry := plugin
suffix = &entry
default:
return "", fmt.Errorf("unsupported mode %q", mode)
}
}
if prefix != nil {
value = stringOption(prefix.Config, "prefix") + value
}
if uppercase {
value = strings.ToUpper(value)
}
if suffix != nil {
value += stringOption(suffix.Config, "suffix")
}
return value, nil
}
func stringOption(config map[string]any, key string) string {
value, _ := config[key].(string)
return value
}When modes need to cooperate inside one transform emit pass, keep those modes in one native binary and dispatch by explicit mode values. Check plugins are independent diagnostics passes.
Transform Plugin
Declare a transform plugin descriptor:
module.exports = {
name: "my-transform-plugin",
source: path.resolve(__dirname, "plugin"),
stage: "transform",
};Accept the transform-stage command set:
my-plugin check --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'
my-plugin transform --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'
my-plugin build --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'check is a no-op for pure transforms. transform writes the transformed TypeScript JSON used by in-memory callers and bundler adapters; render each *SourceFile through shimprinter.EmitSourceFile so the AST mutation surfaces in the output (the host treats the value as opaque text). build loads the project, mutates TypeScript source AST, then lets TypeScript-Go print JavaScript, declarations, and source maps through prog.EmitAllRaw(nil).
Check Plugin
Use stage: "check" for diagnostics before emit:
module.exports = {
name: "my-check-plugin",
source: path.resolve(__dirname, "plugin"),
stage: "check",
};Implement:
my-plugin check --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'
my-plugin fix --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]' # optional
my-plugin format --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]' # optionalExit non-zero when error-severity diagnostics exist. If you do not implement fix or format, exit non-zero with a stderr message β the host treats every non-zero exit as failure (there is no specific phrase it recognizes).
Pretty diagnostics with driver.NewLintDiagnostic
For check-stage plugins, the canonical 3-line lint-style diagnostic flow:
import (
"os"
"github.com/samchon/ttsc/packages/ttsc/driver"
)
func runCheck(opts options) int {
prog, _, err := driver.LoadProgram(opts.cwd, opts.tsconfig, driver.LoadProgramOptions{})
if err != nil {
return 2
}
defer prog.Close()
var diags []driver.Diagnostic
for _, file := range prog.SourceFiles() {
// ...walk the file, identify the offending node, then:
diags = append(diags, driver.NewLintDiagnostic(
file, node.Pos(), node.End(),
/*code*/ 9001, driver.SeverityError,
"[my-plugin/no-debugger] debugger statements are forbidden",
))
}
driver.WritePrettyDiagnostics(os.Stderr, diags, opts.cwd)
if driver.CountErrors(diags) > 0 {
return 1
}
return 0
}See Driver API β Diagnostics for the symbol reference. For token-aligned ranges (skip leading trivia), use shimscanner.SkipTrivia(file.Text(), node.Pos()) β see AST & Checker β Text Ranges.
Mutating leaf-text nodes
For structural mutations (filtering Statements.Nodes, swapping children), shimprinter.EmitSourceFile round-trips cleanly without further work.
For leaf-text mutations (identifier, string-literal, or numeric-literal .Text), the printer reads original source text through getTextOfNode whenever the node is non-synthesized and has a parent. A naive lit.AsStringLiteral().Text = "x" silently vanishes: the printer reads the old span via scanner.GetSourceTextOfNodeFromSourceFile and ignores your .Text.
The fix is two lines that detach the node from its parse-tree source span:
// Source: packages/paths/driver/paths.go::pathsRewriter (the @ttsc/paths leaf-text path)
lit.AsStringLiteral().Text = rewritten
lit.Flags |= shimast.NodeFlagsSynthesized
lit.Loc = shimcore.UndefinedTextRange()Structural mutations do not need this β the printer iterates Statements.Nodes directly without reading source spans, which is why @ttsc/stripβs statement filter (packages/strip/driver/strip.go::stripRewriter) round-trips without the synthesize stamp.
See AST & Checker β Mutating the AST for the underlying mechanism.
Re-spawn ttsx or tsgo from a plugin
ttsc injects three env vars into every plugin process so plugins can re-enter the toolchain without resolving paths themselves: TTSC_NODE_BINARY, TTSC_TSGO_BINARY, TTSC_TTSX_BINARY. See Architecture β Injected into the plugin process for the full reference.
Use case: a fix-stage plugin that re-runs typecheck after its own rewrite, or a generator that wants to ttsx a .ts config file:
import (
"os"
"os/exec"
)
func reTypecheck(cwd string) error {
tsgo := os.Getenv("TTSC_TSGO_BINARY")
if tsgo == "" {
return fmt.Errorf("TTSC_TSGO_BINARY not set (running outside ttsc?)")
}
cmd := exec.Command(tsgo, "--noEmit")
cmd.Dir = cwd
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}Always guard for empty values β if a third-party harness spawns the plugin binary directly (without going through ttsc), the var will be missing.
Text Edits
Collect edits as offsets, then apply in reverse order so earlier edits do not shift later offsets:
sort.SliceStable(edits, func(i, j int) bool {
return edits[i].start > edits[j].start
})Warnings
Write warnings to stderr and exit 0:
fmt.Fprintf(os.Stderr, "my-plugin: warning: ignored unknown option %q\n", key)
return 0Write errors to stderr and exit non-zero:
fmt.Fprintf(os.Stderr, "my-plugin: %s: invalid config\n", tsconfig)
return 2Unrecovered Go panics surface as exit-2 with the stack on stderr; the host does not catch or symbolize them. Wrap rule bodies in recover() if you need graceful per-rule reporting β see packages/lint/linthost/engine.go.
Source Maps
Prefer AST transforms and TypeScript-Go printing so source maps stay owned by the compiler. The public plugin contract does not provide generated JavaScript text as a source-map-bearing edit target.
Watch Mode
ttsc --watch starts a fresh plugin process for each invocation. The source build cache avoids rebuilding the Go binary after the first run, but every rebuild still pays process startup and backend initialization. Keep state in files under outDir if needed; do not rely on process globals.
Next
β Testing