Read this before touching the codebase. It captures the architecture, the moving parts, the invariants that aren't immediately obvious from the file tree, and the exact commands needed to build, test, package, and debug the extension end-to-end.
A Foundry-native Solidity IDE distributed as a single VS Code / Cursor extension. It combines a TypeScript LSP server (17 providers) with a thin extension client (commands, webviews, test explorer, status bar).
Positioning — we are deliberately not a Hardhat/Truffle competitor.
Every feature assumes forge, cast, anvil, and chisel exist on
PATH. When in doubt, design for the Foundry path and leave Hardhat to
Nomic Foundation's extension.
- License: MIT, author: Chris Cashwell
- GitHub:
ccashwell/solidity-workbench - Marketplace publisher:
ccashwell - Engines: VS Code
^1.85.0, Node>=18
solidity-workbench/
├── packages/
│ ├── common/ Shared types, custom LSP messages, LCOV parser
│ ├── server/ LSP server — run via Node over stdio
│ └── extension/ VS Code client — bundles server.js into the VSIX
├── scripts/
│ ├── build-icon.mjs SVG → PNG for the marketplace icon
│ └── prepare-vsix-files.mjs Copy README/LICENSE/CHANGELOG into packages/extension/ before vsce
├── test/fixtures/ Sample Foundry project for E2E tests
├── ARCHITECTURE.md Design rationale (long-form)
├── PRODUCTION_GAPS.md Severity-tagged backlog (P1/P2 cleared; P3 remaining)
├── CHANGELOG.md Keep-a-Changelog format; all recent work under [Unreleased]
└── README.md User-facing docs; shipped in the VSIX
Workspace manager: pnpm (pnpm@8.15.0). TypeScript project references
wire the packages together — do not use raw tsc without -b.
VS Code extension host ──┐
├─ commands/ │ forge, cast, anvil, chisel, script, deploy,
│ │ indexer scaffold (subgraph / Ponder / Envio)
├─ views/ │ status bar, gas profiler, coverage overlays,
│ │ storage layout webview, chisel, foundry.toml IntelliSense
├─ test-explorer/ │ VS Code Test API integration (listTests LSP request)
├─ analysis/ │ Slither bridge
└─ LanguageClient ──── stdio ──── dist/server.js (bundled from packages/server)
LSP server (packages/server)
├─ server.ts initialize handler, capability advertisement, dispatch
├─ providers/* 17 providers — one class per LSP capability
├─ analyzer/ SymbolIndex (cross-file symbols) + ReferenceIndex (inverted)
├─ parser/ @solidity-parser/parser wrapper + mapped SoliditySourceUnit
├─ compiler/ SolcBridge — type-resolved AST from `forge build --json`
├─ workspace/ WorkspaceManager — multi-root, foundry.toml, remappings, forge spawner
└─ utils/ LineIndex (UTF-8-safe byte↔Position), text helpers
Shared (packages/common)
├─ types.ts SoliditySourceUnit, ContractDefinition, NatspecComment, SolSymbol, …
├─ protocol.ts Custom LSP messages + semantic token legend
├─ foundry-config.ts foundry.toml schema
└─ lcov.ts LCOV parser (used by the coverage view client-side)
Two complementary ASTs drive every provider:
| AST | Source | When | Used by |
|---|---|---|---|
Parser AST (SolidityParser) |
@solidity-parser/parser, tolerant |
Every keystroke | Everything — completion, diagnostics (fast tier), semantic tokens, symbols, linter, hover fallback |
Solc AST (SolcBridge) |
forge build --json on save |
After first successful build | Overload disambiguation, receiver-typed member resolution, scope-aware rename for locals, canonical selectors |
A provider that needs type info checks SolcBridge first, then falls back
to parser-only logic — never require solc; the extension must be useful
before the first build completes.
All commands run from the repo root.
pnpm install # Sets up the pnpm workspace
pnpm build # common → server → extension (esbuild bundle)
pnpm watch # Parallel rebuild on change
pnpm test # Runs the server's node --test suite
pnpm lint # ESLint over packages/*/src/**/*.ts
pnpm format:check # Prettier check (use `pnpm format` to fix)
pnpm install # Installs Husky; pre-commit runs format:check
pnpm package # Produces packages/extension/solidity-workbench-*.vsix
pnpm --filter solidity-workbench test:e2e # VS Code E2E via @vscode/test-electronpnpm package runs three steps in order:
pnpm build:icon— rasterizespackages/extension/resources/icon.svg→icon.png. CI checks the PNG for drift; if you edit the SVG, commit the regenerated PNG.node scripts/prepare-vsix-files.mjs— copiesREADME.md,LICENSE,CHANGELOG.mdfrom the repo root intopackages/extension/. Those copies are gitignored (see.gitignore) — the authoritative files live at the repo root. The VSIX needs them present or the Marketplace listing tabs render blank.vsce package --no-dependencies— produces the VSIX from the extension package.
CI verifies the VSIX's contents (required: dist/extension.js, dist/server.js,
icon.png, README.md, LICENSE, CHANGELOG.md; forbidden: anything under
dist/test/**). Edit .github/workflows/ci.yml and publish.yml together when
changing the VSIX shape.
- Open the repo in VS Code
- Press
F5— launches an Extension Development Host with the extension loaded - Open
test/fixtures/sample-project/(or any Foundry repo) in the host pnpm watchfor live rebuilds;Ctrl+R/Cmd+Rin the host to reload
Each provider exports one class named <Feature>Provider with provide*
methods that match the LSP capability names. Providers are instantiated in
server.ts and wired to their dependencies via the constructor or a
setSolcBridge() setter (for the five providers that need type info).
When adding a new provider:
- Create
packages/server/src/providers/<feature>.ts - Instantiate + wire it in
server.ts - Add the capability to the
InitializeResult.capabilitiesobject - Register the LSP handler near the bottom of
server.ts - Write tests in
packages/server/src/__tests__/<feature>.test.ts
Every InitializeResult.capabilities entry must have a matching
connection.on<X> handler, and vice versa. A mismatch causes "unexpected
type" or silent failure on the client — a whole class of bugs we've hit
more than once (implementationProvider was advertised without a handler;
compileOnSave was declared in ServerSettings but never read).
When a provider needs to resolve Receiver.member (hover, inlay hints,
definition, code actions), always consult the receiver through
SymbolIndex before doing a global name lookup. The naïve pattern:
const symbols = this.symbolIndex.findSymbols(memberName); // WRONG for dottedwill silently pick a same-named method from an unrelated type. Correct pattern:
const chain = this.symbolIndex.getInheritanceChain(receiverName);
for (const c of chain) {
const hit = c.functions.find((f) => f.name === memberName);
if (hit) return hit;
}
return null; // prefer no result over a wrong resultUDVT builtins (Currency.wrap / Currency.unwrap) need special handling:
check for kind === "userDefinedValueType" on the receiver and either
synthesise a hover or return empty (inlay hints).
Every user setting must appear in three places:
packages/extension/package.json→contributes.configuration.propertiespackages/server/src/server.ts→ServerSettingsinterface (if the server reads it)packages/extension/src/config.ts→ typed accessor (if the extension reads it)
Settings that are declared but never read are worse than missing ones — they promise behavior that doesn't exist. CI doesn't catch this; be careful.
Commands the user can invoke from the palette must be in
contributes.commands in package.json. Internal shim commands (e.g.
solidity-workbench.findReferencesAt) that are only invoked
programmatically from code lenses can be registered via
vscode.commands.registerCommand without a manifest entry — they won't
appear in the palette.
LSP transports URIs as strings. VS Code commands like
editor.action.findReferences expect real vscode.Uri instances. When a
code lens needs to invoke such a command, emit a thin client-side shim
that parses the string into vscode.Uri before calling through. The
findReferencesAt shim in packages/extension/src/extension.ts is the
canonical example.
No Mocha/Jest/Vitest for unit tests — just node --test against compiled
.js files in dist/__tests__/. Tests must run through a full tsc -b
cycle before they execute; pnpm test handles that automatically.
Mock workspace helper pattern:
function makeFakeWorkspace() {
return {
getAllFileUris: () => [],
uriToPath: (uri: string) => URI.parse(uri).fsPath,
} as unknown as WorkspaceManager;
}Every provider test file uses this exact shape — copy it, don't reinvent.
SemanticTokensProvider is the one provider that drives off the raw
@solidity-parser/parser AST instead of the mapped SoliditySourceUnit.
This is because the raw AST carries precise loc info on every sub-node
(including .identifier.loc on variable declarations and .typeName.loc
on type references), and the mapped AST's nameRange heuristic is too
coarse for per-identifier tokenization. If you add tokenization for a new
construct, follow the same raw-AST pattern.
Commands that need out/**/*.json (subgraph/Ponder/Envio scaffolds, ABI
pickers, anything consuming forge build output) must run a targeted
forge build <file> when the artifact is missing, not fail with "no
artifact". The scaffold pipeline in packages/extension/src/commands/
(subgraph.ts → indexer-shared.ts) is the canonical example.
Note: forge build takes positional path arguments, not
--match-path (that's a forge test flag). Shelling out with the wrong
flag silently builds the whole project — check the output channel if a
build feels slower than it should.
subgraph, ponder, and envio generators live as peer modules under
packages/extension/src/commands/ and share indexer-shared.ts for
event signature extraction, field-name conventions, and type mapping.
The user-facing entry point is solidity-workbench.indexer.scaffold
(prompts for backend); solidity-workbench.subgraph.scaffold is kept
as a back-compat alias. When adding a backend, extend indexer-shared.ts
rather than forking helpers.
Events show their topic0 (keccak256 of the canonical signature),
functions show the 4-byte selector, and errors must show the 4-byte
selector too. If you add a new declarable that has a selector-style
identifier, surface it in hover — silent asymmetry is a reported UX bug.
- Fast (
onDidChangeContent): parser + lint + a few regex-ish sanity checks. Must stay under ~30ms. - Full (
onDidSave):forge build --json, mapped throughLineIndex. Respectsdiagnostics.compileOnSave.
Never call forge build from the fast tier. Never run the linter on save
unconditionally — it's already in the fast tier.
Publishing is tag-gated in .github/workflows/publish.yml. The tag must
be v<X.Y.Z> and must match the version field in
packages/extension/package.json exactly. The workflow:
- Verifies tag ↔ version parity
- Builds, packages, and verifies VSIX contents
- Publishes to Open VSX via
OVSX_TOKEN(ovsx publishon the built VSIX). Legacy secret nameOVSX_PATis still accepted. Missing token or publish failure fails the job — Open VSX is the canonical distribution channel. - Attaches the VSIX to a GitHub Release
VS Code Marketplace publish is not wired in CI yet. Publisher id is
ccashwell on Open VSX.
| Pitfall | Symptom | Fix |
|---|---|---|
| Advertising an LSP capability with no handler | "unexpected type" or silent no-op on the client | Remove the advertisement or add the handler |
| Declaring a setting that no code reads | User toggles it, nothing happens | Either implement it or remove it from package.json |
| Passing an LSP-wire URI string to a VS Code command | "unexpected type" | Use a client-side shim that vscode.Uri.parses it |
Using findSymbols(name) for dotted access |
Wrong same-named method picked | Walk the receiver's inheritance chain first, return null on miss |
| Only tokenizing declaration names in semantic tokens | Struct members, params, type refs render unstyled | Walk the raw AST; use .identifier.loc and .typeName.loc |
| Tokenizing free text inside comment blocks | Comment body gets the identifier overlay | Skip CommentBlock/CommentLine ranges when walking the raw AST for tokens |
| Static elementary-type list for hover | uint8/uint128/bytes16 silently unsupported |
Recognise the family programmatically (/^uint(\d+)$/, etc.) |
Using forge build --match-path <path> |
--match-path is a forge test flag; build silently runs over the whole project |
Pass the path positionally: forge build <relative-path> |
| Scaffold/ABI command fails because artifact is missing | "no artifact found" after a clean checkout | Trigger a targeted forge build <file> from the command first, then proceed |
Showing topic0 for events and bytes4 for functions but nothing for errors |
Reported UX asymmetry | Surface the error's 4-byte selector in hover the same way |
.vscodeignore ! negations + broader exclusions |
Dead files leak into the VSIX | Prefer positive allow-lists like !dist/extension.js over !dist/**/*.js + dist/test/** |
| Forgetting to bump the icon PNG | CI fails the icon-drift check | pnpm build:icon and commit the result |
| Committing scaffold/forge output at the repo root | cache/, out/, subgraph/ land in git |
They're gitignored; regenerate locally with forge build / scaffold commands |
- When reviewing code for findings, resolve them immediately — do not stop at "here's a list of issues."
- Commit scope should match logical change boundaries, not sweep everything into one
WIPcommit. - When fixing an audit or review list, land one commit per item — do not batch unrelated fixes.
- Use the
continual-learningworkflow to persist durable facts across sessions — this document is the primary output. - Prefer conventional-commit-style messages (
fix(scope): …,feat(scope): …) with a short body explaining why. - When a feature gets a second implementation (e.g. a new indexer backend), extract the shared helpers into a peer module before adding the variant — do not fork-then-diverge.
- Split unrelated fixes in a session into separate commits; don't sweep them into one "WIP".
- When the user needs to test fixes in an installed extension, run
pnpm packagefor a VSIX —pnpm buildalone does not produce one.
- File-level Solidity declarations (structs, enums, errors, free functions, UDVTs) live on
SoliditySourceUnitoutsidecontracts; providers must consultsourceUnit.structs,freeFunctions, etc., not onlygetEnclosingContract. using forlibrary extension calls bind the receiver as the library function's first explicit parameter; inlay hints and signature help must resolve the receiver type via contractusingdirectives and omit that parameter from labeled arguments.- Webview
buildHtmlinline scripts must register DOM event listeners once at initialization — re-invoked render helpers must update DOM only, not calladdEventListeneragain (stacked listeners leak memory). - Filesystem watchers that trigger LSP round-trips (e.g. Test Explorer on
**/*.sol) should debounce rapid create/change/delete bursts (~400ms) from fmt, multi-file saves, and codegen. - Comments inside webview HTML template literals must not contain backticks; block comments must not contain
*/substrings — both break parsing of the surrounding string or comment. - Hover and signature help must resolve NatSpec through
resolveEffectiveNatspec()inpackages/server/src/utils/natspec.ts— symbols with only@inheritdocotherwise show the tag label, not the parent's@notice/@param/@return. - Struct and enum members are indexed as
structMember/enumMember; dotted access on struct receivers must useSymbolIndex.findContainerMember, not contract inheritance-chain walks. - GitHub Actions workflows must not set
pnpm/action-setupversionwhen rootpackage.jsonpinspackageManager— duplicate specs causeERR_PNPM_BAD_PM_VERSION.
| Need to… | Look at |
|---|---|
| Add a new LSP provider | packages/server/src/providers/*.ts + server.ts wiring |
| Add a Foundry command | packages/extension/src/commands/ (one file per subsurface) |
| Add an indexer backend (Graph/Ponder/Envio/…) | packages/extension/src/commands/indexer-shared.ts + a peer <backend>-scaffold.ts |
| Add a webview/tree view | packages/extension/src/views/ |
| Change the hover blurb for a built-in type | packages/server/src/providers/hover.ts → describeElementaryType / getBuiltinHover |
| Add a security linter rule | packages/server/src/providers/linter.ts |
| Add a custom LSP message | packages/common/src/protocol.ts |
| Change the VSIX shape | packages/extension/.vscodeignore, packages/extension/package.json, both CI workflows |
| Change the semantic-tokens legend | packages/common/src/protocol.ts → SolSemanticTokenTypes / …Modifiers |
| Understand the rationale for a design choice | ARCHITECTURE.md (long-form) and PRODUCTION_GAPS.md (what's intentionally deferred) |