Skip to Content

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='[...]' # optional

Exit 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 0

Write errors to stderr and exit non-zero:

fmt.Fprintf(os.Stderr, "my-plugin: %s: invalid config\n", tsconfig) return 2

Unrecovered 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

Last updated on