├── src ├── zmk ├── global.d.ts ├── markdown-escape.d.ts ├── file.ts ├── test │ └── extension.test.ts ├── extension.ts ├── keymap.ts ├── webpack-zmk-hardware-plugin │ └── index.js ├── util.ts ├── mouse.ts ├── config.ts ├── behaviors.schema.json ├── keycodes.ts ├── build.ts ├── hardware.ts ├── behaviors.ts ├── SetupWizard.ts ├── Parser.ts ├── KeymapAnalyzer.ts └── behaviors.yaml ├── .prettierignore ├── .gitignore ├── images └── zmk_logo.png ├── .gitmodules ├── .vscode-test.mjs ├── .prettierrc.json ├── .vscode ├── extensions.json ├── settings.default.json ├── settings.json ├── tasks.json └── launch.json ├── .vscodeignore ├── .editorconfig ├── eslint.config.mjs ├── templates └── build.yaml ├── language-configuration.json ├── tsconfig.json ├── LICENSE.md ├── CHANGELOG.md ├── package.json ├── webpack.config.js ├── syntaxes └── dts.tmLanguage.json └── README.md /src/zmk: -------------------------------------------------------------------------------- 1 | ../zmk/docs/src -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | src/zmk-data -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | .vscode-test-web/ 6 | *.vsix 7 | -------------------------------------------------------------------------------- /images/zmk_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joelspadin/zmk-tools/HEAD/images/zmk_logo.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "zmk"] 2 | path = zmk 3 | url = https://github.com/zmkfirmware/zmk.git 4 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.yaml' { 2 | const data: unknown; 3 | export = data; 4 | } 5 | -------------------------------------------------------------------------------- /.vscode-test.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@vscode/test-cli'; 2 | 3 | export default defineConfig({ 4 | files: 'out/test/**/*.test.js', 5 | }); 6 | -------------------------------------------------------------------------------- /src/markdown-escape.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'markdown-escape' { 2 | function escape(markdown: string, skips?: string[]): string; 3 | export = escape; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "overrides": [ 6 | { 7 | "files": "*.json", 8 | "options": { 9 | "tabWidth": 2 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /src/file.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export async function fetchResource(context: vscode.ExtensionContext, ...pathSegments: string[]): Promise { 4 | const uri = vscode.Uri.joinPath(context.extensionUri, ...pathSegments); 5 | return await vscode.workspace.fs.readFile(uri); 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "editorconfig.editorconfig", 7 | "esbenp.prettier-vscode", 8 | "spadin.config-defaults" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | .vscode-test-web/** 4 | dist/test/** 5 | dist/web/test/** 6 | node_modules/** 7 | src/** 8 | zmk/** 9 | .gitignore 10 | .gitmodules 11 | webpack.config.js 12 | **/tsconfig.json 13 | **/.editorconfig 14 | **/.prettierignore 15 | **/.prettierrc.json 16 | **/.vscode-test.* 17 | **/eslint.config.mjs 18 | **/*.map 19 | **/*.ts -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [*.{json,yaml}] 10 | indent_size = 2 11 | 12 | # Turn off all settings for dependencies and output files. 13 | [**/{node_modules,out,dist}/**/*] 14 | charset = unset 15 | trim_trailing_whitespace = unset 16 | indent_style = unset 17 | indent_size = unset 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import js from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config({ 7 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 8 | 9 | files: ['**/*.ts'], 10 | 11 | rules: { 12 | '@typescript-eslint/no-unused-vars': [ 13 | 'error', 14 | { 15 | args: 'none', 16 | }, 17 | ], 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /templates/build.yaml: -------------------------------------------------------------------------------- 1 | # This file generates the GitHub Actions matrix 2 | # For simple board + shield combinations, add them 3 | # to the top level board and shield arrays, for more 4 | # control, add individual board + shield combinations to 5 | # the `include` property, e.g: 6 | # 7 | # board: [ "nice_nano_v2" ] 8 | # shield: [ "corne_left", "corne_right" ] 9 | # include: 10 | # - board: bdn9_rev2 11 | # - board: nice_nano_v2 12 | # shield: reviung41 13 | # 14 | --- 15 | -------------------------------------------------------------------------------- /language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "//", 4 | "blockComment": ["/*", "*/"] 5 | }, 6 | "brackets": [ 7 | ["{", "}"], 8 | ["[", "]"], 9 | ["(", ")"] 10 | ], 11 | "autoClosingPairs": [ 12 | ["{", "}"], 13 | ["[", "]"], 14 | ["(", ")"], 15 | ["\"", "\""], 16 | ["<", ">"] 17 | ], 18 | "surroundingPairs": [ 19 | ["{", "}"], 20 | ["[", "]"], 21 | ["(", ")"], 22 | ["\"", "\""], 23 | ["'", "'"] 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/test/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | import * as vscode from 'vscode'; 6 | // import * as myExtension from '../../extension'; 7 | 8 | suite('Extension Test Suite', () => { 9 | vscode.window.showInformationMessage('Start all tests.'); 10 | 11 | test('Sample test', () => { 12 | assert.equal(-1, [1, 2, 3].indexOf(5)); 13 | assert.equal(-1, [1, 2, 3].indexOf(0)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { KeymapAnalyzer } from './KeymapAnalyzer'; 3 | import { KeymapParser } from './Parser'; 4 | import { SetupWizard } from './SetupWizard'; 5 | 6 | export async function activate(context: vscode.ExtensionContext) { 7 | const parser = await KeymapParser.init(context); 8 | const analyzer = new KeymapAnalyzer(parser); 9 | 10 | const setupWizard = new SetupWizard(context); 11 | 12 | context.subscriptions.push(parser, analyzer, setupWizard); 13 | } 14 | 15 | export function deactivate() {} 16 | -------------------------------------------------------------------------------- /.vscode/settings.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": false // set this to true to hide the "out" folder with the compiled JS files 4 | }, 5 | "search.exclude": { 6 | "out": true // set this to false to include "out" folder in search results 7 | }, 8 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 9 | "typescript.tsc.autoDetect": "off", 10 | "editor.formatOnSave": true, 11 | "[typescript]": { 12 | "editor.codeActionsOnSave": { 13 | "source.organizeImports": true 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": false // set this to true to hide the "out" folder with the compiled JS files 4 | }, 5 | "search.exclude": { 6 | "out": true // set this to false to include "out" folder in search results 7 | }, 8 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 9 | "typescript.tsc.autoDetect": "off", 10 | "editor.formatOnSave": true, 11 | "[typescript]": { 12 | "editor.codeActionsOnSave": { 13 | "source.organizeImports": "explicit" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2022", 5 | "outDir": "dist", 6 | "lib": ["ES2022", "webWorker"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true /* enable all strict type-checking options */, 10 | /* Additional Checks */ 11 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 12 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 13 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 14 | "forceConsistentCasingInFileNames": true, 15 | "rootDirs": ["src", "typings"], 16 | "allowJs": true 17 | }, 18 | "include": ["src/*", "src/test", "src/zmk/data/*"] 19 | } 20 | -------------------------------------------------------------------------------- /src/keymap.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export const SELECTOR: vscode.DocumentSelector = { 4 | pattern: '**/*.keymap', 5 | }; 6 | 7 | export function isKeymap(document: vscode.TextDocument) { 8 | return document.uri.path.endsWith('.keymap'); 9 | } 10 | 11 | export interface IncludeInfo { 12 | /** existing includes */ 13 | paths: string[]; 14 | /** location where a new include can be inserted */ 15 | insertPosition: vscode.Position; 16 | } 17 | 18 | export function addMissingSystemInclude(includeInfo: IncludeInfo, path: string): vscode.TextEdit[] { 19 | if (includeInfo.paths.includes(path)) { 20 | return []; 21 | } 22 | 23 | return [getSystemIncludeTextEdit(includeInfo.insertPosition, path)]; 24 | } 25 | 26 | function getSystemIncludeTextEdit(position: vscode.Position, path: string): vscode.TextEdit { 27 | return vscode.TextEdit.insert(position, `#include <${path}>\n`); 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$ts-webpack-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never", 13 | "group": "watchers" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | } 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "watch-tests", 23 | "problemMatcher": "$tsc-watch", 24 | "isBackground": true, 25 | "presentation": { 26 | "reveal": "never", 27 | "group": "watchers" 28 | }, 29 | "group": "build" 30 | }, 31 | { 32 | "label": "tasks: watch-tests", 33 | "dependsOn": ["npm: watch", "npm: watch-tests"], 34 | "problemMatcher": [] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joel Spadin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/webpack-zmk-hardware-plugin/index.js: -------------------------------------------------------------------------------- 1 | const CopyPlugin = require('copy-webpack-plugin'); 2 | const path = require('path'); 3 | const yaml = require('yaml'); 4 | 5 | const ZMK_ROOT = 'zmk'; 6 | 7 | /** 8 | * Combines all *.zmk.yml files into an array and writes them to a new YAML file. 9 | */ 10 | class ZmkHardwarePlugin extends CopyPlugin { 11 | /** 12 | * @param {string} dest Output path 13 | */ 14 | constructor(dest) { 15 | super({ 16 | patterns: [ 17 | { 18 | from: 'zmk/app/boards/**/*.zmk.yml', 19 | to: dest, 20 | transformAll(assets) { 21 | const hardware = assets.reduce((result, asset) => { 22 | const item = yaml.parse(asset.data.toString()); 23 | 24 | item.directory = path.posix.relative(ZMK_ROOT, path.posix.dirname(asset.sourceFilename)); 25 | 26 | result.push(item); 27 | return result; 28 | }, []); 29 | 30 | return JSON.stringify(hardware); 31 | }, 32 | }, 33 | ], 34 | }); 35 | } 36 | } 37 | 38 | module.exports = ZmkHardwarePlugin; 39 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export function decode(input: BufferSource): string { 4 | return new TextDecoder().decode(input); 5 | } 6 | 7 | export function encode(input: string): Uint8Array { 8 | return new TextEncoder().encode(input); 9 | } 10 | 11 | /** 12 | * Removes all text after the first instance of whitespace in the given string. 13 | */ 14 | export function truncateAtWhitespace(text: string): string { 15 | return text.replace(/\s.+$/s, ''); 16 | } 17 | 18 | export function stripQuotes(text: string): string { 19 | if (text.startsWith('"') && text.endsWith('"')) { 20 | return text.substring(1, text.length - 1); 21 | } 22 | 23 | return text; 24 | } 25 | 26 | export function stripIncludeQuotes(text: string): string { 27 | if (text.startsWith('<') && text.endsWith('>')) { 28 | return text.substring(1, text.length - 1); 29 | } 30 | 31 | return stripQuotes(text); 32 | } 33 | 34 | export function dirname(uri: vscode.Uri): vscode.Uri { 35 | const sepIndex = uri.path.lastIndexOf('/'); 36 | if (sepIndex < 0) { 37 | return uri; 38 | } 39 | 40 | const dirname = uri.path.slice(0, sepIndex); 41 | return uri.with({ path: dirname }); 42 | } 43 | 44 | export function camelCaseToWords(str: string) { 45 | return str.replace(/([a-z])([A-Z])/, (_, end: string, start: string) => end + ' ' + start.toLowerCase()); 46 | } 47 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 14 | "preLaunchTask": "npm: watch" 15 | }, 16 | { 17 | "name": "Run Web Extension", 18 | "type": "extensionHost", 19 | "debugWebWorkerHost": true, 20 | "request": "launch", 21 | "args": ["--extensionDevelopmentPath=${workspaceFolder}", "--extensionDevelopmentKind=web"], 22 | "outFiles": ["${workspaceFolder}/dist/web/**/*.js"], 23 | "preLaunchTask": "npm: watch" 24 | }, 25 | { 26 | "name": "Extension Tests", 27 | "type": "extensionHost", 28 | "request": "launch", 29 | "args": [ 30 | "--extensionDevelopmentPath=${workspaceFolder}", 31 | "--extensionTestsPath=${workspaceFolder}/dist/test/suite/index" 32 | ], 33 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 34 | "preLaunchTask": "npm: watch" 35 | }, 36 | { 37 | "name": "Extension Web Tests", 38 | "type": "extensionHost", 39 | "debugWebWorkerHost": true, 40 | "request": "launch", 41 | "args": [ 42 | "--extensionDevelopmentPath=${workspaceFolder}", 43 | "--extensionDevelopmentKind=web", 44 | "--extensionTestsPath=${workspaceFolder}/dist/web//test/suite/index" 45 | ], 46 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 47 | "preLaunchTask": "npm: watch" 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/mouse.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { IncludeInfo, addMissingSystemInclude } from './keymap'; 3 | 4 | const MOUSE_INCLUDE = 'dt-bindings/zmk/mouse.h'; 5 | 6 | // TODO: parse this from dt-bindings/zmk/mouse.h? 7 | const mouseButtonCompletions: vscode.CompletionItem[] = [ 8 | { 9 | label: 'LCLK', 10 | kind: vscode.CompletionItemKind.EnumMember, 11 | documentation: 'Left click', 12 | }, 13 | { 14 | label: 'RCLK', 15 | kind: vscode.CompletionItemKind.EnumMember, 16 | documentation: 'Right click', 17 | }, 18 | { 19 | label: 'MCLK', 20 | kind: vscode.CompletionItemKind.EnumMember, 21 | documentation: 'Middle click', 22 | }, 23 | { 24 | label: 'MB1', 25 | kind: vscode.CompletionItemKind.EnumMember, 26 | documentation: 'Mouse button 1 (left click)', 27 | }, 28 | { 29 | label: 'MB2', 30 | kind: vscode.CompletionItemKind.EnumMember, 31 | documentation: 'Mouse button 2 (right click)', 32 | }, 33 | { 34 | label: 'MB3', 35 | kind: vscode.CompletionItemKind.EnumMember, 36 | documentation: 'Mouse button 3 (middle click)', 37 | }, 38 | { 39 | label: 'MB4', 40 | kind: vscode.CompletionItemKind.EnumMember, 41 | documentation: 'Mouse button 4', 42 | }, 43 | { 44 | label: 'MB5', 45 | kind: vscode.CompletionItemKind.EnumMember, 46 | documentation: 'Mouse button 5', 47 | }, 48 | ]; 49 | 50 | export function getMouseButtonCompletions(includeInfo: IncludeInfo): vscode.CompletionItem[] { 51 | const additionalTextEdits = addMissingSystemInclude(includeInfo, MOUSE_INCLUDE); 52 | if (additionalTextEdits.length > 0) { 53 | return mouseButtonCompletions.map((item) => { 54 | return { ...item, additionalTextEdits }; 55 | }); 56 | } 57 | 58 | return mouseButtonCompletions; 59 | } 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.5.0 4 | 5 | - Added code completion for `&soft_off` and `&studio_unlock`. 6 | 7 | ## 1.4.0 8 | 9 | - Fixed an issue with parsing an empty build.yaml file. 10 | - Added code completion for `&mkp` and `&bt BT_DISC`. 11 | - Added support for reading `board_root` from `zephyr/module.yml`. 12 | 13 | ## 1.3.0 14 | 15 | - Updated ZMK key codes. 16 | - Changed how the keymap parser is loaded to hopefully fix the web extension. 17 | - Changed `*.keymap` files to identify as `dts` instead of `zmk-keymap` for better compatibility with extensions such as [nRF DeviceTree](https://marketplace.visualstudio.com/items?itemName=nordic-semiconductor.nrf-devicetree). 18 | 19 | ## 1.2.0 20 | 21 | - Disabled syntax error checking, since it gave too many false positives and the parser can't yet provide useful messages for real errors. 22 | - Updated ZMK behaviors. 23 | - Added support for macro-specific behaviors in macro bindings. 24 | 25 | ## 1.1.0 26 | 27 | - **ZMK: Add Keyboard** now grabs the latest version of the hardware list from 28 | [zmk.dev/hardware-metadata.json](https://zmk.dev/hardware-metadata.json) 29 | so the extension no longer needs to be updated to support new keyboards. 30 | 31 | ## 1.0.3 32 | 33 | - Updated ZMK keyboards. 34 | 35 | ## 1.0.2 36 | 37 | - Various bug fixes. 38 | 39 | ## 1.0.0 40 | 41 | - Added the **ZMK: Add Keyboard** command. 42 | 43 | ## 0.5.0 44 | 45 | - Added support for running as a web extension. 46 | - Added code completions for `&caps_word`, `&key_repeat`, and `&bl`. 47 | 48 | ## 0.4.0 49 | 50 | - Added code completion for `&to`, `&gresc`, `&sk`, and `&sl`. 51 | 52 | ## 0.3.0 53 | 54 | - Keymap syntax highlighting now works without the DeviceTree extension. 55 | - .overlay files are now automatically associated with the DeviceTree extension. 56 | - Updated Tree-sitter. Appears to fix "extension host terminated unexpectedly" errors. 57 | - Updated ZMK keycode support data. 58 | 59 | ## 0.2.0 60 | 61 | - Keymap code completion now automatically inserts missing includes. 62 | - Adjusted keymap code completion so it doesn't trigger on space when you're just adjusting alignment. 63 | - Space no longer triggers completion of behaviors. Use tab or enter instead. 64 | 65 | ## 0.1.0 66 | 67 | - Initial release 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zmk-tools", 3 | "displayName": "ZMK Tools", 4 | "description": "Tools for working with ZMK Firmware", 5 | "version": "1.5.0", 6 | "publisher": "spadin", 7 | "license": "MIT", 8 | "author": { 9 | "name": "Joel Spadin" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/joelspadin/zmk-tools.git" 14 | }, 15 | "icon": "images/zmk_logo.png", 16 | "engines": { 17 | "vscode": "^1.94.0" 18 | }, 19 | "categories": [ 20 | "Other" 21 | ], 22 | "activationEvents": [], 23 | "main": "./dist/extension.js", 24 | "browser": "./dist/web/extension.js", 25 | "contributes": { 26 | "languages": [ 27 | { 28 | "id": "dts", 29 | "aliases": [ 30 | "Devicetree" 31 | ], 32 | "extensions": [ 33 | ".keymap", 34 | ".overlay", 35 | ".dtsi", 36 | ".dts" 37 | ], 38 | "configuration": "./language-configuration.json" 39 | } 40 | ], 41 | "grammars": [ 42 | { 43 | "language": "dts", 44 | "scopeName": "source.dts", 45 | "path": "./syntaxes/dts.tmLanguage.json" 46 | } 47 | ], 48 | "configuration": { 49 | "title": "ZMK Tools", 50 | "properties": { 51 | "zmk.configPath": { 52 | "type": "string", 53 | "description": "Workspace-relative path to the folder containing ZMK config files", 54 | "scope": "resource" 55 | }, 56 | "zmk.buildMatrixPath": { 57 | "type": "string", 58 | "description": "Workspace-relative path to the build matrix file", 59 | "scope": "resource" 60 | } 61 | } 62 | }, 63 | "commands": [ 64 | { 65 | "command": "zmk.addKeyboard", 66 | "category": "ZMK", 67 | "title": "Add Keyboard" 68 | } 69 | ] 70 | }, 71 | "scripts": { 72 | "vscode:prepublish": "npm run package", 73 | "compile": "webpack", 74 | "watch": "webpack --watch", 75 | "package": "webpack --mode production --devtool hidden-source-map", 76 | "compile-tests": "tsc -p . --outDir out", 77 | "watch-tests": "tsc -p . -w --outDir out", 78 | "pretest": "npm run compile-tests && npm run compile && npm run lint", 79 | "lint": "eslint src", 80 | "test": "vscode-test", 81 | "run-in-browser": "vscode-test-web --browserType=chromium --extensionDevelopmentPath=. zmk" 82 | }, 83 | "devDependencies": { 84 | "@eslint/js": "^9.13.0", 85 | "@types/mocha": "^10.0.9", 86 | "@types/vscode": "^1.94.0", 87 | "@types/webpack-env": "^1.18.5", 88 | "@vscode/test-cli": "^0.0.10", 89 | "@vscode/test-electron": "^2.4.1", 90 | "@vscode/test-web": "^0.0.62", 91 | "assert": "^2.1.0", 92 | "copy-webpack-plugin": "^12.0.2", 93 | "eslint": "^9.13.0", 94 | "mocha": "^10.7.3", 95 | "prettier": "^3.3.3", 96 | "process": "^0.11.10", 97 | "tree-sitter-cli": "^0.24.3", 98 | "tree-sitter-devicetree": "^0.12.1", 99 | "ts-loader": "^9.5.1", 100 | "typescript": "^5.6.3", 101 | "typescript-eslint": "^8.10.0", 102 | "webpack": "^5.95.0", 103 | "webpack-cli": "^5.1.4", 104 | "yaml-loader": "^0.8.1" 105 | }, 106 | "dependencies": { 107 | "markdown-escape": "^2.0.0", 108 | "web-tree-sitter": "^0.24.3", 109 | "yaml": "^2.6.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as yaml from 'yaml'; 3 | import { decode, dirname } from './util'; 4 | 5 | export class ConfigMissingError extends Error {} 6 | 7 | export interface ConfigLocation { 8 | workspace: vscode.WorkspaceFolder; 9 | config: vscode.Uri; 10 | boardRoot: vscode.Uri; 11 | } 12 | 13 | interface ZephyrModuleFile { 14 | build?: { 15 | settings?: { 16 | board_root?: string; 17 | }; 18 | }; 19 | } 20 | 21 | /** 22 | * If there is only one ZMK config in the workspace, returns it. 23 | * 24 | * If there are multiple, prompts the user to select one and returns it or 25 | * undefined if the user cancels selection. 26 | * 27 | * @throws ConfigMissingError if there are no ZMK configs in the workspace. 28 | */ 29 | export async function getConfigLocation(): Promise { 30 | const allConfigs = await findAllConfigs(); 31 | if (allConfigs.length === 0) { 32 | throw new ConfigMissingError(); 33 | } 34 | if (allConfigs.length === 1) { 35 | return allConfigs[0]; 36 | } 37 | 38 | const items = getConfigPickItems(allConfigs); 39 | const result = await vscode.window.showQuickPick(items, { 40 | title: 'Add to which workspace?', 41 | placeHolder: 'Pick ZMK config workspace', 42 | ignoreFocusOut: true, 43 | }); 44 | 45 | return result?.location; 46 | } 47 | 48 | async function findAllConfigs(): Promise { 49 | const configs = await Promise.all(vscode.workspace.workspaceFolders?.map(locateConfigInWorkspace) ?? []); 50 | return configs.filter((x) => x !== undefined) as ConfigLocation[]; 51 | } 52 | 53 | async function locateBoardRoot(workspace: vscode.WorkspaceFolder): Promise { 54 | try { 55 | const uri = vscode.Uri.joinPath(workspace.uri, 'zephyr/module.yml'); 56 | const file = decode(await vscode.workspace.fs.readFile(uri)); 57 | 58 | const module = yaml.parse(file) as ZephyrModuleFile; 59 | const boardRoot = module?.build?.settings?.board_root; 60 | 61 | if (boardRoot) { 62 | return vscode.Uri.joinPath(workspace.uri, boardRoot); 63 | } 64 | } catch (e) { 65 | if (e instanceof vscode.FileSystemError) { 66 | return undefined; 67 | } 68 | 69 | throw e; 70 | } 71 | 72 | return undefined; 73 | } 74 | 75 | async function locateConfigInWorkspace(workspace: vscode.WorkspaceFolder): Promise { 76 | const boardRoot = await locateBoardRoot(workspace); 77 | 78 | const settings = vscode.workspace.getConfiguration('zmk', workspace); 79 | const path = settings.get('configPath'); 80 | 81 | if (path) { 82 | const config = vscode.Uri.joinPath(workspace.uri, path); 83 | return { workspace, config, boardRoot: boardRoot ?? config }; 84 | } 85 | 86 | const glob = await vscode.workspace.findFiles(new vscode.RelativePattern(workspace, '**/west.yml')); 87 | 88 | if (glob.length === 0) { 89 | return undefined; 90 | } 91 | 92 | const config = dirname(glob[0]); 93 | 94 | return { 95 | workspace, 96 | config, 97 | boardRoot: boardRoot ?? config, 98 | }; 99 | } 100 | 101 | interface ConfigPickItem extends vscode.QuickPickItem { 102 | location: ConfigLocation; 103 | } 104 | 105 | async function getConfigPickItems(configs: ConfigLocation[]) { 106 | return configs.map((location) => { 107 | return { 108 | label: location.workspace.name, 109 | description: location.config.path.substring(location.workspace.uri.path.length), 110 | location, 111 | }; 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /src/behaviors.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft-07/schema", 3 | "$id": "https://github.com/joelspadin/zmk-tools/blob/main/src/behaviors.schema.json", 4 | "title": "ZMK Tools Behaviors", 5 | "type": "object", 6 | "properties": { 7 | "behaviors": { 8 | "$ref": "#/definitions/behaviorProperties" 9 | }, 10 | "macroBehaviors": { 11 | "$ref": "#/definitions/behaviorProperties" 12 | } 13 | }, 14 | "definitions": { 15 | "behaviorProperties": { 16 | "description": "Map of DT property names to lists of behavior info", 17 | "type": "object", 18 | "additionalProperties": { 19 | "type": "array", 20 | "items": { 21 | "$ref": "#/definitions/behavior" 22 | } 23 | }, 24 | "parameters": { 25 | "type": "object", 26 | "additionalProperties": { 27 | "$ref": "#/definition/parameter" 28 | } 29 | } 30 | }, 31 | "behavior": { 32 | "description": "Information for one behavior binding", 33 | "type": "object", 34 | "properties": { 35 | "label": { 36 | "description": "Behavior label reference followed by any parameter placeholders, e.g. \"&foo FOO BAR\"", 37 | "type": "string", 38 | "pattern": "&\\w+(\\s[_A-Z]+)*" 39 | }, 40 | "documentation": { "type": "string" }, 41 | "parameters": { 42 | "description": "Behavior binding parameters", 43 | "type": "array", 44 | "items": { "$ref": "#/definitions/parameter" } 45 | }, 46 | "if": { 47 | "anyOf": [ 48 | { "$ref": "#/definitions/behavior_filter" }, 49 | { 50 | "type": "array", 51 | "items": { "$ref": "#/definitions/behavior_filter" } 52 | } 53 | ] 54 | } 55 | }, 56 | "required": ["label", "parameters"] 57 | }, 58 | "parameter": { 59 | "description": "Behavior binding parameter", 60 | "type": "object", 61 | "properties": { 62 | "label": { 63 | "description": "Placeholder text (should match parameter in behavior label)", 64 | "type": "string" 65 | }, 66 | "documentation": { "type": "string" }, 67 | "include": { 68 | "description": "Path to a header file that defines the values for the parameter", 69 | "type": "string" 70 | }, 71 | "type": { "$ref": "#/definitions/parameter_type" } 72 | }, 73 | "required": ["label"] 74 | }, 75 | "parameter_type": { 76 | "oneOf": [ 77 | { 78 | "type": "string", 79 | "enum": ["keycode", "modifier", "integer", "mouseButton"] 80 | }, 81 | { 82 | "type": "array", 83 | "items": { "$ref": "#/definitions/parameter_value" } 84 | } 85 | ] 86 | }, 87 | "parameter_value": { 88 | "type": "object", 89 | "properties": { 90 | "label": { 91 | "description": "The text value to insert", 92 | "type": "string" 93 | }, 94 | "documentation": { "type": "string" } 95 | }, 96 | "required": ["label"] 97 | }, 98 | "behavior_filter": { 99 | "description": "Rules for when a behavior should be shown", 100 | "type": "object", 101 | "properties": { 102 | "params": { 103 | "description": "Show this behavior only if the parameters match this list", 104 | "type": "array", 105 | "items": { "type": "string" } 106 | }, 107 | "paramsNot": { 108 | "description": "Show this behavior only if the parameters do not match this list", 109 | "type": "array", 110 | "items": { "type": "string" } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/keycodes.ts: -------------------------------------------------------------------------------- 1 | import markdownEscape from 'markdown-escape'; 2 | import * as vscode from 'vscode'; 3 | 4 | import { addMissingSystemInclude, IncludeInfo } from './keymap'; 5 | import codes from './zmk/data/hid'; 6 | import oses from './zmk/data/operating-systems'; 7 | 8 | type KeyDefinition = (typeof codes)[number]; 9 | type OsKey = keyof KeyDefinition['os']; 10 | 11 | const KEYS_INCLUDE = 'dt-bindings/zmk/keys.h'; 12 | 13 | const keycodeCompletions: vscode.CompletionItem[] = []; 14 | const modifierCompletions: vscode.CompletionItem[] = []; 15 | 16 | /** 17 | * Gets completion items for a keycode value. 18 | */ 19 | export function getKeycodeCompletions(includeInfo: IncludeInfo): vscode.CompletionItem[] { 20 | if (keycodeCompletions.length === 0) { 21 | initKeycodeCompletions(); 22 | } 23 | 24 | const additionalTextEdits = addMissingSystemInclude(includeInfo, KEYS_INCLUDE); 25 | if (additionalTextEdits.length > 0) { 26 | return keycodeCompletions.map((item) => { 27 | return { ...item, additionalTextEdits }; 28 | }); 29 | } 30 | 31 | return keycodeCompletions; 32 | } 33 | 34 | /** 35 | * Gets completion items for a modifier value. 36 | */ 37 | export function getModifierCompletions(includeInfo: IncludeInfo): vscode.CompletionItem[] { 38 | if (modifierCompletions.length === 0) { 39 | initModifierCompletions(); 40 | } 41 | 42 | const additionalTextEdits = addMissingSystemInclude(includeInfo, KEYS_INCLUDE); 43 | if (additionalTextEdits.length > 0) { 44 | return modifierCompletions.map((item) => { 45 | return { ...item, additionalTextEdits }; 46 | }); 47 | } 48 | 49 | return modifierCompletions; 50 | } 51 | 52 | function initKeycodeCompletions() { 53 | for (const definition of codes) { 54 | addKeyDefinition(definition); 55 | } 56 | } 57 | 58 | function addKeyDefinition(def: KeyDefinition) { 59 | const documentation = getKeyDocumentation(def); 60 | 61 | for (const name of def.names) { 62 | const completion: vscode.CompletionItem = { 63 | label: name, 64 | detail: def.context, 65 | kind: vscode.CompletionItemKind.EnumMember, 66 | documentation, 67 | }; 68 | 69 | if (isMacro(name)) { 70 | // Don't insert the "(code)" part of a macro. 71 | // TODO: can this be implemented so that committing with tab/enter 72 | // also inserts the parenthesis? 73 | completion.insertText = name.split('(')[0]; 74 | completion.commitCharacters = ['(']; 75 | } 76 | 77 | keycodeCompletions.push(completion); 78 | } 79 | } 80 | 81 | function initModifierCompletions() { 82 | for (const definition of codes) { 83 | addModifierDefinition(definition); 84 | } 85 | } 86 | 87 | function addModifierDefinition(def: KeyDefinition) { 88 | // Keys with a macro as a possible name are modifiers. 89 | if (!def.names.some(isMacro)) { 90 | return; 91 | } 92 | 93 | const documentation = getKeyDocumentation(def); 94 | 95 | for (const name of def.names) { 96 | // ...but the macro itself is only valid as a modifier to a keycode. 97 | if (isMacro(name)) { 98 | continue; 99 | } 100 | 101 | const completion: vscode.CompletionItem = { 102 | label: name, 103 | detail: 'Modifier', 104 | kind: vscode.CompletionItemKind.EnumMember, 105 | documentation, 106 | }; 107 | 108 | modifierCompletions.push(completion); 109 | } 110 | } 111 | 112 | function getKeyDocumentation(def: KeyDefinition) { 113 | const support = oses.map((os) => `* ${os.title}: ${supportIcon(def.os[os.key as OsKey])}`); 114 | 115 | let aliases = ''; 116 | if (def.names.length > 1) { 117 | aliases = 'Aliases: ' + def.names.map((n) => `\`${n}\``).join(', '); 118 | } 119 | 120 | const markdown = `${markdownEscape(def.description)}\n\n${aliases}\n\n${support.join('\n')}`; 121 | 122 | return new vscode.MarkdownString(markdown, true); 123 | } 124 | 125 | function isMacro(name: string): boolean { 126 | return name.includes('('); 127 | } 128 | 129 | function supportIcon(support: boolean | null): string { 130 | if (support === true) { 131 | return '✔️'; 132 | } 133 | 134 | if (support === false) { 135 | return '❌'; 136 | } 137 | 138 | return '❔'; 139 | } 140 | -------------------------------------------------------------------------------- /src/build.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as yaml from 'yaml'; 3 | import { YAMLMap, YAMLSeq } from 'yaml'; 4 | import { ConfigLocation } from './config'; 5 | import { fetchResource } from './file'; 6 | import { decode } from './util'; 7 | 8 | export interface BuildItem { 9 | board: string; 10 | shield?: string; 11 | } 12 | 13 | export function buildItemEquals(a: BuildItem, b: BuildItem): boolean { 14 | return a.board === b.board && a.shield === b.shield; 15 | } 16 | 17 | /** 18 | * Appends the given builds to a config repo's build.yaml file. 19 | */ 20 | export async function addToBuildMatrix( 21 | context: vscode.ExtensionContext, 22 | config: ConfigLocation, 23 | builds: BuildItem[], 24 | ): Promise { 25 | const matrix = await readMatrix(context, config); 26 | 27 | const currentItems = parseInclude(matrix); 28 | 29 | if (!matrix.contents) { 30 | matrix.contents = new YAMLMap(); 31 | } 32 | 33 | if (!matrix.has('include') || !matrix.get('include')) { 34 | matrix.set('include', new YAMLSeq()); 35 | } 36 | 37 | const include = matrix.get('include') as YAMLSeq; 38 | 39 | for (const build of builds) { 40 | if (!currentItems.some((x) => buildItemEquals(x, build))) { 41 | include.add(build); 42 | } 43 | } 44 | 45 | await writeMatrix(config, matrix); 46 | } 47 | 48 | function getMatrixUri(config: ConfigLocation) { 49 | const settings = vscode.workspace.getConfiguration('zmk', config.workspace); 50 | const path = settings.get('buildMatrixPath') || 'build.yaml'; 51 | 52 | return vscode.Uri.joinPath(config.workspace.uri, path); 53 | } 54 | 55 | async function readMatrix(context: vscode.ExtensionContext, config: ConfigLocation): Promise { 56 | try { 57 | const uri = getMatrixUri(config); 58 | const file = decode(await vscode.workspace.fs.readFile(uri)); 59 | 60 | return parseDocument(file); 61 | } catch (e) { 62 | if (e instanceof vscode.FileSystemError) { 63 | return await getEmptyMatrix(context); 64 | } 65 | 66 | throw e; 67 | } 68 | } 69 | 70 | /** 71 | * Parses the build matrix's "include" field as a list of build items. 72 | */ 73 | function parseInclude(matrix: yaml.Document): BuildItem[] { 74 | const include = matrix.get('include'); 75 | 76 | if (!(include instanceof YAMLSeq)) { 77 | return []; 78 | } 79 | 80 | const items: BuildItem[] = []; 81 | for (const map of include.items) { 82 | if (!(map instanceof YAMLMap)) { 83 | continue; 84 | } 85 | 86 | const board = map.get('board'); 87 | if (typeof board !== 'string') { 88 | continue; 89 | } 90 | 91 | const item: BuildItem = { 92 | board, 93 | }; 94 | 95 | const shield = map.get('shield'); 96 | if (typeof shield === 'string') { 97 | item.shield = shield; 98 | } 99 | 100 | items.push(item); 101 | } 102 | return items; 103 | } 104 | 105 | async function writeMatrix(config: ConfigLocation, matrix: yaml.Document) { 106 | const text = stringify(matrix); 107 | const file = new TextEncoder().encode(text); 108 | 109 | const uri = getMatrixUri(config); 110 | await vscode.workspace.fs.writeFile(uri, file); 111 | } 112 | 113 | /** 114 | * Calls yaml.stringify() but preserves comments before the document. 115 | */ 116 | function stringify(matrix: yaml.Document) { 117 | let text = yaml.stringify(matrix); 118 | 119 | if (matrix.commentBefore) { 120 | const comment = matrix.commentBefore 121 | .split('\n') 122 | .map((line) => '#' + line) 123 | .join('\n'); 124 | text = comment + '\n---\n' + text; 125 | } 126 | 127 | return text; 128 | } 129 | 130 | async function getEmptyMatrix(context: vscode.ExtensionContext): Promise { 131 | const file = decode(await fetchResource(context, 'templates/build.yaml')); 132 | 133 | return parseDocument(file); 134 | } 135 | 136 | function parseDocument(source: string) { 137 | if (isEmptyDocument(source)) { 138 | // YAML parser will fail to parse an empty document. An easy fix is to 139 | // add the "include" key that we're going to add later anyways. 140 | source += '\ninclude:'; 141 | } 142 | 143 | return yaml.parseDocument(source, { strict: false }); 144 | } 145 | 146 | const HEADER_RE = /((?:^\s*(#.*)?$[\r\n]+)+)^---$/m; 147 | 148 | function isEmptyDocument(source: string) { 149 | return source.replace(HEADER_RE, '').trim().length === 0; 150 | } 151 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | 'use strict'; 7 | 8 | //@ts-check 9 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 10 | 11 | const path = require('path'); 12 | const webpack = require('webpack'); 13 | const CopyPlugin = require('copy-webpack-plugin'); 14 | const ZmkHardwarePlugin = require('./src/webpack-zmk-hardware-plugin'); 15 | 16 | /** @type {import('webpack').RuleSetRule[]} */ 17 | const rules = [ 18 | { 19 | test: /\.ts$/, 20 | exclude: /node_modules/, 21 | use: [{ loader: 'ts-loader' }], 22 | }, 23 | { 24 | test: /\.ya?ml$/, 25 | type: 'json', 26 | use: [{ loader: 'yaml-loader', options: { asJSON: true } }], 27 | }, 28 | ]; 29 | 30 | /** @type WebpackConfig */ 31 | const webExtensionConfig = { 32 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 33 | target: 'webworker', // extensions run in a webworker context 34 | entry: { 35 | extension: './src/extension.ts', 36 | }, 37 | output: { 38 | filename: '[name].js', 39 | path: path.join(__dirname, './dist/web'), 40 | libraryTarget: 'commonjs', 41 | devtoolModuleFilenameTemplate: '../../[resource-path]', 42 | }, 43 | resolve: { 44 | mainFields: ['browser', 'module', 'main'], // look for `browser` entry point in imported node modules 45 | extensions: ['.ts', '.js'], 46 | fallback: { 47 | // Webpack 5 no longer polyfills Node.js core modules automatically. 48 | // see https://webpack.js.org/configuration/resolve/#resolvefallback 49 | // for the list of Node.js core module polyfills. 50 | assert: require.resolve('assert'), 51 | // web-tree-sitter tries to import "fs", which can be ignored. 52 | // https://github.com/tree-sitter/tree-sitter/issues/466 53 | fs: false, 54 | path: false, 55 | }, 56 | }, 57 | module: { 58 | rules, 59 | }, 60 | plugins: [ 61 | new webpack.optimize.LimitChunkCountPlugin({ 62 | maxChunks: 1, // disable chunks by default since web extensions must be a single bundle 63 | }), 64 | new webpack.ProvidePlugin({ 65 | process: 'process/browser.js', // provide a shim for the global `process` variable 66 | }), 67 | ], 68 | externals: { 69 | vscode: 'commonjs vscode', // ignored because it doesn't exist 70 | }, 71 | performance: { 72 | hints: false, 73 | }, 74 | devtool: 'nosources-source-map', // create a source map that points to the original source file 75 | infrastructureLogging: { 76 | level: 'log', // enables logging required for problem matchers 77 | }, 78 | }; 79 | 80 | /** @type WebpackConfig */ 81 | const extensionConfig = { 82 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 83 | target: 'node', // vscode extensions run in a Node.js-context 84 | 85 | entry: './src/extension.ts', 86 | output: { 87 | path: path.resolve(__dirname, 'dist'), 88 | filename: 'extension.js', 89 | libraryTarget: 'commonjs2', 90 | }, 91 | externals: { 92 | vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. 93 | // Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 94 | // modules added here also need to be added in the .vscodeignore file 95 | }, 96 | resolve: { 97 | extensions: ['.ts', '.js'], 98 | }, 99 | module: { 100 | rules, 101 | }, 102 | plugins: [ 103 | // hardware.yaml only needs to be built once, so this isn't needed in both configs. 104 | new ZmkHardwarePlugin('hardware.json'), 105 | new CopyPlugin({ 106 | patterns: [ 107 | require.resolve('web-tree-sitter/tree-sitter.wasm'), 108 | require.resolve('tree-sitter-devicetree/tree-sitter-devicetree.wasm'), 109 | ], 110 | }), 111 | ], 112 | devtool: 'nosources-source-map', // create a source map that points to the original source file 113 | infrastructureLogging: { 114 | level: 'log', // enables logging required for problem matchers 115 | }, 116 | }; 117 | 118 | module.exports = [extensionConfig, webExtensionConfig]; 119 | -------------------------------------------------------------------------------- /syntaxes/dts.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", 3 | "scopeName": "source.dts", 4 | "name": "DTS", 5 | "comment": "DeviceTree Language syntax", 6 | "fileTypes": ["keymap", "overlay"], 7 | "patterns": [ 8 | { 9 | "comment": "Line comments", 10 | "begin": "//", 11 | "beginCaptures": { 12 | "0": { 13 | "name": "punctuation.definition.comment.dts" 14 | } 15 | }, 16 | "end": "$", 17 | "name": "comment.line.double-slash.dts" 18 | }, 19 | { 20 | "comment": "Include directives", 21 | "match": "(#include)(\\s+(<|\")([^>\"]*)(>|\"))", 22 | "captures": { 23 | "1": { 24 | "name": "keyword.directive.dts" 25 | }, 26 | "2": { 27 | "name": "string.quoted.double.dts" 28 | }, 29 | "3": { 30 | "name": "punctuation.definition.string.begin.dts" 31 | }, 32 | "4": { 33 | "name": "entity.name.include.dts" 34 | }, 35 | "5": { 36 | "name": "punctuation.definition.string.end.dts" 37 | } 38 | } 39 | }, 40 | { 41 | "comment": "Binary properties", 42 | "begin": "\\[", 43 | "end": "\\]", 44 | "name": "constant.numeric.dts" 45 | }, 46 | { 47 | "comment": "String literals", 48 | "begin": "\"", 49 | "beginCaptures": { 50 | "0": { 51 | "name": "punctuation.definition.string.begin.dts" 52 | } 53 | }, 54 | "end": "\"", 55 | "endCaptures": { 56 | "0": { 57 | "name": "punctuation.definition.string.end.dts" 58 | } 59 | }, 60 | "name": "string.quoted.double.dts", 61 | "patterns": [ 62 | { 63 | "include": "#strings" 64 | } 65 | ] 66 | }, 67 | { 68 | "comment": "Labels", 69 | "match": "^\\s*([0-9a-zA-Z_\\-+,.]+):", 70 | "captures": { 71 | "1": { 72 | "name": "entity.name.type.dts" 73 | } 74 | } 75 | }, 76 | { 77 | "comment": "Node names", 78 | "match": "(/|([[:alpha:][:digit:]\\-_]+)(@[0-9a-fA-F]+)?)\\s*{", 79 | "captures": { 80 | "1": { 81 | "name": "entity.name.function.dts" 82 | } 83 | } 84 | }, 85 | { 86 | "comment": "Cell properties", 87 | "begin": "<", 88 | "beginCaptures": { 89 | "0": { 90 | "name": "punctuation.definition.list.begin.dts" 91 | } 92 | }, 93 | "end": ">", 94 | "endCaptures": { 95 | "0": { 96 | "name": "punctuation.definition.list.end.dts" 97 | } 98 | }, 99 | "patterns": [ 100 | { 101 | "include": "#references" 102 | }, 103 | { 104 | "include": "#numbers" 105 | }, 106 | { 107 | "include": "#macros" 108 | }, 109 | { 110 | "include": "#blockcomments" 111 | } 112 | ] 113 | }, 114 | { 115 | "include": "#references" 116 | }, 117 | { 118 | "include": "#blockcomments" 119 | } 120 | ], 121 | "repository": { 122 | "brackets": { 123 | "patterns": [ 124 | { 125 | "match": "\\{|\\}", 126 | "name": "punctuation.other.bracket.curly.go" 127 | }, 128 | { 129 | "match": "\\(|\\)", 130 | "name": "punctuation.other.bracket.round.go" 131 | }, 132 | { 133 | "match": "\\[|\\]", 134 | "name": "punctuation.other.bracket.square.go" 135 | } 136 | ] 137 | }, 138 | "keywords": { 139 | "patterns": [ 140 | { 141 | "match": "\\b#include\\b", 142 | "name": "keyword.directive.dts" 143 | } 144 | ] 145 | }, 146 | "strings": { 147 | "name": "string.quoted.double.dts", 148 | "begin": "\"", 149 | "end": "\"", 150 | "patterns": [ 151 | { 152 | "name": "constant.character.escape.dts", 153 | "match": "\\\\." 154 | } 155 | ] 156 | }, 157 | "blockcomments": { 158 | "name": "comment.block.dts", 159 | "begin": "/\\*", 160 | "end": "\\*/", 161 | "patterns": [ 162 | { 163 | "name": "punctuation.definition.comment.dts", 164 | "match": "\\\\." 165 | } 166 | ] 167 | }, 168 | "references": { 169 | "patterns": [ 170 | { 171 | "match": "&[0-9a-zA-Z,._\\-+]+", 172 | "name": "variable.name.dts" 173 | } 174 | ] 175 | }, 176 | "numbers": { 177 | "patterns": [ 178 | { 179 | "match": "(0x[0-9a-fA-F]+|[[:digit:]]+)", 180 | "name": "constant.numeric.dts" 181 | } 182 | ] 183 | }, 184 | "macros": { 185 | "patterns": [ 186 | { 187 | "match": "[0-9a-zA-Z_]+", 188 | "name": "constant.numeric.dts" 189 | } 190 | ] 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/hardware.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as yaml from 'yaml'; 3 | import { ConfigLocation } from './config'; 4 | import { fetchResource } from './file'; 5 | import { decode, dirname } from './util'; 6 | 7 | const BASE_URL = 'https://raw.githubusercontent.com/zmkfirmware/zmk/main'; 8 | const METADATA_URL = 'https://zmk.dev/hardware-metadata.json'; 9 | 10 | export type Feature = 'keys' | 'display' | 'encoder' | 'underglow' | 'backlight' | 'pointer'; 11 | export type Output = 'usb' | 'ble'; 12 | 13 | export type Variant = 14 | | string 15 | | { 16 | id: string; 17 | features: Feature[]; 18 | }; 19 | 20 | export interface Board { 21 | type: 'board'; 22 | file_format?: string; 23 | id: string; 24 | name: string; 25 | directory: string; 26 | baseUri?: string; 27 | url?: string; 28 | arch?: string; 29 | outputs?: Output[]; 30 | description?: string; 31 | manufacturer?: string; 32 | version?: string; 33 | siblings?: string[]; 34 | features?: Feature[]; 35 | variants?: Variant[]; 36 | exposes?: string[]; 37 | } 38 | 39 | export interface Shield { 40 | type: 'shield'; 41 | file_format?: string; 42 | id: string; 43 | name: string; 44 | directory: string; 45 | baseUri?: string; 46 | url?: string; 47 | description?: string; 48 | manufacturer?: string; 49 | version?: string; 50 | siblings?: string[]; 51 | features?: Feature[]; 52 | variants?: Variant[]; 53 | exposes?: string[]; 54 | requires?: string[]; 55 | } 56 | 57 | export interface Interconnect { 58 | type: 'interconnect'; 59 | file_format?: string; 60 | id: string; 61 | name: string; 62 | directory: string; 63 | baseUri?: string; 64 | url?: string; 65 | description?: string; 66 | manufacturer?: string; 67 | version?: string; 68 | } 69 | 70 | export type Keyboard = Board | Shield; 71 | export type Hardware = Board | Shield | Interconnect; 72 | 73 | export interface GroupedHardware { 74 | keyboards: Keyboard[]; 75 | controllers: Board[]; 76 | } 77 | 78 | export interface KeyboardFiles { 79 | configUrl: vscode.Uri; 80 | keymapUrl: vscode.Uri; 81 | } 82 | 83 | export async function getHardware(context: vscode.ExtensionContext, config: ConfigLocation): Promise { 84 | const sources = await Promise.all([getZmkHardware(context), getUserHardware(config)]); 85 | 86 | const groups = sources.flat().reduce( 87 | (groups, hardware) => { 88 | if (isKeyboard(hardware)) { 89 | groups.keyboards.push(hardware); 90 | } else if (isController(hardware)) { 91 | groups.controllers.push(hardware); 92 | } 93 | 94 | return groups; 95 | }, 96 | { keyboards: [], controllers: [] }, 97 | ); 98 | 99 | sortHardware(groups.keyboards); 100 | sortHardware(groups.controllers); 101 | 102 | return groups; 103 | } 104 | 105 | function sortHardware(hardware: T[]) { 106 | hardware.sort((a, b) => a.name.localeCompare(b.name)); 107 | } 108 | 109 | export function getKeyboardFiles(keyboard: Keyboard): KeyboardFiles { 110 | return { 111 | configUrl: vscode.Uri.parse(`${keyboard.baseUri}/${keyboard.id}.conf`), 112 | keymapUrl: vscode.Uri.parse(`${keyboard.baseUri}/${keyboard.id}.keymap`), 113 | }; 114 | } 115 | 116 | export function filterToShield(boards: Board[], shield: Shield): Board[] { 117 | if (shield.requires === undefined || shield.requires.length === 0) { 118 | throw new Error(`Shield ${shield.id} is missing "requires" field.`); 119 | } 120 | 121 | return boards.filter((board) => shield.requires?.every((interconnect) => board.exposes?.includes(interconnect))); 122 | } 123 | 124 | function isKeyboard(hardware: Hardware): hardware is Keyboard { 125 | switch (hardware.type) { 126 | case 'board': 127 | return hardware.features?.includes('keys') ?? false; 128 | 129 | case 'shield': 130 | return true; 131 | } 132 | return false; 133 | } 134 | 135 | function isController(hardware: Hardware): hardware is Board { 136 | return hardware.type === 'board' && !isKeyboard(hardware); 137 | } 138 | 139 | async function getZmkHardwareMetadata(context: vscode.ExtensionContext): Promise { 140 | const response = await fetch(METADATA_URL, { credentials: 'same-origin' }); 141 | if (response.ok) { 142 | return await response.text(); 143 | } 144 | 145 | vscode.window.showInformationMessage( 146 | `Failed to fetch ${METADATA_URL} (${await response.text()}). Falling back to built-in keyboard list.`, 147 | ); 148 | 149 | return decode(await fetchResource(context, 'dist/hardware.json')); 150 | } 151 | 152 | async function getZmkHardware(context: vscode.ExtensionContext): Promise { 153 | const file = await getZmkHardwareMetadata(context); 154 | return (JSON.parse(file) as Hardware[]).map((hardware) => ({ 155 | ...hardware, 156 | baseUri: `${BASE_URL}/${hardware.directory}`, 157 | })); 158 | } 159 | 160 | async function getUserHardware(config: ConfigLocation): Promise { 161 | const meta = await vscode.workspace.findFiles(new vscode.RelativePattern(config.boardRoot, '**/*.zmk.yml')); 162 | 163 | return Promise.all( 164 | meta.map(async (uri) => { 165 | const file = decode(await vscode.workspace.fs.readFile(uri)); 166 | const hardware = yaml.parse(file) as Hardware; 167 | 168 | return { 169 | ...hardware, 170 | baseUri: dirname(uri).toString(), 171 | }; 172 | }), 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZMK Tools 2 | 3 | Visual Studio Code tools for working with [ZMK Firmware](https://zmk.dev/). 4 | 5 | It is primarily intended for editing keymaps in a ZMK user config repo, but it 6 | also works in the main ZMK repo. 7 | 8 | ## Create a ZMK User Config Repo 9 | 10 | If you do not yet have a repo: 11 | 12 | 1. Open the [ZMK config template repo](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fzmkfirmware%2Funified-zmk-config-template) on GitHub. 13 | 2. Click the **Use this template** button and follow the instructions to create your own repo. 14 | (If you don't know what to name it, `zmk-config` is a good default name.) 15 | 16 | Next, open the repo in VS Code using one of the options below: 17 | 18 | ### Edit in a Web Browser 19 | 20 | Using [github.dev](https://github.dev) you can edit your repo completely within your web browser. 21 | 22 | 1. Open a web browser to your repo in GitHub, then press the `.` (period) key to open the repo in github.dev. 23 | 2. If you have not installed this extension in github.dev before, press `Ctrl+P` and enter this command: 24 | 25 | ``` 26 | ext install spadin.zmk-tools 27 | ``` 28 | 29 | ### Edit with the GitHub Repositories Extension 30 | 31 | Using an extension, you can run VS Code locally but edit the repo remotely without cloning it. 32 | 33 | 1. Install the [GitHub Repositories](https://marketplace.visualstudio.com/items?itemName=GitHub.remotehub) extension. 34 | 2. Click the green **Open a Remote Window** button at the lower-left corner of the window. 35 | 3. Select **Open Remote Repository...** 36 | 4. Select **Open Repository from GitHub**. 37 | 5. Select your repository from the list. 38 | 6. If you have not installed this extension in a GitHub remote window before, press `Ctrl+P` and enter this command: 39 | 40 | ``` 41 | ext install spadin.zmk-tools 42 | ``` 43 | 44 | ### Clone and Edit Locally 45 | 46 | Clone the repo to your computer and open it in VS Code. 47 | 48 | If you don't know how to do this, check [Learn the Basics of Git in Under 10 Minutes](https://www.freecodecamp.org/news/learn-the-basics-of-git-in-under-10-minutes-da548267cc91/) 49 | or try one of the options above instead. 50 | 51 | ## Add a Keyboard 52 | 53 | Once the repo is open in VS Code, you can run the **Add Keyboard** command to add a keyboard: 54 | 55 | 1. Press **F1** and run the **ZMK: Add keyboard** command. 56 | 2. Follow the prompts to select a keyboard. ZMK Tools will copy the necessary files into your repo and add it to your `build.yaml` file so GitHub will build it for you. 57 | 3. Edit your `.keymap` and/or `.conf` files. See the [ZMK docs](https://zmk.dev/docs/customization) for help. 58 | 4. Select the **Source Control** tab on the side bar. 59 | 5. Hover over the header for the **Changes** list and click the **Stage All Changes** button (plus icon). 60 | 6. Enter a commit message and press `Ctrl+Enter` or click the **Commit** button (checkmark icon) to save your changes. 61 | - If you are editing the repo locally, click the **Sync Changes** button to push your changes to GitHub. 62 | 63 | Ever time you commit a change, GitHub will automatically build the firmware for you. 64 | Open a web browser to your repo in GitHub and click the **Actions** tab at the top, 65 | then click the latest build (at the top of the list). 66 | 67 | If the build succeeded, you can download the firmware from the **Artifacts** section. 68 | 69 | ### Troubleshooting 70 | 71 | If the build failed, click the failed job from the list on the left, then look for 72 | the error message. Correct the error, then repeat steps 3-6 above. 73 | 74 | The error probably looks something like `Error: nice_nano.dts.pre.tmp:760.12-13 syntax error`. 75 | If so, the following step of the build (named ` DTS File`) shows the `.dts.pre.tmp` file, 76 | which is a combination of your `.keymap` and a few other files. 77 | 78 | Match the line number from the error (760 in this example) to a line in the file, 79 | using the right-most column of line numbers. There is probably a typo on that line. 80 | Find the line in your `.keymap` and fix it. 81 | 82 | ## Keymap Features 83 | 84 | - Syntax highlighting 85 | - Code completion in `bindings` and `sensor-bindings` properties. 86 | - Very basic syntax checking (as of this writing, Tree-sitter does not provide useful error messages). 87 | 88 | ## DeviceTree Overlays 89 | 90 | - This extension marks `.overlay` files as DeviceTree files so the 91 | [DeviceTree extension](https://marketplace.visualstudio.com/items?itemName=plorefice.devicetree) 92 | will provide syntax highlighting if it is installed. 93 | 94 | ## Help and Feedback 95 | 96 | If you run into an issue or you have an idea for something to add to this extension, 97 | let me know by [creating an issue](https://github.com/joelspadin/zmk-tools/issues) 98 | or joining the ZMK Discord server (see the Discord link at the bottom of the 99 | [ZMK Firmware homepage](https://zmk.dev/).) 100 | 101 | ## Credits 102 | 103 | - Keymap parsing uses the amazing [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) library. 104 | - Some parsing code is adapted from [Tree Sitter for VSCode](https://github.com/georgewfraser/vscode-tree-sitter) 105 | (Copyright © 2016 George Fraser, [MIT license](https://github.com/georgewfraser/vscode-tree-sitter/blob/master/LICENSE.md)). 106 | - Syntax highlighting of `.keymap` files is taken from [vscode-devicetree](https://github.com/plorefice/vscode-devicetree) 107 | (Copyright © 2017 Pietro Lorefice, [MIT license](https://github.com/plorefice/vscode-devicetree/blob/master/LICENSE)). 108 | - The [ZMK logo](https://github.com/zmkfirmware/zmk/blob/main/docs/static/img/zmk_logo.svg) 109 | is part of the [ZMK documentation](https://github.com/zmkfirmware/zmk/tree/main/docs) 110 | and is licensed [CC-BY-NC-SA](http://creativecommons.org/licenses/by-nc-sa/4.0/). 111 | -------------------------------------------------------------------------------- /src/behaviors.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import Parser from 'web-tree-sitter'; 3 | import behaviorsFile from './behaviors.yaml'; 4 | 5 | import { getKeycodeCompletions, getModifierCompletions } from './keycodes'; 6 | import { IncludeInfo, addMissingSystemInclude } from './keymap'; 7 | import { getMouseButtonCompletions } from './mouse'; 8 | import { camelCaseToWords, truncateAtWhitespace } from './util'; 9 | 10 | const BEHAVIORS_INCLUDE = 'behaviors.dtsi'; 11 | const PREFERRED_BEHAVIOR = '&kp'; 12 | 13 | export interface ParameterValue { 14 | label: string; 15 | documentation?: string; 16 | } 17 | 18 | export type ParameterType = 'keycode' | 'modifier' | 'mouseButton' | 'integer' | ParameterValue[]; 19 | 20 | export type Parameter = vscode.ParameterInformation & { 21 | type?: ParameterType; 22 | include?: string; 23 | }; 24 | 25 | export interface BehaviorFilter { 26 | params?: string[]; 27 | paramsNot?: string[]; 28 | } 29 | 30 | export interface Behavior { 31 | label: string; 32 | documentation?: string; 33 | parameters: Parameter[]; 34 | if?: BehaviorFilter; 35 | } 36 | 37 | interface BehaviorsDocument { 38 | behaviors: Record; 39 | macroBehaviors: Record; 40 | } 41 | 42 | const BEHAVIORS = (behaviorsFile as BehaviorsDocument).behaviors; 43 | const MACRO_BEHAVIORS = (behaviorsFile as BehaviorsDocument).macroBehaviors; 44 | 45 | function isParamMatch(behavior: Parser.SyntaxNode, params: readonly string[]) { 46 | let node = behavior.nextNamedSibling; 47 | for (const param of params) { 48 | if (!node || node.text !== param) { 49 | return false; 50 | } 51 | 52 | node = node.nextNamedSibling; 53 | } 54 | 55 | return true; 56 | } 57 | 58 | export function getBehaviors(property: string, compatible?: string): readonly Behavior[] | undefined { 59 | if (compatible === 'zmk,behavior-macro') { 60 | const result = (BEHAVIORS[property] ?? []).concat(MACRO_BEHAVIORS[property] ?? []); 61 | return result.length > 0 ? result : undefined; 62 | } 63 | 64 | return BEHAVIORS[property]; 65 | } 66 | 67 | export function testBehavior(behavior: Parser.SyntaxNode, filter: BehaviorFilter | BehaviorFilter[]): boolean { 68 | if (Array.isArray(filter)) { 69 | return filter.every((f) => testBehavior(behavior, f)); 70 | } 71 | 72 | if (filter.params && !isParamMatch(behavior, filter.params)) { 73 | return false; 74 | } 75 | 76 | if (filter.paramsNot && isParamMatch(behavior, filter.paramsNot)) { 77 | return false; 78 | } 79 | 80 | return true; 81 | } 82 | 83 | /** 84 | * Gets a list of function signatures for behaviors. 85 | * @param behaviors A list of behaviors valid for this location. 86 | * @param activeParameter The index of the active parameter. The returned 87 | * signatures will be filtered to those where this is a valid parameter. 88 | */ 89 | export function behaviorsToSignatures( 90 | behaviors: readonly Behavior[], 91 | activeParameter?: number, 92 | ): vscode.SignatureInformation[] { 93 | let filtered: readonly Behavior[] = behaviors; 94 | 95 | if (activeParameter !== undefined) { 96 | filtered = behaviors.filter((b) => activeParameter < b.parameters.length); 97 | } 98 | 99 | return filtered.map((b) => { 100 | const sig = new vscode.SignatureInformation(b.label, new vscode.MarkdownString(b.documentation)); 101 | sig.parameters = b.parameters.map(getParameterInformation); 102 | sig.activeParameter = activeParameter; 103 | return sig; 104 | }); 105 | } 106 | 107 | /** 108 | * Gets a list of code completions for behaviors. 109 | * @param behaviors A list of behaviors valid for this location. 110 | * @param range The range to replace when a completion is committed. 111 | */ 112 | export function behaviorsToCompletions( 113 | behaviors: readonly Behavior[], 114 | includeInfo: IncludeInfo, 115 | range?: vscode.Range, 116 | ): vscode.CompletionItem[] { 117 | const additionalTextEdits = addMissingSystemInclude(includeInfo, BEHAVIORS_INCLUDE); 118 | 119 | function getEntry(b: Behavior): [string, vscode.CompletionItem] { 120 | const label = truncateAtWhitespace(b.label); 121 | const completion = new vscode.CompletionItem(label, vscode.CompletionItemKind.Function); 122 | completion.documentation = new vscode.MarkdownString(b.documentation); 123 | completion.range = range; 124 | completion.additionalTextEdits = additionalTextEdits; 125 | 126 | // TODO: remember the last-used behavior and prefer that. 127 | if (label === PREFERRED_BEHAVIOR) { 128 | completion.preselect = true; 129 | } 130 | 131 | return [label, completion]; 132 | } 133 | 134 | const dedupe = new Map(behaviors.map(getEntry)); 135 | 136 | return [...dedupe.values()]; 137 | } 138 | 139 | /** 140 | * Gets a list of code completions for the active parameter. 141 | * @param parameter The active parameter. 142 | */ 143 | export function parameterToCompletions(parameter: Parameter, includeInfo: IncludeInfo): vscode.CompletionItem[] { 144 | if (Array.isArray(parameter.type)) { 145 | const additionalTextEdits = parameter.include ? addMissingSystemInclude(includeInfo, parameter.include) : []; 146 | 147 | return parameter.type.map((v) => { 148 | const completion = new vscode.CompletionItem(v.label, vscode.CompletionItemKind.EnumMember); 149 | completion.documentation = new vscode.MarkdownString(v.documentation); 150 | completion.additionalTextEdits = additionalTextEdits; 151 | 152 | return completion; 153 | }); 154 | } 155 | 156 | switch (parameter.type) { 157 | case 'keycode': 158 | return getKeycodeCompletions(includeInfo); 159 | 160 | case 'modifier': 161 | return getModifierCompletions(includeInfo); 162 | 163 | case 'mouseButton': 164 | return getMouseButtonCompletions(includeInfo); 165 | } 166 | 167 | return []; 168 | } 169 | 170 | /** 171 | * Gets the ParameterInformation for a parameter 172 | */ 173 | function getParameterInformation(parameter: Parameter): vscode.ParameterInformation { 174 | let documentation = parameter.documentation; 175 | if (typeof documentation === 'string' && parameter.type) { 176 | const typeName = camelCaseToWords(parameter.type as string); 177 | 178 | documentation = new vscode.MarkdownString(`\`${typeName}\`: ${documentation}`); 179 | } 180 | 181 | return { label: parameter.label, documentation }; 182 | } 183 | -------------------------------------------------------------------------------- /src/SetupWizard.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { addToBuildMatrix, BuildItem } from './build'; 3 | import { ConfigLocation, ConfigMissingError, getConfigLocation } from './config'; 4 | import { 5 | Board, 6 | filterToShield, 7 | getHardware, 8 | getKeyboardFiles, 9 | GroupedHardware, 10 | Hardware, 11 | Keyboard, 12 | Shield, 13 | } from './hardware'; 14 | 15 | const OPEN_REPO_ACTION = 'Open ZMK config template'; 16 | const TEMPLATE_URL = 17 | 'https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fzmkfirmware%2Funified-zmk-config-template'; 18 | 19 | interface KeyboardParts { 20 | keyboard: Keyboard; 21 | board: Board; 22 | shield?: Shield; 23 | } 24 | 25 | export class SetupWizard implements vscode.Disposable { 26 | private disposable: vscode.Disposable; 27 | 28 | constructor(private context: vscode.ExtensionContext) { 29 | this.disposable = vscode.commands.registerCommand('zmk.addKeyboard', this.runWizard, this); 30 | } 31 | 32 | dispose() { 33 | this.disposable.dispose(); 34 | } 35 | 36 | private async runWizard() { 37 | // TODO: make a custom quick pick which allows going back a step. 38 | const config = await getConfig(); 39 | if (!config) { 40 | return; 41 | } 42 | 43 | const parts = await this.pickKeyboardParts(config); 44 | if (!parts) { 45 | return; 46 | } 47 | 48 | await vscode.window.withProgress( 49 | { 50 | location: vscode.ProgressLocation.Notification, 51 | title: 'Fetching keyboard files', 52 | }, 53 | async () => { 54 | await copyConfigFiles(config, parts.keyboard); 55 | }, 56 | ); 57 | 58 | try { 59 | const builds = getBuildItems(parts); 60 | await addToBuildMatrix(this.context, config, builds); 61 | } catch (e) { 62 | console.error(e); 63 | vscode.window.showErrorMessage(`Failed to update build matrix: ${e}`); 64 | } 65 | } 66 | 67 | private async pickKeyboardParts(config: ConfigLocation): Promise { 68 | const hardware = getHardware(this.context, config); 69 | 70 | const keyboard = await this.pickKeyboard(hardware); 71 | if (!keyboard) { 72 | return undefined; 73 | } 74 | 75 | switch (keyboard.type) { 76 | case 'board': 77 | return { keyboard, board: keyboard }; 78 | 79 | case 'shield': { 80 | const board = await this.pickController(hardware, keyboard); 81 | if (!board) { 82 | return undefined; 83 | } 84 | 85 | return { 86 | keyboard, 87 | board, 88 | shield: keyboard, 89 | }; 90 | } 91 | } 92 | } 93 | 94 | private async pickKeyboard(hardware: Promise) { 95 | const getItems = async () => { 96 | return getHardwarePickItems((await hardware).keyboards); 97 | }; 98 | 99 | const result = await vscode.window.showQuickPick(getItems(), { 100 | title: 'Pick a keyboard', 101 | placeHolder: 'Keyboard', 102 | ignoreFocusOut: true, 103 | matchOnDescription: true, 104 | }); 105 | 106 | return result?.item; 107 | } 108 | 109 | private async pickController(hardware: Promise, shield: Shield) { 110 | const getItems = async () => { 111 | const compatible = filterToShield((await hardware).controllers, shield); 112 | return getHardwarePickItems(compatible); 113 | }; 114 | 115 | const result = await vscode.window.showQuickPick(getItems(), { 116 | title: 'Pick an MCU board', 117 | placeHolder: 'Controller', 118 | ignoreFocusOut: true, 119 | matchOnDescription: true, 120 | }); 121 | 122 | return result?.item; 123 | } 124 | } 125 | 126 | async function getConfig() { 127 | try { 128 | return await getConfigLocation(); 129 | } catch (e: unknown) { 130 | if (e instanceof ConfigMissingError) { 131 | showConfigMissingError(); 132 | } 133 | console.error(e); 134 | } 135 | return undefined; 136 | } 137 | 138 | async function showConfigMissingError() { 139 | const response = await vscode.window.showErrorMessage( 140 | 'Could not find a ZMK config repo in the workspace. Go to the template repo and click "Use this template" to create a new repo.', 141 | OPEN_REPO_ACTION, 142 | ); 143 | if (response === OPEN_REPO_ACTION) { 144 | vscode.env.openExternal(vscode.Uri.parse(TEMPLATE_URL)); 145 | } 146 | } 147 | 148 | function getBuildItems(parts: KeyboardParts) { 149 | if (parts.shield) { 150 | return getShieldBuildItems(parts.board, parts.shield); 151 | } 152 | 153 | return getBoardBuildItems(parts.board); 154 | } 155 | 156 | function getBoardBuildItems(board: Board): BuildItem[] { 157 | const ids = board.siblings ?? [board.id]; 158 | 159 | return ids.map((id) => { 160 | return { board: id }; 161 | }); 162 | } 163 | 164 | function getShieldBuildItems(board: Board, shield: Shield): BuildItem[] { 165 | const ids = shield.siblings ?? [shield.id]; 166 | 167 | return ids.map((id) => { 168 | return { shield: id, board: board.id }; 169 | }); 170 | } 171 | 172 | interface HardwarePickItem extends vscode.QuickPickItem { 173 | item: T; 174 | } 175 | 176 | function getHardwarePickItems(hardware: T[]): HardwarePickItem[] { 177 | return hardware.map((item) => { 178 | return { 179 | label: item.name, 180 | description: item.id, 181 | item, 182 | }; 183 | }); 184 | } 185 | 186 | async function copyConfigFiles(config: ConfigLocation, keyboard: Keyboard) { 187 | const files = getKeyboardFiles(keyboard); 188 | 189 | const configUri = vscode.Uri.joinPath(config.config, `${keyboard.id}.conf`); 190 | const keymapUri = vscode.Uri.joinPath(config.config, `${keyboard.id}.keymap`); 191 | 192 | await Promise.all([copyFile(config, configUri, files.configUrl), copyFile(config, keymapUri, files.keymapUrl)]); 193 | 194 | const keymap = await vscode.workspace.openTextDocument(keymapUri); 195 | vscode.window.showTextDocument(keymap); 196 | } 197 | 198 | async function exists(uri: vscode.Uri) { 199 | try { 200 | await vscode.workspace.fs.stat(uri); 201 | return true; 202 | } catch (e) { 203 | if (e instanceof vscode.FileSystemError) { 204 | return false; 205 | } 206 | 207 | throw e; 208 | } 209 | } 210 | 211 | async function copyFile(config: ConfigLocation, dest: vscode.Uri, source: vscode.Uri) { 212 | // Don't overwrite existing files. 213 | if (await exists(dest)) { 214 | return; 215 | } 216 | 217 | try { 218 | const buffer = await fetchFile(config, source); 219 | await vscode.workspace.fs.writeFile(dest, buffer); 220 | } catch (e) { 221 | vscode.window.showWarningMessage(`Failed to copy [${source}](${source}): ${e}`); 222 | return; 223 | } 224 | } 225 | 226 | async function fetchFile(config: ConfigLocation, uri: vscode.Uri): Promise { 227 | if (uri.toString().startsWith(config.workspace.uri.toString())) { 228 | return await vscode.workspace.fs.readFile(uri); 229 | } 230 | 231 | const response = await fetch(uri.toString()); 232 | if (!response.ok) { 233 | throw new Error(await response.text()); 234 | } 235 | 236 | return new Uint8Array(await response.arrayBuffer()); 237 | } 238 | -------------------------------------------------------------------------------- /src/Parser.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import Parser from 'web-tree-sitter'; 3 | import { fetchResource } from './file'; 4 | import { stripQuotes } from './util'; 5 | 6 | const WHITESPACE_RE = /\s/; 7 | 8 | async function initTreeSitter(context: vscode.ExtensionContext) { 9 | await Parser.init({ 10 | locateFile(path: string, prefix: string) { 11 | const uri = vscode.Uri.joinPath(context.extensionUri, 'dist', path); 12 | return uri.toString(true); 13 | }, 14 | }); 15 | } 16 | 17 | async function loadLanguage(context: vscode.ExtensionContext) { 18 | const wasmBinary = await fetchResource(context, 'dist/tree-sitter-devicetree.wasm'); 19 | 20 | return await Parser.Language.load(wasmBinary); 21 | } 22 | 23 | async function createParser(context: vscode.ExtensionContext) { 24 | await initTreeSitter(context); 25 | 26 | const parser = new Parser(); 27 | const language = await loadLanguage(context); 28 | 29 | parser.setLanguage(language); 30 | return { parser, language }; 31 | } 32 | 33 | export interface ParseChangedEvent { 34 | document: vscode.TextDocument; 35 | } 36 | 37 | /** 38 | * Parses `.keymap` files. 39 | */ 40 | export class KeymapParser implements vscode.Disposable { 41 | static async init(context: vscode.ExtensionContext): Promise { 42 | const { parser, language } = await createParser(context); 43 | return new KeymapParser(parser, language); 44 | } 45 | 46 | private _onDidChangeParse = new vscode.EventEmitter(); 47 | public onDidChangeParse = this._onDidChangeParse.event; 48 | 49 | private disposable: vscode.Disposable; 50 | private trees: Record = {}; 51 | 52 | private constructor( 53 | private parser: Parser, 54 | private language: Parser.Language, 55 | ) { 56 | this.disposable = vscode.Disposable.from( 57 | vscode.workspace.onDidCloseTextDocument((document) => this.deleteTree(document)), 58 | vscode.workspace.onDidChangeTextDocument((e) => this.updateTree(e)), 59 | ); 60 | } 61 | 62 | dispose() { 63 | this.disposable.dispose(); 64 | } 65 | 66 | /** 67 | * Returns an up-to-date parse tree for a document. 68 | */ 69 | parse(document: vscode.TextDocument): Parser.Tree { 70 | return this.trees[document.uri.toString()] ?? this.openDocument(document); 71 | } 72 | 73 | /** 74 | * Builds a tree-sitter query for keymap files. 75 | */ 76 | query(expression: string): Parser.Query { 77 | return this.language.query(expression); 78 | } 79 | 80 | private getTree(document: vscode.TextDocument): Parser.Tree | undefined { 81 | return this.trees[document.uri.toString()]; 82 | } 83 | 84 | private setTree(document: vscode.TextDocument, tree: Parser.Tree): Parser.Tree { 85 | this.trees[document.uri.toString()] = tree; 86 | this._onDidChangeParse.fire({ document }); 87 | return tree; 88 | } 89 | 90 | private deleteTree(document: vscode.TextDocument) { 91 | delete this.trees[document.uri.toString()]; 92 | } 93 | 94 | private getParserInput(document: vscode.TextDocument): Parser.Input { 95 | return (index, startPosition) => { 96 | if (startPosition && startPosition.row < document.lineCount) { 97 | const line = document.lineAt(startPosition.row); 98 | return line.text.slice(startPosition.column); 99 | } 100 | 101 | return null; 102 | }; 103 | } 104 | 105 | private openDocument(document: vscode.TextDocument): Parser.Tree { 106 | return this.setTree(document, this.parser.parse(document.getText())); 107 | } 108 | 109 | private updateTree(e: vscode.TextDocumentChangeEvent) { 110 | const tree = this.getTree(e.document); 111 | if (!tree) { 112 | return; 113 | } 114 | 115 | for (const change of e.contentChanges) { 116 | const startIndex = change.rangeOffset; 117 | const oldEndIndex = change.rangeOffset + change.rangeLength; 118 | const newEndIndex = change.rangeOffset + change.text.length; 119 | const startPosition = asPoint(e.document.positionAt(startIndex)); 120 | const oldEndPosition = asPoint(e.document.positionAt(oldEndIndex)); 121 | const newEndPosition = asPoint(e.document.positionAt(newEndIndex)); 122 | tree.edit({ startIndex, oldEndIndex, newEndIndex, startPosition, oldEndPosition, newEndPosition }); 123 | } 124 | 125 | // TODO: figure out how to make this work to be more efficient. 126 | // const newTree = this.parser.parse(this.getParserInput(e.document), tree); 127 | const newTree = this.parser.parse(e.document.getText(), tree); 128 | this.setTree(e.document, newTree); 129 | } 130 | } 131 | 132 | /** 133 | * Converts a vscode position to a tree-sitter point. 134 | */ 135 | export function asPoint(position: vscode.Position): Parser.Point { 136 | return { row: position.line, column: position.character }; 137 | } 138 | 139 | /** 140 | * Converts a tree-sitter point to a vscode position. 141 | */ 142 | export function asPosition(point: Parser.Point): vscode.Position { 143 | return new vscode.Position(point.row, point.column); 144 | } 145 | 146 | /** 147 | * Returns whether two nodes are equal. 148 | * TODO: replace this with a.equals(b) once tree-sitter's equals() is fixed. 149 | */ 150 | export function nodesEqual(a: Parser.SyntaxNode, b: Parser.SyntaxNode): boolean { 151 | type NodeWithId = Parser.SyntaxNode & { id: number }; 152 | 153 | return (a as NodeWithId).id === (b as NodeWithId).id; 154 | } 155 | 156 | /** 157 | * Returns whether `node` is a descendant of `other`. 158 | */ 159 | export function isDescendantOf(node: Parser.SyntaxNode, other: Parser.SyntaxNode): boolean { 160 | let current: Parser.SyntaxNode | null = node; 161 | 162 | while (current) { 163 | if (nodesEqual(current, other)) { 164 | return true; 165 | } 166 | 167 | current = current.parent; 168 | } 169 | 170 | return false; 171 | } 172 | 173 | /** 174 | * Finds a position inside the first non-whitespace token which is before the 175 | * given position. 176 | */ 177 | export function findPreviousToken( 178 | document: vscode.TextDocument, 179 | position: vscode.Position, 180 | ): vscode.Position | undefined { 181 | const line = document.lineAt(position.line); 182 | 183 | for (let i = position.character - 1; i >= 0; i--) { 184 | const char = line.text[i]; 185 | 186 | if (char !== undefined && !WHITESPACE_RE.test(char)) { 187 | return position.with({ character: i }); 188 | } 189 | } 190 | 191 | return undefined; 192 | } 193 | 194 | /** 195 | * Gets the named descendant of `root` at a position. 196 | */ 197 | export function nodeAtPosition(root: Parser.SyntaxNode, position: vscode.Position): Parser.SyntaxNode { 198 | const point = asPoint(position); 199 | return root.namedDescendantForPosition(point); 200 | } 201 | 202 | /** 203 | * Returns the closest ancestor that has a given node type. 204 | */ 205 | export function getAncesorOfType(node: Parser.SyntaxNode | null, type: string): Parser.SyntaxNode | null { 206 | while (node && node.type !== type) { 207 | node = node.parent; 208 | } 209 | 210 | return node; 211 | } 212 | 213 | /** 214 | * Gets the start/end position of a node as a vscode range. 215 | */ 216 | export function getNodeRange(node: Parser.SyntaxNode): vscode.Range { 217 | return new vscode.Range(asPosition(node.startPosition), asPosition(node.endPosition)); 218 | } 219 | 220 | /** 221 | * Gets the name of the DeviceTree property which includes the given node, 222 | * or `undefined` if it is not part of a property. 223 | */ 224 | export function getPropertyName(node: Parser.SyntaxNode): string | undefined { 225 | const prop = getAncesorOfType(node, 'property'); 226 | return prop?.childForFieldName('name')?.text; 227 | } 228 | 229 | /** 230 | * Gets the "compatible" property of the DeviceTree node which includes the given node, 231 | * or `undefined` if it is not part of a node with such a property. 232 | */ 233 | export function getCompatible(node: Parser.SyntaxNode): string | undefined { 234 | const dtNode = getAncesorOfType(node, 'node'); 235 | const properties = dtNode?.descendantsOfType('property'); 236 | const compatible = properties?.find((x) => x.childForFieldName('name')?.text === 'compatible'); 237 | const value = compatible?.childForFieldName('value'); 238 | return value ? stripQuotes(value.text) : undefined; 239 | } 240 | -------------------------------------------------------------------------------- /src/KeymapAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import type Parser from 'web-tree-sitter'; 3 | import { 4 | KeymapParser, 5 | ParseChangedEvent, 6 | asPosition, 7 | findPreviousToken, 8 | getAncesorOfType, 9 | getCompatible, 10 | getNodeRange, 11 | getPropertyName, 12 | isDescendantOf, 13 | nodeAtPosition, 14 | nodesEqual, 15 | } from './Parser'; 16 | import { 17 | Behavior, 18 | behaviorsToCompletions, 19 | behaviorsToSignatures, 20 | getBehaviors, 21 | parameterToCompletions, 22 | testBehavior, 23 | } from './behaviors'; 24 | import * as keymap from './keymap'; 25 | import { IncludeInfo } from './keymap'; 26 | import { stripIncludeQuotes, truncateAtWhitespace } from './util'; 27 | 28 | const DIAGNOSTICS_UPDATE_DELAY = 500; 29 | 30 | type CompletionResult = vscode.ProviderResult>; 31 | type SignatureResult = vscode.ProviderResult; 32 | 33 | /** 34 | * Manages all code analysis for .keymap files. 35 | */ 36 | export class KeymapAnalyzer implements vscode.CompletionItemProvider, vscode.SignatureHelpProvider, vscode.Disposable { 37 | private disposable: vscode.Disposable; 38 | private diagnosticCollection: vscode.DiagnosticCollection; 39 | // private errorQuery: Parser.Query; 40 | private includeQuery: Parser.Query; 41 | private updateTimeout?: ReturnType; 42 | private staleDocuments: Set = new Set(); 43 | 44 | public constructor(private parser: KeymapParser) { 45 | this.diagnosticCollection = vscode.languages.createDiagnosticCollection('zmk-keymap'); 46 | 47 | // this.errorQuery = this.parser.query('(ERROR) @error'); 48 | this.includeQuery = this.parser.query('(preproc_include path: (_) @include)'); 49 | 50 | this.disposable = vscode.Disposable.from( 51 | this.diagnosticCollection, 52 | this.parser.onDidChangeParse(this.handleParseChanged, this), 53 | vscode.languages.registerCompletionItemProvider(keymap.SELECTOR, this, ' ', '&', '('), 54 | vscode.languages.registerSignatureHelpProvider(keymap.SELECTOR, this, ' '), 55 | ); 56 | 57 | for (const document of vscode.workspace.textDocuments) { 58 | if (keymap.isKeymap(document)) { 59 | this.staleDocuments.add(document); 60 | } 61 | } 62 | this.setUpdateTimeout(); 63 | } 64 | 65 | dispose() { 66 | this.clearUpdateTimeout(); 67 | this.disposable.dispose(); 68 | } 69 | 70 | provideCompletionItems( 71 | document: vscode.TextDocument, 72 | position: vscode.Position, 73 | token: vscode.CancellationToken, 74 | context: vscode.CompletionContext, 75 | ): CompletionResult { 76 | return this.getCompletions(this.getAnalysisArgs(document, position, context)); 77 | } 78 | 79 | provideSignatureHelp( 80 | document: vscode.TextDocument, 81 | position: vscode.Position, 82 | token: vscode.CancellationToken, 83 | context: vscode.SignatureHelpContext, 84 | ): SignatureResult { 85 | return this.getSignatureHelp(this.getAnalysisArgs(document, position, context)); 86 | } 87 | 88 | private handleParseChanged(e: ParseChangedEvent) { 89 | this.staleDocuments.add(e.document); 90 | this.setUpdateTimeout(); 91 | } 92 | 93 | private getAnalysisArgs(document: vscode.TextDocument, position: vscode.Position, context: T): AnalysisArgs { 94 | const tree = this.parser.parse(document); 95 | let node = nodeAtPosition(tree.rootNode, position); 96 | let isAfter = false; 97 | 98 | const prevToken = findPreviousToken(document, position); 99 | const prevNode = prevToken ? nodeAtPosition(tree.rootNode, prevToken) : undefined; 100 | 101 | if (prevNode && isDescendantOf(prevNode, node)) { 102 | isAfter = asPosition(prevNode.endPosition).isBefore(position); 103 | node = prevNode; 104 | } 105 | 106 | return { document, position, context, node, isAfter }; 107 | } 108 | 109 | private clearUpdateTimeout() { 110 | if (this.updateTimeout) { 111 | clearTimeout(this.updateTimeout); 112 | } 113 | } 114 | 115 | private setUpdateTimeout() { 116 | this.clearUpdateTimeout(); 117 | this.updateTimeout = setTimeout(() => { 118 | for (const document of this.staleDocuments) { 119 | this.updateDiagnostics(document); 120 | } 121 | this.staleDocuments.clear(); 122 | }, DIAGNOSTICS_UPDATE_DELAY); 123 | } 124 | 125 | private updateDiagnostics(document: vscode.TextDocument) { 126 | // TODO: Disabled until tree-sitter provides better error diagnostics. 127 | // const tree = this.parser.parse(document); 128 | // this.diagnosticCollection.delete(document.uri); 129 | // if (tree.rootNode.hasError()) { 130 | // const diagnostics: vscode.Diagnostic[] = []; 131 | // const errors = this.errorQuery.matches(tree.rootNode); 132 | // for (const capture of getCaptures(errors)) { 133 | // // TODO: provide a more meaningful error message 134 | // // see https://github.com/tree-sitter/tree-sitter/issues/255 135 | // const range = getNodeRange(capture.node); 136 | // diagnostics.push(new vscode.Diagnostic(range, 'Syntax error', vscode.DiagnosticSeverity.Error)); 137 | // } 138 | // // TODO: search for missing nodes too. 139 | // this.diagnosticCollection.set(document.uri, diagnostics); 140 | // } 141 | } 142 | 143 | private getIncludeInfo(document: vscode.TextDocument): IncludeInfo { 144 | const tree = this.parser.parse(document); 145 | const includes = this.includeQuery.matches(tree.rootNode); 146 | 147 | const captures = [...getCaptures(includes)]; 148 | 149 | let insertPosition: vscode.Position; 150 | 151 | if (captures.length > 0) { 152 | insertPosition = asPosition(captures[captures.length - 1].node.endPosition) 153 | .translate({ lineDelta: 1 }) 154 | .with({ character: 0 }); 155 | } else { 156 | insertPosition = findFirstPositionForInclude(document, tree); 157 | } 158 | 159 | return { 160 | paths: captures.map((capture) => stripIncludeQuotes(capture.node.text)), 161 | insertPosition, 162 | }; 163 | } 164 | 165 | private getCompletions(args: CompletionArgs): CompletionResult { 166 | const property = getPropertyName(args.node); 167 | if (property) { 168 | const compatible = getCompatible(args.node); 169 | return this.getCompletionsForProperty(args, property, compatible); 170 | } 171 | 172 | return undefined; 173 | } 174 | 175 | private getSignatureHelp(args: SignatureArgs): SignatureResult { 176 | const property = getPropertyName(args.node); 177 | if (property) { 178 | const compatible = getCompatible(args.node); 179 | return this.getSignaturesForProperty(args, property, compatible); 180 | } 181 | 182 | return undefined; 183 | } 184 | 185 | private getCompletionsForProperty(args: CompletionArgs, property: string, compatible?: string): CompletionResult { 186 | const behaviors = getBehaviors(property, compatible); 187 | if (behaviors) { 188 | return this.getCompletionsForBindings(args, behaviors); 189 | } 190 | return undefined; 191 | } 192 | 193 | private getSignaturesForProperty(args: SignatureArgs, property: string, compatible?: string): SignatureResult { 194 | const behaviors = getBehaviors(property, compatible); 195 | if (behaviors) { 196 | return this.getSignaturesForBindings(args, behaviors); 197 | } 198 | return undefined; 199 | } 200 | 201 | private getCompletionsForBindings(args: CompletionArgs, validBehaviors: readonly Behavior[]): CompletionResult { 202 | const { node } = args; 203 | if (node.type === 'integer_cells') { 204 | return this.getCompletionsForBehaviors(args, validBehaviors); 205 | } 206 | 207 | const { behavior, paramIndex } = findCurrentBehavior(args); 208 | if (behavior) { 209 | if (paramIndex !== undefined) { 210 | return this.getBehaviorParamCompletions(args, validBehaviors, behavior, paramIndex); 211 | } 212 | 213 | return this.getCompletionsForBehaviors(args, validBehaviors, behavior); 214 | } 215 | 216 | return undefined; 217 | } 218 | 219 | private getSignaturesForBindings(args: SignatureArgs, validBehaviors: readonly Behavior[]): SignatureResult { 220 | const { behavior, paramIndex } = findCurrentBehavior(args); 221 | if (behavior && paramIndex !== undefined) { 222 | const filteredBehaviors = filterBehaviors(validBehaviors, behavior, { matchFullWord: true }); 223 | const signatures = behaviorsToSignatures(filteredBehaviors, paramIndex); 224 | return { 225 | signatures, 226 | activeSignature: 0, // TODO? 227 | activeParameter: paramIndex, 228 | }; 229 | } 230 | 231 | return undefined; 232 | } 233 | 234 | private getCompletionsForBehaviors( 235 | args: CompletionArgs, 236 | validBehaviors: readonly Behavior[], 237 | behavior?: Parser.SyntaxNode, 238 | ): CompletionResult { 239 | // Don't trigger completion for behaviors on space if there's a behavior 240 | // right after the cursor. You're probably just changing alignment. 241 | if (args.context.triggerCharacter === ' ') { 242 | const node = getAncesorOfType(args.node, 'reference') ?? args.node; 243 | 244 | if (asPosition(node.startPosition).isEqual(args.position)) { 245 | return; 246 | } 247 | 248 | if (getNextSiblingOnLine(node)?.type === 'reference') { 249 | return; 250 | } 251 | } 252 | 253 | if (behavior) { 254 | let range = getNodeRange(behavior); 255 | if (!range.isSingleLine) { 256 | range = range.with({ end: args.position }); 257 | } 258 | 259 | return behaviorsToCompletions( 260 | filterBehaviors(validBehaviors, behavior), 261 | this.getIncludeInfo(args.document), 262 | range, 263 | ); 264 | } 265 | 266 | return behaviorsToCompletions(validBehaviors, this.getIncludeInfo(args.document)); 267 | } 268 | 269 | private getBehaviorParamCompletions( 270 | args: CompletionArgs, 271 | validBehaviors: readonly Behavior[], 272 | behaviorNode: Parser.SyntaxNode, 273 | paramIndex: number, 274 | ): CompletionResult { 275 | // Don't trigger completion for behaviors on space unless there's a behavior 276 | // right after the cursor. You're probably just changing alignment. 277 | if (args.context.triggerCharacter === ' ') { 278 | const node = isDescendantOf(args.node, behaviorNode) ? behaviorNode : args.node; 279 | if (asPosition(node.startPosition).isEqual(args.position)) { 280 | return; 281 | } 282 | 283 | const next = getNextSiblingOnLine(node); 284 | if (next && next.type !== 'reference') { 285 | return; 286 | } 287 | } 288 | 289 | const filteredBehaviors = filterBehaviors(validBehaviors, behaviorNode); 290 | 291 | if (filteredBehaviors.length > 0) { 292 | const behavior = filteredBehaviors[0]; 293 | if (paramIndex < behavior.parameters.length) { 294 | return parameterToCompletions(behavior.parameters[paramIndex], this.getIncludeInfo(args.document)); 295 | } 296 | } 297 | 298 | // This is after the last parameter for the behavior. Suggest a new 299 | // behavior instead. 300 | return this.getCompletionsForBehaviors(args, validBehaviors); 301 | } 302 | } 303 | 304 | function* getCaptures(matches: Parser.QueryMatch[]): Generator { 305 | for (const match of matches) { 306 | for (const capture of match.captures) { 307 | yield capture; 308 | } 309 | } 310 | } 311 | 312 | function findFirstPositionForInclude(document: vscode.TextDocument, tree: Parser.Tree): vscode.Position { 313 | let line = 0; 314 | do { 315 | const node = tree.rootNode.descendantForPosition({ row: line, column: 0 }); 316 | if (nodesEqual(node, tree.rootNode)) { 317 | break; 318 | } 319 | 320 | if (node.type !== 'comment') { 321 | break; 322 | } 323 | 324 | line++; 325 | } while (line < document.lineCount); 326 | 327 | return new vscode.Position(line, 0); 328 | } 329 | 330 | interface AnalysisArgs { 331 | document: vscode.TextDocument; 332 | position: vscode.Position; 333 | context: T; 334 | node: Parser.SyntaxNode; 335 | isAfter: boolean; 336 | } 337 | 338 | type CompletionArgs = AnalysisArgs; 339 | type SignatureArgs = AnalysisArgs; 340 | 341 | interface BehaviorLocation { 342 | behavior: Parser.SyntaxNode | null; 343 | paramIndex?: number; 344 | } 345 | 346 | function findCurrentBehavior({ node, isAfter }: AnalysisArgs): BehaviorLocation { 347 | // Find the child of the integer cells array we're in. 348 | let current: Parser.SyntaxNode | null = node; 349 | while (current && current.parent?.type !== 'integer_cells') { 350 | current = current.parent; 351 | } 352 | 353 | if (!current) { 354 | return { behavior: null }; 355 | } 356 | 357 | if (current.type === 'reference') { 358 | // We're inside the reference node. 359 | return { behavior: current, paramIndex: isAfter ? 0 : undefined }; 360 | } 361 | 362 | // Walk back through siblings until we find the reference node. 363 | let paramIndex = isAfter ? 0 : -1; 364 | while (current && current.type !== 'reference') { 365 | paramIndex++; 366 | current = current.previousNamedSibling; 367 | } 368 | 369 | if (!current) { 370 | // Reached start without finding a reference node. 371 | return { behavior: null }; 372 | } 373 | 374 | return { behavior: current, paramIndex }; 375 | } 376 | 377 | function filterBehaviors( 378 | validBehaviors: readonly Behavior[], 379 | behavior: Parser.SyntaxNode, 380 | options?: { matchFullWord: boolean }, 381 | ): Behavior[] { 382 | const { matchFullWord } = { matchFullWord: false, ...options }; 383 | 384 | const text = truncateAtWhitespace(behavior.text); 385 | const filtered = validBehaviors.filter((b) => { 386 | if (!b.label.startsWith(text)) { 387 | return false; 388 | } 389 | 390 | if (matchFullWord) { 391 | if (truncateAtWhitespace(b.label) !== text) { 392 | return false; 393 | } 394 | } 395 | 396 | if (b.if) { 397 | return testBehavior(behavior, b.if); 398 | } 399 | 400 | return true; 401 | }); 402 | 403 | return filtered; 404 | } 405 | 406 | function getNextSiblingOnLine(node: Parser.SyntaxNode): Parser.SyntaxNode | null { 407 | const next = node.nextNamedSibling; 408 | if (next?.startPosition.row === node.endPosition.row) { 409 | return next; 410 | } 411 | 412 | return null; 413 | } 414 | -------------------------------------------------------------------------------- /src/behaviors.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=behaviors.schema.json 2 | 3 | parameters: 4 | keycode: &keycode 5 | documentation: Key code 6 | label: KEYCODE 7 | type: keycode 8 | 9 | layer: &layer 10 | documentation: Layer index 11 | label: LAYER 12 | type: integer 13 | 14 | mouseButton: &mouseButton 15 | documentation: Mouse button 16 | label: BUTTON 17 | type: mouseButton 18 | 19 | behaviors: 20 | bindings: 21 | # Key Press https://zmk.dev/docs/keymaps/behaviors/key-press 22 | - label: '&kp KEYCODE' 23 | documentation: | 24 | [Key press](https://zmk.dev/docs/keymaps/behaviors/key-press) 25 | 26 | Sends standard key codes on press/release. 27 | parameters: 28 | - *keycode 29 | 30 | # Key Toggle https://zmk.dev/docs/keymaps/behaviors/key-toggle 31 | - label: '&kt KEYCODE' 32 | documentation: | 33 | [Key toggle](https://zmk.dev/docs/keymaps/behaviors/key-toggle) 34 | 35 | Toggles whether a key is pressed. 36 | parameters: 37 | - *keycode 38 | 39 | # Layers https://zmk.dev/docs/keymaps/behaviors/layers 40 | - label: '&mo LAYER' 41 | documentation: | 42 | [Momentary layer](https://zmk.dev/docs/keymaps/behaviors/layers#momentary-layer) 43 | 44 | Switches to a layer while the key is held. 45 | parameters: 46 | - *layer 47 | 48 | - label: '< LAYER TAP' 49 | documentation: | 50 | [Layer-tap](https://zmk.dev/docs/keymaps/behaviors/layers#layer-tap) 51 | 52 | * **On hold:** switches to a layer 53 | * **On tap:** sends a keycode 54 | parameters: 55 | - label: LAYER 56 | type: integer 57 | documentation: Layer index to use when held 58 | - label: TAP 59 | type: keycode 60 | documentation: Key code to send when tapped 61 | 62 | - label: '&to' 63 | documentation: | 64 | [To layer](https://zmk.dev/docs/keymaps/behaviors/layers#to-layer) 65 | 66 | Enables a layer and disables all other layers except the default layer. 67 | parameters: 68 | - *layer 69 | 70 | - label: '&tog LAYER' 71 | documentation: | 72 | [Toggle layer](https://zmk.dev/docs/keymaps/behaviors/layers#toggle-layer) 73 | 74 | Toggles whether a layer is enabled. 75 | parameters: 76 | - *layer 77 | 78 | # Miscellaneous https://zmk.dev/docs/keymaps/behaviors/misc 79 | - label: '&trans' 80 | documentation: | 81 | [Transparent](https://zmk.dev/docs/keymaps/behaviors/misc#transparent) 82 | 83 | Passes key presses down to the next active layer in the stack. 84 | Does nothing if on the base layer. 85 | parameters: [] 86 | 87 | - label: '&none' 88 | documentation: | 89 | [None](https://zmk.dev/docs/keymaps/behaviors/misc#none) 90 | 91 | Ignores a key press so it will *not* be passed down to the next active layer in the stack. 92 | parameters: [] 93 | 94 | # Mod-Tap https://zmk.dev/docs/keymaps/behaviors/mod-tap 95 | - label: '&mt MODIFIER TAP' 96 | documentation: | 97 | [Mod-tap](https://zmk.dev/docs/keymaps/behaviors/mod-tap) 98 | 99 | * **On hold:** holds a modifier 100 | * **On tap:** sends a keycode 101 | parameters: 102 | - label: MODIFIER 103 | documentation: Modifier to send when held 104 | type: modifier 105 | - label: TAP 106 | documentation: Key code to send when tapped 107 | type: keycode 108 | 109 | # Mod-Morph https://zmk.dev/docs/keymaps/behaviors/mod-morph 110 | - label: '&gresc' 111 | documentation: | 112 | [Grave escape](https://zmk.dev/docs/keymaps/behaviors/mod-morph) 113 | 114 | Sends `&kp ESCAPE` normally or `&kp GRAVE` when either Shift or GUI modifiers are held. 115 | parameters: [] 116 | 117 | # Sticky Key https://zmk.dev/docs/keymaps/behaviors/sticky-key 118 | - label: '&sk KEYCODE' 119 | documentation: | 120 | [Sticky key](https://zmk.dev/docs/keymaps/behaviors/sticky-key) 121 | 122 | Sends a key and keeps it pressed until another key is pressed. 123 | parameters: 124 | - *keycode 125 | 126 | # Sticky Layer https://zmk.dev/docs/keymaps/behaviors/sticky-layer 127 | - label: '&sl LAYER' 128 | documentation: | 129 | [Sticky layer](https://zmk.dev/docs/keymaps/behaviors/sticky-layer) 130 | 131 | Activates a layer until another key is pressed. 132 | parameters: 133 | - *layer 134 | 135 | # Caps Word https://zmk.dev/docs/keymaps/behaviors/caps-word 136 | - label: '&caps_word' 137 | documentation: | 138 | [Caps word](https://zmk.dev/docs/keymaps/behaviors/caps-word) 139 | 140 | Acts like caps lock but automatically deactivates when a "break" key is pressed. 141 | parameters: [] 142 | 143 | # Key Repeat https://zmk.dev/docs/keymaps/behaviors/key-repeat 144 | - label: '&key_repeat' 145 | documentation: | 146 | [Key repeat](https://zmk.dev/docs/keymaps/behaviors/key-repeat) 147 | 148 | Sends whatever key code was last sent. 149 | parameters: [] 150 | 151 | # Reset https://zmk.dev/docs/keymaps/behaviors/reset 152 | - label: '&sys_reset' 153 | documentation: | 154 | [Reset](https://zmk.dev/docs/keymaps/behaviors/reset#reset) 155 | 156 | Resets the keyboard and restarts its firmware. 157 | parameters: [] 158 | 159 | - label: '&bootloader' 160 | documentation: | 161 | [Bootloader reset](https://zmk.dev/docs/keymaps/behaviors/reset#bootloader-reset) 162 | 163 | Resets the keyboard and puts it into bootloader mode, allowing you to flash new firmware. 164 | parameters: [] 165 | 166 | # Bluetooth https://zmk.dev/docs/keymaps/behaviors/bluetooth 167 | - label: '&bt ACTION' 168 | documentation: | 169 | [Bluetooth command](https://zmk.dev/docs/keymaps/behaviors/bluetooth) 170 | if: 171 | - paramsNot: [BT_SEL] 172 | - paramsNot: [BT_DISC] 173 | parameters: 174 | - label: ACTION 175 | include: dt-bindings/zmk/bt.h 176 | type: 177 | - label: BT_CLR 178 | documentation: Clear bond information between the keyboard and host for the selected profile. 179 | - label: BT_NXT 180 | documentation: Switch to the next profile, cycling through to the first one when the end is reached. 181 | - label: BT_PRV 182 | documentation: Switch to the previous profile, cycling through to the last one when the beginning is reached. 183 | - label: BT_SEL 184 | documentation: Select the 0-indexed profile by number. 185 | - label: BT_DISC 186 | documentation: Disconnect from the 0-indexed profile by number, if it's currently connected and inactive. 187 | 188 | - label: '&bt BT_SEL PROFILE' 189 | documentation: | 190 | [Bluetooth command](https://zmk.dev/docs/keymaps/behaviors/bluetooth) 191 | 192 | Select the 0-indexed profile by number. 193 | if: 194 | params: [BT_SEL] 195 | parameters: 196 | - label: BT_SEL 197 | include: dt-bindings/zmk/bt.h 198 | type: 199 | - label: BT_SEL 200 | documentation: Selects the 0-indexed profile by number. 201 | - label: PROFILE 202 | documentation: 0-based index of the profile to select. 203 | type: integer 204 | 205 | - label: '&bt BT_DISC PROFILE' 206 | documentation: | 207 | [Bluetooth command](https://zmk.dev/docs/keymaps/behaviors/bluetooth) 208 | 209 | Disconnect from the 0-indexed profile by number, if it's currently connected and inactive. 210 | if: 211 | params: [BT_DISC] 212 | parameters: 213 | - label: BT_DISC 214 | include: dt-bindings/zmk/bt.h 215 | type: 216 | - label: BT_DISC 217 | documentation: Disconnect from the 0-indexed profile by number, if it's currently connected and inactive. 218 | - label: PROFILE 219 | documentation: 0-based index of the profile from which to disconnect. 220 | type: integer 221 | 222 | # Output Selection https://zmk.dev/docs/keymaps/behaviors/outputs 223 | - label: '&out ACTION' 224 | documentation: | 225 | [Output selection command](https://zmk.dev/docs/keymaps/behaviors/outputs) 226 | parameters: 227 | - label: ACTION 228 | include: dt-bindings/zmk/outputs.h 229 | type: 230 | - label: OUT_USB 231 | documentation: Prefer sending to USB. 232 | - label: OUT_BLE 233 | documentation: Prefer sending to the current bluetooth profile. 234 | - label: OUT_TOG 235 | documentation: Toggle between USB and BLE. 236 | 237 | # RGB Underglow https://zmk.dev/docs/keymaps/behaviors/underglow 238 | - label: '&rgb_ug ACTION' 239 | documentation: | 240 | [RGB underglow command](https://zmk.dev/docs/keymaps/behaviors/lighting#rgb-underglow) 241 | parameters: 242 | - label: ACTION 243 | include: dt-bindings/zmk/rgb.h 244 | type: 245 | - label: RGB_TOG 246 | documentation: Toggles the RGB feature on and off. 247 | - label: RGB_HUI 248 | documentation: Increases the hue of the RGB feature. 249 | - label: RGB_HUD 250 | documentation: Decreases the hue of the RGB feature. 251 | - label: RGB_SAI 252 | documentation: Increases the saturation of the RGB feature. 253 | - label: RGB_SAD 254 | documentation: Decreases the saturation of the RGB feature. 😢 255 | - label: RGB_BRI 256 | documentation: Increases the brightness of the RGB feature. 257 | - label: RGB_BRD 258 | documentation: Decreases the brightness of the RGB feature. 259 | - label: RGB_SPI 260 | documentation: Increases the speed of the RGB feature effect's animation. 261 | - label: RGB_SPD 262 | documentation: Decreases the speed of the RGB feature effect's animation. 263 | - label: RGB_EFF 264 | documentation: Cycles the RGB feature's effect forwards. 265 | - label: RGB_EFR 266 | documentation: Cycles the RGB feature's effect reverse. 267 | 268 | # Backlight https://zmk.dev/docs/keymaps/behaviors/backlight 269 | - label: '&bl ACTION' 270 | documentation: | 271 | [Backlight command](https://zmk.dev/docs/keymaps/behaviors/backlight) 272 | if: 273 | paramsNot: [BL_SET] 274 | parameters: 275 | - label: ACTION 276 | include: dt-bindings/zmk/backlight.h 277 | type: 278 | - label: BL_ON 279 | documentation: Turn on backlight 280 | - label: BL_OFF 281 | documentation: Turn off backlight 282 | - label: BL_TOG 283 | documentation: Toggle backlight on and off 284 | - label: BL_INC 285 | documentation: Increase brightness 286 | - label: BL_DEC 287 | documentation: Decrease brightness 288 | - label: BL_CYCLE 289 | documentation: Cycle brightness 290 | - label: BL_SET 291 | documentation: Set a specific brightness 292 | 293 | - label: '&bl BL_SET BRIGHTNESS' 294 | documentation: | 295 | [Backlight command](https://zmk.dev/docs/keymaps/behaviors/backlight) 296 | 297 | Set backlight brightness 298 | if: 299 | params: [BL_SET] 300 | parameters: 301 | - label: BL_SET 302 | include: dt-bindings/zmk/backlight.h 303 | type: 304 | - label: BL_SET 305 | documentation: Set a specific brightness 306 | - label: BRIGHTNESS 307 | documentation: Brightness as a percentage 308 | type: integer 309 | 310 | # Power Management https://zmk.dev/docs/keymaps/behaviors/power 311 | - label: '&ext_power ACTION' 312 | documentation: | 313 | [External power control](https://zmk.dev/docs/keymaps/behaviors/power#external-power-control) 314 | parameters: 315 | - label: ACTION 316 | include: dt-bindings/zmk/ext_power.h 317 | type: 318 | - label: EP_OFF 319 | documentation: Disable the external power. 320 | - label: EP_ON 321 | documentation: Enable the external power. 322 | - label: EP_TOG 323 | documentation: Toggle the external power on/off. 324 | 325 | # Mouse Button Press https://zmk.dev/docs/keymaps/behaviors/mouse-emulation 326 | - label: '&mkp BUTTON' 327 | documentation: | 328 | [Mouse button press](https://zmk.dev/docs/keymaps/behaviors/mouse-emulation#mouse-button-press) 329 | 330 | Sends a mouse button press. 331 | parameters: 332 | - *mouseButton 333 | 334 | # Soft Off https://zmk.dev/docs/keymaps/behaviors/soft-off 335 | - label: '&soft_off' 336 | documentation: | 337 | [Soft Off](https://zmk.dev/docs/keymaps/behaviors/soft-off) 338 | 339 | Puts the keyboard into an off state. It can be turned back on with the reset button, 340 | or on specific keyboards, with a dedicated on button. 341 | parameters: [] 342 | 343 | # ZMK Studio Unlock https://zmk.dev/docs/keymaps/behaviors/studio-unlock 344 | - label: '&studio_unlock' 345 | documentation: | 346 | [ZMK Studio Unlock](https://zmk.dev/docs/keymaps/behaviors/studio-unlock) 347 | 348 | Grants [ZMK Studio](https://zmk.dev/docs/features/studio) access to make changes to the keyboard. 349 | parameters: [] 350 | 351 | sensor-bindings: 352 | # Encoder 353 | - label: '&inc_dec_kp CW_KEY CCW_KEY' 354 | documentation: | 355 | [Encoder key press](https://zmk.dev/docs/features/encoders) 356 | 357 | Sends keycodes when rotating an encoder. 358 | parameters: 359 | - label: CW_KEY 360 | documentation: Keycode to send when the encoder is rotated clockwise. 361 | type: keycode 362 | - label: CCW_KEY 363 | documentation: Keycode to send when the encoder is rotated counter-clockwise. 364 | type: keycode 365 | 366 | macroBehaviors: 367 | # https://zmk.dev/docs/keymaps/behaviors/macros 368 | bindings: 369 | - label: '¯o_tap' 370 | documentation: | 371 | [Tap mode](https://zmk.dev/docs/keymaps/behaviors/macros#binding-activation-mode) 372 | 373 | Switch the macro to key tap mode 374 | parameters: [] 375 | 376 | - label: '¯o_press' 377 | documentation: | 378 | [Press mode](https://zmk.dev/docs/keymaps/behaviors/macros#binding-activation-mode) 379 | 380 | Switch the macro to key press mode 381 | parameters: [] 382 | 383 | - label: '¯o_release' 384 | documentation: | 385 | [Release mode](https://zmk.dev/docs/keymaps/behaviors/macros#binding-activation-mode) 386 | 387 | Switch the macro to key release mode 388 | parameters: [] 389 | 390 | - label: '¯o_tap_time TAP_MS' 391 | documentation: | 392 | [Tap time](https://zmk.dev/docs/keymaps/behaviors/macros#tap-time) 393 | 394 | Change the duration of taps when in tap mode 395 | parameters: 396 | - label: TAP_MS 397 | documentation: Tap duration in milliseconds 398 | type: integer 399 | 400 | - label: '¯o_wait_time WAIT_MS' 401 | documentation: | 402 | [Wait time](https://zmk.dev/docs/keymaps/behaviors/macros#wait-time) 403 | 404 | Change the time to wait between bindings 405 | parameters: 406 | - label: WAIT_MS 407 | documentation: Delay in milliseconds 408 | type: integer 409 | 410 | - label: '¯o_pause_for_release' 411 | documentation: | 412 | [Pause for release](https://zmk.dev/docs/keymaps/behaviors/macros#processing-continuation-on-release) 413 | 414 | Pause the macro until the key that triggered the macro is released 415 | parameters: [] 416 | --------------------------------------------------------------------------------