IDE Integration
This guide explains how to configure your IDE to work seamlessly with Turnkey's automatic dependency synchronization.
Overview
Turnkey can automatically update rules.star files when you modify source code imports. While this happens automatically when running tk build, you can also configure your IDE to trigger sync on file save for immediate feedback.
VS Code
Run on Save Extension
Install the Run on Save extension, then add to your workspace .vscode/settings.json:
{
"emeraldwalk.runonsave": {
"commands": [
{
"match": "\\.(go|rs|py|ts|tsx|sol)$",
"cmd": "tk rules sync --quiet ${fileDirname}"
}
]
}
}
This runs tk rules sync on the directory containing the modified file whenever you save a source file.
Task-based Approach
Alternatively, create a VS Code task in .vscode/tasks.json:
{
"version": "2.0.0",
"tasks": [
{
"label": "Sync rules.star",
"type": "shell",
"command": "tk rules sync",
"presentation": {
"reveal": "silent",
"panel": "shared"
},
"problemMatcher": []
}
]
}
Then bind it to a keyboard shortcut in keybindings.json:
{
"key": "ctrl+shift+s",
"command": "workbench.action.tasks.runTask",
"args": "Sync rules.star"
}
JetBrains IDEs (IntelliJ, GoLand, PyCharm, etc.)
File Watchers
-
Go to Settings > Tools > File Watchers
-
Click + to add a new watcher
-
Configure:
- Name:
Turnkey Rules Sync - File type:
Go files(or your language) - Scope:
Project Files - Program:
tk - Arguments:
rules sync --quiet $FileDir$ - Output paths to refresh:
$FileDir$/rules.star - Working directory:
$ProjectFileDir$
- Name:
-
Under Advanced Options:
- Check: "Trigger the watcher on external changes"
- Uncheck: "Auto-save edited files to trigger the watcher"
External Tools
Alternatively, set up an external tool:
-
Go to Settings > Tools > External Tools
-
Click + to add:
- Name:
Sync rules.star - Program:
tk - Arguments:
rules sync - Working directory:
$ProjectFileDir$
- Name:
-
Assign a keyboard shortcut in Keymap settings
Neovim
Add to your Neovim configuration:
-- Auto-run tk rules sync on save for supported file types
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = { "*.go", "*.rs", "*.py", "*.ts", "*.tsx", "*.sol" },
callback = function()
local file_dir = vim.fn.expand("%:p:h")
vim.fn.jobstart({ "tk", "rules", "sync", "--quiet", file_dir }, {
on_exit = function(_, code)
if code ~= 0 then
vim.notify("tk rules sync failed", vim.log.levels.WARN)
end
end,
})
end,
})
Emacs
Add to your Emacs configuration:
(defun turnkey-sync-rules ()
"Run tk rules sync on the current file's directory."
(when (and buffer-file-name
(string-match-p "\\.\\(go\\|rs\\|py\\|ts\\|tsx\\|sol\\)$" buffer-file-name))
(let ((default-directory (file-name-directory buffer-file-name)))
(start-process "tk-rules-sync" nil "tk" "rules" "sync" "--quiet" "."))))
(add-hook 'after-save-hook #'turnkey-sync-rules)
Configuration Options
Module Options
Rules sync is configured through turnkey's Buck2 options in your
flake.nix, which generate the [rules] section of .turnkey/sync.toml
(a generated file: don't edit it):
turnkey.toolchains.buck2.rules = {
enabled = true; # Enable rules.star sync (default: false)
autoSync = true; # Auto-sync before tk build (default: true)
strict = false; # Fail if rules would change - for CI (default: false)
};
Sync finds each language's internal targets from its own manifest
(go.mod, Cargo.toml, the uv workspace) and uses turnkey's deps cells
(godeps, rustdeps, pydeps, jsdeps, soldeps), through the deps
file each is built from (the language's depsFile). Both reach sync
through the [[languages]] of .turnkey/sync.toml. The platforms it
resolves deps for come from buck2.platforms (see
Platform-Conditional Deps).
Command Line Options
tk rules sync # Sync only stale files (git-based detection)
tk rules sync --force # Force sync all files
tk rules sync --verbose # Show detailed output
tk rules sync --dry-run # Show what would change without writing
tk rules check # Check every rules.star file (exit 1 if any is stale)
Staleness Detection
tk rules sync and the sync tk runs before buck2 commands skip work that can't have changed:
- Git-based: only directories with uncommitted source file changes are considered
- Mtime-based: within those, a
rules.starnewer than every source file next to it is skipped
--force (or --all) turns both off. This means tk rules sync is nearly instant in most cases, making it suitable for on-save hooks.
tk rules check uses neither: it always checks every rules.star. Once a stale rules.star is committed, git reports no change for it and nothing makes its sources newer, so a filtered check would pass it forever.
Preservation Markers
If you have manual dependencies that shouldn't be auto-managed, use preservation markers:
go_binary(
name = "my-app",
srcs = ["main.go"],
deps = [
# turnkey:auto-start
"godeps//vendor/github.com/google/uuid:uuid",
# turnkey:auto-end
# turnkey:preserve-start
# Manual override for special case
"//special:dep",
# turnkey:preserve-end
],
)
Dependencies between preserve-start and preserve-end markers are never modified by sync.
Opting a Target Out
To keep sync away from one target entirely, for example to work around a
problem, put a # turnkey:no-sync comment on its own line right before the
rule:
# Links a hand-built native library sync knows nothing about
# turnkey:no-sync
rust_library(
name = "my-lib-native",
deps = _COMMON_DEPS + ["//third-party/native:lib"],
)
Sync never changes an opted-out target. tk rules sync -v and
tk rules check -v list them as OPTED OUT:.
Platform-Conditional Deps
Some deps are only needed on some platforms. Sync resolves every target's
deps on each platform the project builds for, so what it writes is the same
whichever machine runs it. The platforms are buck2.platforms, the flake's
systems by default:
turnkey.toolchains.buck2.platforms = [ "x86_64-linux" "aarch64-darwin" ];
They reach sync through the [conditions] section of .turnkey/sync.toml,
in Buck2's names:
[conditions]
settings = "toolchains//conditions"
[[conditions.platforms]]
os = "linux"
cpu = "x86_64"
Deps every platform needs are written as a plain list. The others are
written as a select() after it:
rust_library(
name = "my-lib",
deps = [
# turnkey:auto-start
"rustdeps//vendor/libc:libc",
# turnkey:auto-end
] + select({
"config//os:linux": ["rustdeps//vendor/fuser:fuser"],
"config//os:macos": [],
}),
)
- A key is the smallest one that says exactly where the deps apply:
config//os:<os>when they differ only by OS (orconfig//cpu:<cpu>by CPU alone), otherwise one of the toolchains cell'sconfig_settings combining both,toolchains//conditions:<os>-<cpu>, one per platform. Go build tags are dimensions too (see Go below), combined the same way. - Every platform gets a branch, empty if it needs nothing more, and there is
no
DEFAULT: building for a platform that isn't listed fails instead of silently missing deps. - Sync reads this form back and owns all of it. The
turnkey:autoandturnkey:preservemarkers apply to the plain list only. A change to one branch rewrites only that branch. - A target whose deps don't depend on the platform keeps a plain list.
A select() sync can't read (its keys aren't config//os:*,
config//cpu:*, toolchains//conditions:* or DEFAULT, or its values
aren't lists of labels) makes the target unreadable, like any other
expression.
Where Deps Come From
- Rust: the crate's
Cargo.toml, not its sources.[dependencies]go to library and binary targets; test targets get[dependencies]plus[dev-dependencies].workspace = trueentries are resolved against the rootCargo.toml, a workspace member maps to its own target, and any other crate maps torustdeps//vendor/<package>:<package>(a renamed dependency maps to itspackage). An existing label that pins a version (rustdeps//vendor/tokio@1.50.0:tokio) satisfies the unversioned one. A target-specific table ([target.'cfg(...)'.dependencies], or a target triple) applies on the platforms its spec holds on, so its deps are platform-conditional: a dep every platform gets is a plain dep, one no platform gets is dropped.cfg()supportstarget_os,target_family(unix),target_arch,target_pointer_width,target_env,target_vendor,target_endianandall/any/not. Build dependencies are not synced: sync reports them and leaves any existing dep on them alone. See Rust Features for features, optional dependencies and dependencies on a member's variant. - Go: the imports
go listreports, on every platform: it runs once per platform withGOOS,GOARCHandCGO_ENABLED=1set, so a_linux.gofile's imports become aconfig//os:linuxbranch whichever machine runs sync. A binary or a test is built with itsbuild_tags, taken literally (none is a plaingo build). A library gets its tags from the configuration, through the prelude's transition: each tag inbuck2.go.allowedBuildTagsthat its build constraints use is an on/off dimension, and imports that depend on one become aselect()onprelude//go/tags/constraints:<tag>[set]/[unset], or on a toolchains cell setting combining it with the platform (toolchains//conditions:linux-<tag>,...-no_<tag>). Test targets get_test.goimports, external test packages' (XTestImports) included. - Python: the imports found in the sources. An import of a package that a
uv workspace member provides (
turnkey.cfg,from turnkey import cfg) maps to that member's target. The packages come from the members listed in the rootpyproject.toml's[tool.uv.workspace]and their source layout, so a downstream namespace such asacme.*works the same way. Any other import maps topydeps. - TypeScript: the npm packages imported by the sources, written to the
target's
npm_depsas the jsdeps cell'sjsdeps//:<npm name>aliases (jsdeps//:@types/lodash), plus each one's@types/...package when it is a direct dependency too. Only the rootpackage.json's dependencies (js-deps.toml's[direct]) map: an import of anything else is unmapped. The target'sdeps(other TypeScript targets) are not synced. - Other languages: the imports found in the sources, mapped to targets.
Sync never removes a dep it can't account for:
- If a target has imports sync can't map to a target (reported as
unmapped import), its deps are incomplete, so sync adds what it resolved but removes nothing, and prints aKEPT:line naming the deps it kept and why. - A target whose
depsis an expression (_DEPS,_COMMON_DEPS + [...]) rather than a list of labels is not synced. Unless# turnkey:no-syncopts it out,tk rules syncandtk rules checkreport it asUNREADABLE:, and it makestk rules checkand strict mode fail: write its deps as a list of labels, or opt it out. (The sync before a build doesn't report it.) - Deps are only added or removed, never reordered.
Rust Features
A Rust target builds what Cargo would, and sync keeps it that way: it
writes the target's features (the literal list the prelude passes to
rustc, one --cfg feature="..." each) as well as its deps.
-
A primary target, one that sets neither of the attributes below, builds what
cargo build -p <crate>builds: the crate'sdefaultfeatures, expanded. An optional dependency is a dep only when an enabled feature activates it (dep:x, an implicit feature,x/feat). -
A variant asks for features in Cargo's terms, on two attributes of turnkey's prelude Rust rules that rustc never sees:
rust_library( name = "composition-full", crate = "composition", cargo_features = ["watcher"] + select({ "config//os:linux": ["fuse"], "config//os:macos": ["fuse-t"], }), # default_features = False, # as Cargo's default-features )Sync expands the request as Cargo would for a dependency asking for those features (
dep:x,x/feat, weakx?/feat, feature-to-feature, anddefaultunlessdefault_features = False), and writes thefeaturesanddepsit gives, per platform when the request is aselect(). -
featuresis literal:defaultis written only when listed, and Buck2 adds nothing implicit. -
A dependency on a workspace member that asks for features (its own
features = [...]with the[workspace.dependencies]entry's, those its crate's features forward to it, and the member's defaults unlessdefault-features = false) maps to the member'srust_librarywhose request enables exactly the same features, per platform. If none or several do, the dependency is reported as an unmapped import naming the features it needs, and nothing is removed.
A crate that doesn't build under Buck2 is a bug to fix in the rustdeps cell, not a reason to make a target a variant.
rust-analyzer sees a select()'d features through the host's branch.
Troubleshooting
Sync not running
- Ensure
deps-extractis in your PATH (built withcargo install --path src/rust/deps-extract) - Check that
[rules] enabled = truein.turnkey/sync.toml - Verify the file type is supported (Go, Rust, Python, TypeScript, Solidity)
Sync too slow
- Use the default staleness detection (don't use
--forcein on-save hooks) - Target a specific directory:
tk rules sync src/cmd/myapp
Wrong dependencies detected
- Check your
*-deps.tomlfiles are up to date (runtk sync) - Verify internal prefix configuration in sync.toml
- Run
tk rules sync --verboseto see what's being detected