├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── images ├── example.gif ├── logo.png ├── logo.svg └── terminal.gif ├── package.json ├── src ├── command-processor.ts ├── command-variables.ts ├── extension.ts ├── file-ignore-checker.ts ├── run-on-save.ts ├── types.ts └── util.ts ├── test ├── .vscode-test.mjs ├── fixture │ └── .gitignore ├── src │ └── extension.test.ts └── tsconfig.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /out 3 | /test/out 4 | .vscode-test/ 5 | *.vsix 6 | *.tsbuildinfo 7 | package-lock.json -------------------------------------------------------------------------------- /.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 | "eg2.tslint" 6 | ] 7 | } -------------------------------------------------------------------------------- /.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 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--disable-extensions", 15 | "--extensionDevelopmentPath=${workspaceFolder}" 16 | ], 17 | "outFiles": [ 18 | "${workspaceFolder}/out/**/*.js" 19 | ], 20 | "preLaunchTask": "npm: watch" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } -------------------------------------------------------------------------------- /.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": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | test/** 4 | src/** 5 | .gitignore 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/tslint.json 9 | **/*.tsbuildinfo 10 | **/*.map 11 | **/*.ts 12 | **/*.svg 13 | **/*.gif 14 | 15 | # under node_modules 16 | **/.github/** 17 | **/node_modules/**/.* 18 | **/node_modules/**/@types/** 19 | **/node_modules/**/test/** 20 | **/node_modules/**/*.md 21 | **/node_modules/**/*.markdown 22 | **/node_modules/**/LICENSE 23 | **/node_modules/**/license 24 | **/node_modules/**/README 25 | **/node_modules/**/readme 26 | **/node_modules/**/CHANGELOG 27 | **/node_modules/**/changelog -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | ## [1.10.1] 4 | - Fix #49, when `workingDirectoryAsCWD` is `true`, and multiple root folders existing, `cwd` can be rightly choose the root folder that recently saved file belongs to. 5 | 6 | ## [1.10.0] 7 | - Merges `strawhat-dev@github`'s pull request, supports `commands[].languages` to do language id matching. 8 | 9 | ## [1.9.2] 10 | - `defaultRunIn` option now it's default value is `backend`, not `vscode`. If your commands can't run, try specify this option. 11 | 12 | ## [1.9.1] 13 | - Fix #44, which will cause `defaultRunIn` option not working. 14 | 15 | ## [1.9.0] 16 | - Add a `doNotDisturb` option to prevent output tab from been focused on non-zero exit code. Thanks to pull request from `Tristano8@github` . 17 | 18 | 19 | ## [1.8.0] 20 | - Supports `onlyRunOnManualSave` option, to limit running commands only for manually save. 21 | - Fixes the wrong `commandBeforeSaving` usage description. 22 | 23 | 24 | ## [1.7.1] 25 | 26 | - Merges `CyprusSocialite@github`'s pull request, supports `clearOutput` option, and adjust plugin activation time. 27 | - Supports `commandBeforeSaving`, to specifies commands before saving document. 28 | - `globMatch` can be specified as a relative path, which will be used to match file path relative to current workspace directory. 29 | 30 | 31 | ## [1.7.0] 32 | 33 | - Merges `jackwhelpton@github`'s pull request, supports more variables. 34 | - Adjusts test settings. 35 | 36 | 37 | ## [1.6.0] 38 | 39 | - Adds a `terminalHideTimeout` option, to support close terminal automatically after command ran. 40 | - Adds a `ignoreFilesBy` option, to support ignore files that listed in like `.gitignore`. 41 | - Adds a `args` option, to provide arguments for command, especially for vscode command. 42 | 43 | 44 | ## [1.5.0] 45 | 46 | - Adds a `async` option, to support run commands in a sequence. 47 | 48 | 49 | ## [1.4.3] 50 | 51 | - `globMatch` will also apply "Variable Substitution", so you may specify a `globMatch` expression that include `${workspaceFolder}` to only match saved files in current workspace. 52 | 53 | 54 | ## [1.4.0] 55 | 56 | - Supports `forcePathSeparator` option. 57 | 58 | 59 | ## [1.3.1] 60 | 61 | - Supports `${fileRelative}`. 62 | 63 | 64 | ## [1.3.0] 65 | 66 | - Supports `commandBeforeSaving`. 67 | - Supports `globMatch` to make it easier to specifies file matcher. 68 | 69 | 70 | ## [1.2.0] 71 | 72 | - Supports vscode command, Thanks to Hulvdan@github. 73 | 74 | 75 | ## [1.1.0] 76 | 77 | - Will escape command line arguments automatically when file or directory name contains white space. 78 | 79 | 80 | ## [1.0.6] 81 | 82 | - Supports run command in terminal. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 pucelle 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | a save logo 3 | Run on Save - VSCode Extension 4 |

5 | 6 | Configure shell commands and related file patterns, commands will be executed when matched files were saved. 7 | 8 | 9 | ## Features 10 | 11 | You can specify status bar messages which will show before and after commands executing, such that they will tell you what's happening and not distrub you much: 12 | 13 | ![example](images/example.gif) 14 | 15 | If you prefer running commands in vscode terminal, which keeps message colors and give more feedback details, change the `runIn` option to `terminal`. 16 | 17 | ![terminal](images/terminal.gif) 18 | 19 | If you need to run VS Code's commands change `runIn` option to `vscode` 20 | 21 | 22 | ## Configuration 23 | 24 | | Name | Description 25 | | --- | --- 26 | | `runOnSave.statusMessageTimeout` | Specify the default timeout milliseconds after which the status bar message will hide, default value is `3000`, means 3 seconds. 27 | | `runOnSave.ignoreFilesBy` | Specifies it to ignore files that list in `.gitignore` or `.npmignore`. default value is empty list. 28 | | `runOnSave.shell` | Specify in which shell the commands are executed, defaults to the default vscode shell. 29 | | `runOnSave.defaultRunIn` | Specify default `commands[].runIn` for all the commands, defaults to `backend` if not specified. 30 | | `runOnSave.onlyRunOnManualSave` | Whether to only run commands when a file is manually saved, defaults to `false`. 31 | | `runOnSave.commands` | Specify the array of commands to execute and related info, its child options as below. 32 | 33 | ### Command Options 34 | 35 | Note, `languages`, `match`, `globMatch` work like filter, if specifies more than one, command must match all then can run. So normally specifying one of them would be enough. 36 | 37 | | Name | Description 38 | | --- | --- 39 | | `commands[].languages` | Specify an array of language ids to filter commands, when language matches command will run. 40 | | `commands[].match` | Specify RegExp source string, files which's path match will be included. E.g.: `\\.scss$` can used to match scss files. 41 | | `commands[].notMatch` | Specify RegExp source string, files which's path match will be excluded even they were included by `match` or `globMatch`. E.g.: `[\\\\\\/]_[\\w-]+\\.scss$` can be used to exclude scss library files. 42 | | `commands[].globMatch` | Specify a glob expression, to match the whole file path or the relative path relative to current workspace directory. the matched files will be included. E.g.: `**/*.scss` will match all scss files, `*.scss` will match all scss files located in current workspace directory. 43 | | `commands[].command` | Specify the shell command to execute. You may include variable substitution like what to do in [VSCode Tasks](https://code.visualstudio.com/docs/editor/tasks#_variable-substitution). 44 | | `commands[].commandBeforeSaving` | Same as `commands`, but runs it before saving action happens, and document is not saved yet. 45 | | `commands[].args` | Specify the command parameters, can be a string, array of string, or an object. 46 | | `commands[].forcePathSeparator` | Force path separator in variable substitution to be `/`, `\\`, default is not specified. 47 | | `commands[].async` | All the commands with `async: false` will run in a sequence, means run next after previous completed. Default value is `true`. | 48 | | `commands[].runningStatusMessage` | Specify the status bar message when the shell command begin to run, supports variable substitution too. Only works when `runIn=backend`. 49 | | `commands[].finishStatusMessage` | Specify the status bar message after the shell command finished executing, also supports variable substitution. Only works when `runIn=backend`. 50 | | `commands[].statusMessageTimeout` | Specify the timeout milliseconds of current message, after which the status bar message will hide, default value is `3000`, means 3 seconds. 51 | | `commands[].terminalHideTimeout` | Specify the timeout in milliseconds after which the terminal for running current command will hide. Only works when `runIn=terminal`. If default value is `-1`, set it as a value `>=0` can make it work. 52 | | `commands[].workingDirectoryAsCWD`| Specify the vscode working directory as shell CWD (Current Working Directory). Only works when `runIn=backend`. 53 | | `commands[].clearOutput` | Clear the output channel before running current command. Default value is `false`. 54 | | `commands[].doNotDisturb` | By default, output tab would get focus after receiving non-zero exit codes. Set this option to `true` can prevent it. Only works when `runIn=backend`. 55 | | `commands[].runIn` | See list below. Default value is specified by `runOnSave.defaultRunIn`, or `vscode`. 56 | - `backend`: Run command silently and show messages in output channel, you can specify runningStatusMessage and finishStatusMessage to give you a little feedback. Choose this when you don't want to be disturbed. 57 | - `terminal`: Run command in vscode terminal, which keeps message colors. Choose this when you want to get feedback details. 58 | - `vscode`: Run vscode's command. Choose this if you want to execute vscode's own command or a command of a particular extension. 59 | 60 | 61 | ### Sample Configuration 62 | 63 | ```js 64 | { 65 | "runOnSave.statusMessageTimeout": 3000, 66 | "runOnSave.commands": [ 67 | { 68 | // Match scss files except names start with `_`. 69 | "match": ".*\\.scss$", 70 | "notMatch": "[\\\\\\/]_[^\\\\\\/]*\\.scss$", 71 | "command": "node-sass ${file} ${fileDirname}/${fileBasenameNoExtension}.css", 72 | "runIn": "backend", 73 | "runningStatusMessage": "Compiling ${fileBasename}", 74 | "finishStatusMessage": "${fileBasename} compiled" 75 | }, 76 | { 77 | // Match less files except names start with `_`. 78 | "globMatch": "**/[^_]*.less", 79 | "command": "node-sass ${file} ${fileDirname}/${fileBasenameNoExtension}.css", 80 | "runIn": "terminal" 81 | }, 82 | { 83 | // Match any python files by path. 84 | "match": ".*\\.py$", 85 | "command": "python.runLinting", 86 | "runIn": "vscode" 87 | }, 88 | { 89 | // Match any python files by language id. 90 | "languages": ["python"], 91 | "command": "python.runLinting", 92 | "runIn": "vscode" 93 | } 94 | ] 95 | } 96 | ``` 97 | 98 | 99 | ### Variable Substitution 100 | 101 | Can be used in `command`, `runningStatusMessage`, `finishStatusMessage`, `globMatch`. 102 | 103 | Note that if `forcePathSeparator` specified, separators in these variables will be replaced. 104 | 105 | | Variable | Description 106 | | --- | --- 107 | | `${workspaceFolder}` | the path of the folder opened in VS Code. 108 | | `${workspaceFolderBasename}` | the name of the folder opened in VS Code without any slashes (/). 109 | | `${file}` | the path of current opened file. 110 | | `${fileBasename}` | the basename part of current opened file. 111 | | `${fileBasenameNoExtension}` | the basename part without extension of current opened file. 112 | | `${fileExtname}` | the extension part of current opened file. 113 | | `${fileRelative}` | the shorter relative file path part from current vscode working directory. 114 | | `${fileDirname}` | the dirname path part of current opened file. 115 | | `${fileDirnameBasename}` | the basename of dirname path part of current opened file. 116 | | `${fileDirnameRelative}` | the shorter relative dirname path part from current vscode working directory. 117 | | `${cwd}` | the task runner's current working directory on startup. 118 | | `${userHome}` | current user's system home directory. 119 | | `${lineNumber}` | number of active line in vscode editor. 120 | | `${selectedText}` | selected text in vscode editor. 121 | | `${execPath}` | absolute pathname of the vscode process. 122 | | `${defaultBuildTaskName}` | default build task name of current project. 123 | | `${pathSeparator}` | path separator based on system. 124 | | `${env:envName}` | reference environment variable `envName`. 125 | | `${config:vsConfigName}` | reference vscode configuration name `vsConfigName`. 126 | | `${command:vsCommandName}` | execute vscode command `vsCommandName`, and reference returned result. 127 | 128 | 129 | To better distinguish file path associated variables, assume you have opened a workspace located at `/Users/UserName/ProjectName`, and you are editing `folderName/subFolderName/fileName.css` inside of it, then: 130 | 131 | | Variable | Value 132 | | --- | --- 133 | | `${workspaceFolder}` | `/Users/UserName/ProjectName` 134 | | `${workspaceFolderBasename}` | `ProjectName` 135 | | `${file}` | `/Users/UserName/ProjectName/folderName/subFolderName/fileName.css` 136 | | `${fileBasename}` | `fileName.css` 137 | | `${fileBasenameNoExtension}` | `fileName` 138 | | `${fileExtname}` | `css` 139 | | `${fileRelative}` | `folderName/subFolderName/fileName.css` 140 | | `${fileDirname}` | `/Users/UserName/ProjectName/folderName/subFolderName` 141 | | `${fileDirnameBasename}` | `subFolderName` 142 | | `${fileDirnameRelative}` | `folderName/subFolderName` 143 | 144 | 145 | 146 | ## Commands 147 | 148 | The following commands are exposed in the command palette 149 | 150 | - `Run On Save: Enable` - to enable the extension 151 | - `Run On Save: Disable` - to disable the extension 152 | 153 | 154 | ## References 155 | 156 | This plugin inspired from these 2 plugins: 157 | 158 | [vscode-runonsave](https://github.com/emeraldwalk/vscode-runonsave) and [vscode-save-and-run](https://github.com/wk-j/vscode-save-and-run). 159 | 160 | 161 | ## License 162 | 163 | MIT 164 | -------------------------------------------------------------------------------- /images/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pucelle/vscode-run-on-save/0c24eda6bcc7d94c0e8c06c9537ded2135cf0d3a/images/example.gif -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pucelle/vscode-run-on-save/0c24eda6bcc7d94c0e8c06c9537ded2135cf0d3a/images/logo.png -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /images/terminal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pucelle/vscode-run-on-save/0c24eda6bcc7d94c0e8c06c9537ded2135cf0d3a/images/terminal.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "run-on-save", 3 | "displayName": "Run on Save", 4 | "description": "Run configured shell commands when a file is saved in vscode, and output configured messages on status bar.", 5 | "icon": "images/logo.png", 6 | "version": "1.10.2", 7 | "license": "MIT", 8 | "publisher": "pucelle", 9 | "homepage": "https://github.com/pucelle/vscode-run-on-save", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/pucelle/vscode-run-on-save" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/pucelle/vscode-run-on-save/issues" 16 | }, 17 | "engines": { 18 | "vscode": "^1.81.0" 19 | }, 20 | "categories": [ 21 | "Other" 22 | ], 23 | "keywords": [ 24 | "run on save", 25 | "save and run", 26 | "compile scss on save", 27 | "compile less on save", 28 | "compile files on save" 29 | ], 30 | "activationEvents": [ 31 | "onStartupFinished" 32 | ], 33 | "main": "./out/extension.js", 34 | "contributes": { 35 | "commands": [ 36 | { 37 | "command": "extension.enableRunOnSave", 38 | "title": "Run On Save: Enable" 39 | }, 40 | { 41 | "command": "extension.disableRunOnSave", 42 | "title": "Run On Save: Disable" 43 | } 44 | ], 45 | "configuration": { 46 | "title": "Run On Save", 47 | "type": "object", 48 | "properties": { 49 | "runOnSave.statusMessageTimeout": { 50 | "type": "number", 51 | "description": "Specify the timeout in millisecond after which the status message will be hidden. Works when `runIn=backend`, can be overwritten by the `statusMessageTimeout` in each command.", 52 | "default": 3000 53 | }, 54 | "runOnSave.ignoreFilesBy": { 55 | "type": "array", 56 | "items": { 57 | "type": "string", 58 | "enum": [ 59 | ".gitignore", 60 | ".npmignore" 61 | ] 62 | }, 63 | "default": [], 64 | "description": "Specifies it to ignore files list in `.gitignore` or `.npmignore`." 65 | }, 66 | "runOnSave.shell": { 67 | "type": "string", 68 | "description": "Specify what shell will be used for all the commands. Default is vscode's default shell.", 69 | "default": null 70 | }, 71 | "runOnSave.defaultRunIn": { 72 | "type": "string", 73 | "description": "Run command in which environment by default. Default value is `backend`.", 74 | "enum": [ 75 | "backend", 76 | "terminal", 77 | "vscode" 78 | ], 79 | "default": "backend", 80 | "enumDescriptions": [ 81 | "Run command silently and output messages to output channel, you can specify `runningStatusMessage` and `finishStatusMessage` to give you feedback. Choose this when you don't want to be disturbed.", 82 | "Run command in vscode terminal, which keeps message colors. Choose this when you want to get command feedback details.", 83 | "Run vscode's command. Choose this if you want to execute vscode's own command or a command from a installed vscode extension." 84 | ] 85 | }, 86 | "runOnSave.onlyRunOnManualSave": { 87 | "type": "boolean", 88 | "description": "Only run commands when manually saving a file. Default value is `false`.", 89 | "default": false 90 | }, 91 | "runOnSave.commands": { 92 | "type": "array", 93 | "description": "Shell commands array.", 94 | "default": [], 95 | "items": { 96 | "type": "object", 97 | "properties": { 98 | "languages": { 99 | "type": "array", 100 | "description": "Specify an array of languageIds for which this command applies to.", 101 | "default": [], 102 | "uniqueItems": true, 103 | "items": { 104 | "type": "string", 105 | "examples": [ 106 | "abap", 107 | "bat", 108 | "bibtex", 109 | "clojure", 110 | "coffeescript", 111 | "c", 112 | "cpp", 113 | "csharp", 114 | "dockercompose", 115 | "css", 116 | "cuda-cpp", 117 | "d", 118 | "dart", 119 | "pascal", 120 | "diff", 121 | "dockerfile", 122 | "erlang", 123 | "fsharp", 124 | "git-rebase", 125 | "go", 126 | "groovy", 127 | "handlebars", 128 | "haml", 129 | "haskell", 130 | "html", 131 | "ini", 132 | "java", 133 | "javascript", 134 | "javascriptreact", 135 | "json", 136 | "jsonc", 137 | "julia", 138 | "latex", 139 | "less", 140 | "lua", 141 | "makefile", 142 | "markdown", 143 | "objective-c", 144 | "objective-cpp", 145 | "ocaml", 146 | "pascal", 147 | "perl6", 148 | "php", 149 | "plaintext", 150 | "powershell", 151 | "pug", 152 | "python", 153 | "r", 154 | "razor", 155 | "ruby", 156 | "rust", 157 | "syntax)", 158 | "shaderlab", 159 | "shellscript", 160 | "slim", 161 | "sql", 162 | "stylus", 163 | "svelte", 164 | "swift", 165 | "typescript", 166 | "typescriptreact", 167 | "tex", 168 | "vb", 169 | "vue", 170 | "vue-html", 171 | "xml", 172 | "xsl", 173 | "yaml" 174 | ] 175 | } 176 | }, 177 | "match": { 178 | "type": "string", 179 | "description": "Specify a RegExp source to match file path. Note if specifies both `match` and `globMatch`, commands matched them both will be executed.", 180 | "default": "" 181 | }, 182 | "notMatch": { 183 | "type": "string", 184 | "description": "Specify a RegExp source, the files whole path match it will be excluded.", 185 | "default": "" 186 | }, 187 | "globMatch": { 188 | "type": "string", 189 | "description": "Specify a glob expression to match file path. reference to: https://github.com/isaacs/node-glob#glob-primer. Note if specifies both `match` and `globMatch`, only commands matched them both will be executed.", 190 | "default": "" 191 | }, 192 | "commandBeforeSaving": { 193 | "type": "string", 194 | "description": "Specify the command to be executed before saving the file. Note that for backend command, file will be saved after command executed completed.", 195 | "default": "echo ${file}" 196 | }, 197 | "command": { 198 | "type": "string", 199 | "description": "Specify the command to be executed after file saved.", 200 | "default": "echo ${file}" 201 | }, 202 | "args": { 203 | "type": [ 204 | "string", 205 | "array", 206 | "object" 207 | ], 208 | "description": "Specify the command parameters, can be a string, array of string, or an object. Note for a `backend` or `terminal` command, if args option is defined as array of string, or an object, will format argument to add quotes if needed. e.g., `['-a', 'Value 1']` will be formatted as `-a \"Value 1\"`", 209 | "default": "" 210 | }, 211 | "runIn": { 212 | "type": "string", 213 | "description": "Run command in which environment, if not specified, uses `runOnSave.defaultRunIn`.", 214 | "enum": [ 215 | "backend", 216 | "terminal", 217 | "vscode" 218 | ], 219 | "default": "backend", 220 | "enumDescriptions": [ 221 | "Run command silently and output messages to output channel, you can specify `runningStatusMessage` and `finishStatusMessage` to give you feedback. Choose this when you don't want to be disturbed.", 222 | "Run command in vscode terminal, which keeps message colors. Choose this when you want to get command feedback details.", 223 | "Run vscode's command. Choose this if you want to execute vscode's own command or a command from a installed vscode extension." 224 | ] 225 | }, 226 | "async": { 227 | "type": "boolean", 228 | "description": "All the commands with `async: false` will run in a sequence, means run next after previous completed. Default value is `true`.", 229 | "default": true 230 | }, 231 | "runningStatusMessage": { 232 | "type": "string", 233 | "description": "Specify the status bar message when the shell command began to run. Only works when `runIn=backend`.", 234 | "default": "" 235 | }, 236 | "finishStatusMessage": { 237 | "type": "string", 238 | "description": "Specify the status bar message after the shell command finished executing. Only works when `runIn=backend`.", 239 | "default": "" 240 | }, 241 | "statusMessageTimeout": { 242 | "type": "number", 243 | "description": "Specify the timeout in millisecond after which the status message will hide. Only works when `runIn=backend`.", 244 | "default": 3000 245 | }, 246 | "terminalHideTimeout": { 247 | "type": "number", 248 | "description": "Specify the timeout in millisecond after which the terminal for running current command will hide. Only works when `runIn=terminal`. If default value is `-1`, set it as a value `>=0` can make it work.", 249 | "default": -1 250 | }, 251 | "workingDirectoryAsCWD": { 252 | "type": "boolean", 253 | "description": "Specify the vscode working directory as shell CWD (Current Working Directory). Only works when `runIn=backend`.", 254 | "default": true 255 | }, 256 | "clearOutput": { 257 | "type": "boolean", 258 | "description": "Clear the output channel before running. Default value is `false`.", 259 | "default": false 260 | } 261 | } 262 | } 263 | }, 264 | "runOnSave.commandsBeforeSaving": { 265 | "type": "array", 266 | "description": "Shell commands array, just like `runOnSave.commands`, but runs before saving documents.", 267 | "default": [], 268 | "items": { 269 | "type": "object", 270 | "properties": { 271 | "languages": { 272 | "type": "array", 273 | "description": "Specify an array of language ids for which this command applies to.", 274 | "default": [], 275 | "uniqueItems": true, 276 | "items": { 277 | "type": "string", 278 | "examples": [ 279 | "abap", 280 | "bat", 281 | "bibtex", 282 | "clojure", 283 | "coffeescript", 284 | "c", 285 | "cpp", 286 | "csharp", 287 | "dockercompose", 288 | "css", 289 | "cuda-cpp", 290 | "d", 291 | "dart", 292 | "pascal", 293 | "diff", 294 | "dockerfile", 295 | "erlang", 296 | "fsharp", 297 | "git-rebase", 298 | "go", 299 | "groovy", 300 | "handlebars", 301 | "haml", 302 | "haskell", 303 | "html", 304 | "ini", 305 | "java", 306 | "javascript", 307 | "javascriptreact", 308 | "json", 309 | "jsonc", 310 | "julia", 311 | "latex", 312 | "less", 313 | "lua", 314 | "makefile", 315 | "markdown", 316 | "objective-c", 317 | "objective-cpp", 318 | "ocaml", 319 | "pascal", 320 | "perl6", 321 | "php", 322 | "plaintext", 323 | "powershell", 324 | "pug", 325 | "python", 326 | "r", 327 | "razor", 328 | "ruby", 329 | "rust", 330 | "syntax", 331 | "shaderlab", 332 | "shellscript", 333 | "slim", 334 | "sql", 335 | "stylus", 336 | "svelte", 337 | "swift", 338 | "typescript", 339 | "typescriptreact", 340 | "tex", 341 | "vb", 342 | "vue", 343 | "vue-html", 344 | "xml", 345 | "xsl", 346 | "yaml" 347 | ] 348 | } 349 | }, 350 | "match": { 351 | "type": "string", 352 | "description": "Specify a RegExp source to match file path. Note if specifies both `match` and `globMatch`, commands matched them both will be executed.", 353 | "default": "" 354 | }, 355 | "notMatch": { 356 | "type": "string", 357 | "description": "Specify a RegExp source, the files whole path match it will be excluded.", 358 | "default": "" 359 | }, 360 | "globMatch": { 361 | "type": "string", 362 | "description": "Specify a glob expression to match file path. reference to: https://github.com/isaacs/node-glob#glob-primer. Note if specifies both `match` and `globMatch`, only commands matched them both will be executed.", 363 | "default": "" 364 | }, 365 | "commandBeforeSaving": { 366 | "type": "string", 367 | "description": "Specify the command to be executed before saving the file. Note that for backend command, file will be saved after command executed completed.", 368 | "default": "echo ${file}" 369 | }, 370 | "command": { 371 | "type": "string", 372 | "description": "Specify the command to be executed after file saved.", 373 | "default": "echo ${file}" 374 | }, 375 | "args": { 376 | "type": [ 377 | "string", 378 | "array", 379 | "object" 380 | ], 381 | "description": "Specify the command parameters, can be a string, array of string, or an object. Note for a `backend` or `terminal` command, if args option is defined as array of string, or an object, will format argument to add quotes if needed. e.g., `['-a', 'Value 1']` will be formatted as `-a \"Value 1\"`", 382 | "default": "" 383 | }, 384 | "runIn": { 385 | "type": "string", 386 | "description": "Run command in.", 387 | "enum": [ 388 | "backend", 389 | "terminal", 390 | "vscode" 391 | ], 392 | "default": "backend", 393 | "enumDescriptions": [ 394 | "Run command silently and output messages to output channel, you can specify `runningStatusMessage` and `finishStatusMessage` to give you feedback. Choose this when you don't want to be disturbed.", 395 | "Run command in vscode terminal, which keeps message colors. Choose this when you want to get command feedback details.", 396 | "Run vscode's command. Choose this if you want to execute vscode's own command or a command from a installed vscode extension." 397 | ] 398 | }, 399 | "async": { 400 | "type": "boolean", 401 | "description": "All the commands with `async: false` will run in a sequence, means run next after previous completed. Default value is `true`.", 402 | "default": true 403 | }, 404 | "runningStatusMessage": { 405 | "type": "string", 406 | "description": "Specify the status bar message when the shell command began to run. Only works when `runIn=backend`.", 407 | "default": "" 408 | }, 409 | "finishStatusMessage": { 410 | "type": "string", 411 | "description": "Specify the status bar message after the shell command finished executing. Only works when `runIn=backend`.", 412 | "default": "" 413 | }, 414 | "statusMessageTimeout": { 415 | "type": "number", 416 | "description": "Specify the timeout in millisecond after which the status message will hide. Only works when `runIn=backend`.", 417 | "default": 3000 418 | }, 419 | "terminalHideTimeout": { 420 | "type": "number", 421 | "description": "Specify the timeout in millisecond after which the terminal for running current command will hide. Only works when `runIn=terminal`. If default value is `-1`, set it as a value `>=0` can make it work.", 422 | "default": -1 423 | }, 424 | "workingDirectoryAsCWD": { 425 | "type": "boolean", 426 | "description": "Specify the vscode working directory as shell CWD (Current Working Directory). Only works when `runIn=backend`.", 427 | "default": true 428 | }, 429 | "clearOutput": { 430 | "type": "boolean", 431 | "description": "Clear the output channel before running. Default value is `false`.", 432 | "default": false 433 | }, 434 | "doNotDisturb": { 435 | "type": "boolean", 436 | "description": "By default, output tab would get focus after receiving non-zero exit codes. Set this option to `true` can prevent it. Only works when `runIn=backend`.", 437 | "default": false 438 | } 439 | } 440 | } 441 | } 442 | } 443 | } 444 | }, 445 | "scripts": { 446 | "vscode:prepublish": "npm run build", 447 | "build": "tsc -p ./", 448 | "watch": "tsc -watch -p ./", 449 | "test": "cd test && tsc -b && vscode-test" 450 | }, 451 | "devDependencies": { 452 | "@types/fs-extra": "^11.0.1", 453 | "@types/minimatch": "^3.0.5", 454 | "@types/mocha": "^2.2.48", 455 | "@types/node": "^20.5.4", 456 | "@types/vscode": "^1.81.0", 457 | "@vscode/test-cli": "0.0.4", 458 | "@vscode/test-electron": "^2.3.8", 459 | "typescript": "^4.9.5" 460 | }, 461 | "dependencies": { 462 | "fs-extra": "^11.1.1", 463 | "minimatch": "^3.1.2" 464 | } 465 | } 466 | -------------------------------------------------------------------------------- /src/command-processor.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import {formatCommandPieces, encodeCommandLineToBeQuotedIf} from './util' 3 | import * as minimatch from 'minimatch' 4 | import {CommandVariables} from './command-variables' 5 | import * as path from 'path' 6 | import {Configuration, PathSeparator, RawCommand, VSCodeDocumentPartial} from './types' 7 | 8 | 9 | /** Processed command base, will be extended. */ 10 | export interface ProcessedCommandBase { 11 | languages?: string[] 12 | match?: RegExp 13 | notMatch?: RegExp 14 | globMatch?: string 15 | commandBeforeSaving?: string 16 | command: string 17 | args?: string[] | object | string 18 | forcePathSeparator?: PathSeparator 19 | runIn: string 20 | async?: boolean 21 | clearOutput?: boolean 22 | doNotDisturb?: boolean 23 | } 24 | 25 | export interface BackendCommand extends ProcessedCommandBase { 26 | runIn: 'backend' 27 | runningStatusMessage: string 28 | finishStatusMessage: string 29 | workingDirectoryAsCWD: boolean 30 | statusMessageTimeout?: number 31 | doNotDisturb?: boolean 32 | } 33 | 34 | export interface TerminalCommand extends ProcessedCommandBase { 35 | runIn: 'terminal' 36 | statusMessageTimeout?: number 37 | terminalHideTimeout?: number 38 | } 39 | 40 | export interface VSCodeCommand extends ProcessedCommandBase { 41 | runIn: 'vscode' 42 | args?: string[] | object | string 43 | } 44 | 45 | export type ProcessedCommand = BackendCommand | TerminalCommand | VSCodeCommand 46 | 47 | 48 | 49 | export class CommandProcessor { 50 | 51 | private commands: ProcessedCommand[] = [] 52 | 53 | setRawCommands(commands: RawCommand[], defaultRunIn: Configuration['defaultRunIn']) { 54 | this.commands = this.processCommands(commands, defaultRunIn) 55 | } 56 | 57 | private processCommands(commands: RawCommand[], defaultRunIn: Configuration['defaultRunIn']): ProcessedCommand[] { 58 | return commands.map(command => { 59 | return Object.assign({}, command, { 60 | runIn: command.runIn || defaultRunIn || 'backend', 61 | languages: command.languages, 62 | match: command.match ? new RegExp(command.match, 'i') : undefined, 63 | notMatch: command.notMatch ? new RegExp(command.notMatch, 'i') : undefined, 64 | globMatch: command.globMatch ? command.globMatch : undefined 65 | }) as ProcessedCommand 66 | }) 67 | } 68 | 69 | /** Prepare raw commands to link current working file. */ 70 | prepareCommandsForFileBeforeSaving(document: VSCodeDocumentPartial): Promise { 71 | return this.prepareCommandsForDocument(document, true) 72 | } 73 | 74 | /** Prepare raw commands to link current working file. */ 75 | prepareCommandsForFileAfterSaving(document: VSCodeDocumentPartial): Promise { 76 | return this.prepareCommandsForDocument(document, false) 77 | } 78 | 79 | /** Prepare raw commands to link current working file. */ 80 | private async prepareCommandsForDocument(document: VSCodeDocumentPartial, forCommandsAfterSaving: boolean): Promise { 81 | let preparedCommands: ProcessedCommand[] = [] 82 | 83 | for (let command of await this.filterCommandsByDocument(document)) { 84 | let commandString = forCommandsAfterSaving 85 | ? command.commandBeforeSaving 86 | : command.command 87 | 88 | if (!commandString) { 89 | continue 90 | } 91 | 92 | let pathSeparator = command.forcePathSeparator 93 | 94 | if (command.runIn === 'backend') { 95 | preparedCommands.push({ 96 | runIn: 'backend', 97 | command: this.formatArgs(await this.formatCommandString(commandString, pathSeparator, document.uri), command.args), 98 | runningStatusMessage: await this.formatVariables(command.runningStatusMessage, pathSeparator, document.uri), 99 | finishStatusMessage: await this.formatVariables(command.finishStatusMessage, pathSeparator, document.uri), 100 | async: command.async ?? true, 101 | clearOutput: command.clearOutput ?? false, 102 | doNotDisturb: command.doNotDisturb ?? false, 103 | } as BackendCommand) 104 | } 105 | else if (command.runIn === 'terminal') { 106 | preparedCommands.push({ 107 | runIn: 'terminal', 108 | command: this.formatArgs(await this.formatCommandString(commandString, pathSeparator, document.uri), command.args), 109 | async: command.async ?? true, 110 | clearOutput: command.clearOutput ?? false, 111 | } as TerminalCommand) 112 | } 113 | else { 114 | preparedCommands.push({ 115 | runIn: 'vscode', 116 | command: await this.formatCommandString(commandString, pathSeparator, document.uri), 117 | args: command.args, 118 | async: command.async ?? true, 119 | clearOutput: command.clearOutput ?? false, 120 | } as VSCodeCommand) 121 | } 122 | } 123 | 124 | return preparedCommands 125 | } 126 | 127 | private async filterCommandsByDocument(document: VSCodeDocumentPartial): Promise { 128 | let filteredCommands = [] 129 | 130 | for (let command of this.commands) { 131 | let {languages, match, notMatch, globMatch} = command 132 | 133 | if (!this.doLanguageTest(languages, document)) { 134 | continue 135 | } 136 | 137 | if (!this.doMatchTest(match, notMatch, document.uri)) { 138 | continue 139 | } 140 | 141 | if (!(await this.doGlobMatchTest(globMatch, document.uri))) { 142 | continue 143 | } 144 | 145 | filteredCommands.push(command) 146 | } 147 | 148 | return filteredCommands 149 | } 150 | 151 | private doLanguageTest(languages: string[] | undefined, document: VSCodeDocumentPartial): boolean { 152 | 153 | // No languages specified, not filter out document. 154 | if (!languages?.length) { 155 | return true 156 | } 157 | 158 | // Does not apply if user has specified languages and the current document is a `NotebookDocument`. 159 | if (!('languageId' in document)) { 160 | return false 161 | } 162 | 163 | // Match `languageId` case-insensitively. 164 | return languages.some((languageId) => languageId.toLowerCase() === document.languageId!.toLowerCase()) 165 | } 166 | 167 | private doMatchTest(match: RegExp | undefined, notMatch: RegExp | undefined, uri: vscode.Uri): boolean { 168 | if (match && !match.test(uri.fsPath)) { 169 | return false 170 | } 171 | 172 | if (notMatch && notMatch.test(uri.fsPath)) { 173 | return false 174 | } 175 | 176 | return true 177 | } 178 | 179 | private async doGlobMatchTest(globMatch: string | undefined, uri: vscode.Uri): Promise { 180 | if (!globMatch) { 181 | return true 182 | } 183 | 184 | if (/\${(?:\w+:)?[\w\.]+}/.test(globMatch)) { 185 | globMatch = await this.formatVariables(globMatch, undefined, uri) 186 | } 187 | 188 | let gm = new minimatch.Minimatch(globMatch) 189 | 190 | // If match whole path. 191 | if (gm.match(uri.fsPath)) { 192 | return true 193 | } 194 | 195 | // Or match relative path. 196 | let relativePath = path.relative(vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath || '', uri.fsPath) 197 | if (gm.match(relativePath)) { 198 | return true 199 | } 200 | 201 | return false 202 | } 203 | 204 | private async formatCommandString(command: string, pathSeparator: PathSeparator | undefined, uri: vscode.Uri): Promise { 205 | if (!command) { 206 | return '' 207 | } 208 | 209 | // If white spaces exist in file name or directory name, we need to wrap them with `""`. 210 | // We do this by testing each piece, and wrap them if needed. 211 | return formatCommandPieces(command, async (piece) => { 212 | return CommandVariables.format(piece, uri, pathSeparator) 213 | }) 214 | } 215 | 216 | private async formatVariables(message: string, pathSeparator: PathSeparator | undefined, uri: vscode.Uri): Promise { 217 | if (!message) { 218 | return '' 219 | } 220 | 221 | return CommandVariables.format(message, uri, pathSeparator) 222 | } 223 | 224 | /** Add args to a command string. */ 225 | private formatArgs(command: string, args: string[] | object | string | undefined): string { 226 | if (!args) { 227 | return command 228 | } 229 | 230 | if (Array.isArray(args)) { 231 | for (let arg of args) { 232 | command += ' ' + encodeCommandLineToBeQuotedIf(arg) 233 | } 234 | } 235 | else if (typeof args === 'string') { 236 | command += ' ' + args 237 | } 238 | else if (typeof args === 'object') { 239 | for (let [key, value] of Object.entries(args)) { 240 | command += ' ' + key + ' ' + encodeCommandLineToBeQuotedIf(value) 241 | } 242 | } 243 | 244 | return command 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/command-variables.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as vscode from 'vscode' 3 | import {formatPathSeparator, replaceAsync} from './util' 4 | import {homedir} from 'os' 5 | 6 | 7 | type VariableProvider = (uri: vscode.Uri) => string | Promise 8 | type SubVariableProvider = (name: string, uri: vscode.Uri) => string | Promise 9 | 10 | 11 | export namespace CommandVariables { 12 | 13 | const VariableProviders: Record = { 14 | userHome: () => homedir(), 15 | workspaceFolder: (uri) => getRootPath(uri), 16 | workspaceFolderBasename: (uri) => path.basename(getRootPath(uri)), 17 | file: (uri) => uri.fsPath, 18 | fileBasename: (uri) => path.basename(uri.fsPath), 19 | fileBasenameNoExtension: (uri) => path.basename(uri.fsPath, path.extname(uri.fsPath)), 20 | fileExtname: (uri) => path.extname(uri.fsPath), 21 | fileRelative: (uri) => path.relative(getRootPath(uri), uri.fsPath), 22 | fileDirname: (uri) => getDirName(uri.fsPath), 23 | fileDirnameBasename: (uri) => path.basename(getDirName(uri.fsPath)), 24 | fileDirnameRelative: (uri) => getDirName(path.relative(getRootPath(uri), uri.fsPath)), 25 | 26 | cwd: () => process.cwd(), 27 | lineNumber: () => getEditor()?.selection.active.line.toString() || '', 28 | selectedText: () => getEditor()?.document.getText(getEditor()!.selection) || '', 29 | execPath: () => process.execPath, 30 | defaultBuildTaskName: defaultBuildTaskName, 31 | pathSeparator: () => path.sep, 32 | } 33 | 34 | const SubVariableProviders: Record = { 35 | env: name => process.env[name] || '', 36 | config: (name, uri) => vscode.workspace.getConfiguration('', uri)?.get(name) || '', 37 | command: async name => await vscode.commands.executeCommand(name), 38 | } 39 | 40 | // `path.dirname(...)` can't be used to handle paths like `\\dir\name`. 41 | function getDirName(filePath: string): string { 42 | return filePath.replace(/[\\\/][^\\\/]+$/, '') || filePath[0] || '' 43 | } 44 | 45 | function getRootPath(uri: vscode.Uri): string { 46 | return vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath || '' 47 | } 48 | 49 | function getEditor() { 50 | return vscode.window.activeTextEditor 51 | } 52 | 53 | async function defaultBuildTaskName() { 54 | let tasks = await vscode.tasks.fetchTasks() 55 | let task = tasks.find(t => t.group?.id == vscode.TaskGroup.Build.id && t.group.isDefault) 56 | 57 | return task?.name || '' 58 | } 59 | 60 | 61 | /** Format variables of a string in kind of command / command piece / message. */ 62 | export async function format(string: string, uri: vscode.Uri, pathSeparator: string | undefined) { 63 | 64 | // Compatible with old versioned syntax `${env.xxx}`. 65 | string = string.replace(/\$\{env\.(\w+)\}/g, '${env:$1}') 66 | 67 | return await replaceAsync(string, /\$\{(?:(\w+):)?([\w\.]+)\}/g, async (m0: string, prefix: string | undefined, name: string) => { 68 | let value = await getVariableValue(prefix, name, uri) 69 | if (value !== null) { 70 | return formatPathSeparator(value, pathSeparator) 71 | } 72 | 73 | return m0 74 | }) 75 | } 76 | 77 | /** Get each variable value by RegExp match result. */ 78 | async function getVariableValue(prefix: string | undefined, name: string, uri: vscode.Uri): Promise { 79 | if (prefix && SubVariableProviders.hasOwnProperty(prefix)) { 80 | return SubVariableProviders[prefix](name, uri) 81 | } 82 | else if (VariableProviders.hasOwnProperty(name)) { 83 | return VariableProviders[name](uri) 84 | } 85 | else { 86 | return null 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import {RunOnSaveExtension} from './run-on-save'; 3 | 4 | 5 | export function activate(context: vscode.ExtensionContext): RunOnSaveExtension { 6 | const extension = new RunOnSaveExtension(context) 7 | 8 | context.subscriptions.push( 9 | vscode.workspace.onDidChangeConfiguration(() => { 10 | extension.loadConfig() 11 | }), 12 | 13 | vscode.commands.registerCommand('extension.enableRunOnSave', () => { 14 | extension.setEnabled(true) 15 | }), 16 | 17 | vscode.commands.registerCommand('extension.disableRunOnSave', () => { 18 | extension.setEnabled(false) 19 | }), 20 | 21 | vscode.workspace.onWillSaveTextDocument((e: vscode.TextDocumentWillSaveEvent) => { 22 | e.waitUntil(extension.onWillSaveDocument(e.document, e.reason)) 23 | }), 24 | 25 | vscode.workspace.onWillSaveNotebookDocument((e: vscode.NotebookDocumentWillSaveEvent) => { 26 | e.waitUntil(extension.onWillSaveDocument(e.notebook, e.reason)) 27 | }), 28 | 29 | vscode.workspace.onDidSaveTextDocument((document: vscode.TextDocument) => { 30 | extension.onDocumentSaved(document) 31 | }), 32 | 33 | vscode.workspace.onDidSaveNotebookDocument((document: vscode.NotebookDocument) => { 34 | extension.onDocumentSaved(document) 35 | }), 36 | ) 37 | 38 | return extension 39 | } 40 | -------------------------------------------------------------------------------- /src/file-ignore-checker.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import * as path from 'path' 3 | import {IMinimatch, Minimatch} from 'minimatch' 4 | 5 | 6 | export interface FileIgnoreCheckerOptions { 7 | 8 | /** Current workspace directory. */ 9 | workspaceDir?: string 10 | 11 | /** Ignores file names. */ 12 | ignoreFilesBy: string[] 13 | } 14 | 15 | 16 | export class FileIgnoreChecker { 17 | 18 | /** Current workspace directory, default value is `undefined`. */ 19 | private workspaceDir: string | undefined 20 | 21 | /** Ignores file names. */ 22 | private ignoreFilesBy: string[] 23 | 24 | constructor(options: FileIgnoreCheckerOptions) { 25 | this.workspaceDir = options.workspaceDir 26 | this.ignoreFilesBy = options.ignoreFilesBy 27 | } 28 | 29 | /** Test whether should ignore a file by it's path. */ 30 | async shouldIgnore(filePath: string): Promise { 31 | 32 | // No ignore files should follow, never ignore. 33 | if (this.ignoreFilesBy.length === 0) { 34 | return false 35 | } 36 | 37 | let workspaceDir = this.workspaceDir 38 | 39 | // Not in current working directory, never ignore. 40 | if (workspaceDir && !path.normalize(filePath).startsWith(workspaceDir)) { 41 | return false 42 | } 43 | 44 | let dir = path.dirname(filePath) 45 | while (dir && dir !== workspaceDir) { 46 | for (let ignoreFileName of this.ignoreFilesBy) { 47 | let ignoreFilePath = path.join(dir, ignoreFileName) 48 | 49 | if (await fs.pathExists(ignoreFilePath)) { 50 | let shouldIgnore = await this.shouldIgnoreByIgnoreFilePath(filePath, ignoreFilePath) 51 | if (shouldIgnore) { 52 | return true 53 | } 54 | } 55 | } 56 | 57 | dir = path.dirname(dir) 58 | } 59 | 60 | return false 61 | } 62 | 63 | private async shouldIgnoreByIgnoreFilePath(filePath: string, ignoreFilePath: string): Promise { 64 | let ignoreRules = await this.parseIgnoreRules(ignoreFilePath) 65 | let relPath = path.relative(path.dirname(ignoreFilePath), filePath) 66 | 67 | return this.matchIgnoreRules(relPath, ignoreRules) 68 | } 69 | 70 | private async parseIgnoreRules(ignoreFilePath: string): Promise { 71 | let text = await fs.readFile(ignoreFilePath, 'utf8') 72 | 73 | let globOptions = { 74 | matchBase: true, 75 | dot: true, 76 | flipNegate: true, 77 | nocase: true 78 | } 79 | 80 | let ruleLines = text.split(/\r?\n/) 81 | .filter(line => !/^#|^$/.test(line.trim())) 82 | 83 | // Here it doesn't supports expressions like `!XXX`. 84 | let rules = ruleLines.map(pattern => { 85 | if (pattern.startsWith('/')) { 86 | pattern = pattern.slice(1) 87 | } 88 | else { 89 | pattern = '{**/,}' + pattern 90 | } 91 | 92 | if (pattern.endsWith('/')) { 93 | pattern = pattern.replace(/\/$/, '{/**,}') 94 | } 95 | 96 | return new Minimatch(pattern, globOptions) 97 | }) 98 | 99 | return rules 100 | } 101 | 102 | private matchIgnoreRules(relPath: string, ignoreRules: IMinimatch[]): boolean { 103 | for (let rule of ignoreRules) { 104 | if (rule.match(relPath)) { 105 | return true 106 | } 107 | } 108 | 109 | return false 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/run-on-save.ts: -------------------------------------------------------------------------------- 1 | import {exec, ChildProcess} from 'child_process' 2 | import * as vscode from 'vscode' 3 | import {CommandProcessor, BackendCommand, TerminalCommand, VSCodeCommand, ProcessedCommand} from './command-processor' 4 | import {FleetingDoubleKeysCache, timeout} from './util' 5 | import {FileIgnoreChecker} from './file-ignore-checker' 6 | import {RawCommand, VSCodeDocument} from './types' 7 | 8 | 9 | export class RunOnSaveExtension { 10 | 11 | private context: vscode.ExtensionContext 12 | private config!: vscode.WorkspaceConfiguration 13 | private channel: vscode.OutputChannel = vscode.window.createOutputChannel('Run on Save') 14 | private commandProcessor: CommandProcessor = new CommandProcessor() 15 | 16 | /** A record of document uris and document versions to save reasons. */ 17 | private documentSaveReasonCache: FleetingDoubleKeysCache 18 | = new FleetingDoubleKeysCache() 19 | 20 | constructor(context: vscode.ExtensionContext) { 21 | this.context = context 22 | this.loadConfig() 23 | this.showEnablingChannelMessage() 24 | 25 | context.subscriptions.push(this.channel) 26 | } 27 | 28 | /** Load or reload configuration. */ 29 | loadConfig() { 30 | this.config = vscode.workspace.getConfiguration('runOnSave') 31 | this.commandProcessor.setRawCommands(this.config.get('commands') || [], this.config.get('defaultRunIn')!) 32 | } 33 | 34 | private showEnablingChannelMessage () { 35 | let message = `Run on Save is ${this.getEnabled() ? 'enabled' : 'disabled'}` 36 | this.showChannelMessage(message) 37 | this.showStatusMessage(message) 38 | } 39 | 40 | private showChannelMessage(message: string) { 41 | this.channel.appendLine(message) 42 | } 43 | 44 | getEnabled(): boolean { 45 | return !!this.context.globalState.get('enabled', true) 46 | } 47 | 48 | setEnabled(enabled: boolean) { 49 | this.context.globalState.update('enabled', enabled) 50 | this.showEnablingChannelMessage() 51 | } 52 | 53 | private showStatusMessage(message: string, timeout?: number) { 54 | timeout = timeout || this.config.get('statusMessageTimeout') || 3000 55 | 56 | let disposable = vscode.window.setStatusBarMessage(message, timeout) 57 | this.context.subscriptions.push(disposable) 58 | } 59 | 60 | /** Returns a promise it was resolved firstly and then will save document. */ 61 | async onWillSaveDocument(document: VSCodeDocument, reason: vscode.TextDocumentSaveReason) { 62 | this.documentSaveReasonCache.set(document.uri.fsPath, document.version, reason) 63 | 64 | if (!this.getEnabled() || await this.shouldIgnore(document.uri, reason)) { 65 | return 66 | } 67 | 68 | let commandsToRun = await this.commandProcessor.prepareCommandsForFileBeforeSaving(document) 69 | if (commandsToRun.length > 0) { 70 | await this.runCommands(commandsToRun, document.uri) 71 | } 72 | } 73 | 74 | async onDocumentSaved(document: VSCodeDocument) { 75 | let reason = this.documentSaveReasonCache.get(document.uri.fsPath, document.version) 76 | 77 | if (!this.getEnabled() || await this.shouldIgnore(document.uri, reason)) { 78 | return 79 | } 80 | 81 | let commandsToRun = await this.commandProcessor.prepareCommandsForFileAfterSaving(document) 82 | if (commandsToRun.length > 0) { 83 | await this.runCommands(commandsToRun, document.uri) 84 | } 85 | } 86 | 87 | private async shouldIgnore(uri: vscode.Uri, reason: vscode.TextDocumentSaveReason | undefined): Promise { 88 | if (reason !== vscode.TextDocumentSaveReason.Manual && this.config.get('onlyRunOnManualSave')) { 89 | return true 90 | } 91 | 92 | let checker = new FileIgnoreChecker({ 93 | workspaceDir: vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath, 94 | ignoreFilesBy: this.config.get('ignoreFilesBy') || [], 95 | }) 96 | 97 | return checker.shouldIgnore(uri.fsPath) 98 | } 99 | 100 | private async runCommands(commands: ProcessedCommand[], uri: vscode.Uri) { 101 | let promises: Promise[] = [] 102 | let syncCommands = commands.filter(c => !c.async) 103 | let asyncCommands = commands.filter(c => c.async) 104 | 105 | // Run commands in a parallel. 106 | for (let command of asyncCommands) { 107 | promises.push(this.runACommand(command, uri)) 108 | } 109 | 110 | // Run commands in series. 111 | for (let command of syncCommands) { 112 | await this.runACommand(command, uri) 113 | } 114 | 115 | await Promise.all(promises) 116 | } 117 | 118 | private runACommand(command: ProcessedCommand, uri: vscode.Uri): Promise { 119 | if (command.clearOutput) { 120 | this.channel.clear() 121 | } 122 | 123 | let runIn = command.runIn || this.config.get('defaultRunIn') || 'backend' 124 | 125 | if (runIn === 'backend') { 126 | return this.runBackendCommand(command as BackendCommand, uri) 127 | } 128 | else if (runIn === 'terminal') { 129 | return this.runTerminalCommand(command as TerminalCommand) 130 | } 131 | else { 132 | return this.runVSCodeCommand(command as VSCodeCommand) 133 | } 134 | } 135 | 136 | private runBackendCommand(command: BackendCommand, uri: vscode.Uri) { 137 | return new Promise((resolve) => { 138 | this.showChannelMessage(`Running "${command.command}"`) 139 | 140 | if (command.runningStatusMessage) { 141 | this.showStatusMessage(command.runningStatusMessage, command.statusMessageTimeout) 142 | } 143 | 144 | let child = this.execShellCommand(command.command, command.workingDirectoryAsCWD ?? true, uri) 145 | child.stdout!.on('data', data => this.channel.append(data.toString())) 146 | child.stderr!.on('data', data => this.channel.append(data.toString())) 147 | 148 | child.on('exit', (e) => { 149 | if (e === 0 && command.finishStatusMessage) { 150 | this.showStatusMessage(command.finishStatusMessage, command.statusMessageTimeout) 151 | } 152 | 153 | if (e !== 0 && !command.doNotDisturb) { 154 | this.channel.show(true) 155 | } 156 | 157 | resolve() 158 | }) 159 | }) as Promise 160 | } 161 | 162 | private execShellCommand(command: string, workingDirectoryAsCWD: boolean, uri: vscode.Uri): ChildProcess { 163 | let cwd: string | undefined 164 | 165 | if (workingDirectoryAsCWD) { 166 | cwd = vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath 167 | ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath 168 | } 169 | 170 | let shell = this.getShellPath() 171 | 172 | return shell ? exec(command, { shell, cwd }) : exec(command, { cwd }) 173 | } 174 | 175 | private getShellPath(): string | undefined { 176 | return this.config.get('shell') || undefined 177 | } 178 | 179 | private async runTerminalCommand(command: TerminalCommand) { 180 | let terminal = this.createTerminal() 181 | 182 | terminal.show() 183 | terminal.sendText(command.command) 184 | 185 | await timeout(100) 186 | await vscode.commands.executeCommand("workbench.action.focusActiveEditorGroup") 187 | 188 | if ((command.terminalHideTimeout || -1) >= 0) { 189 | await timeout(command.terminalHideTimeout!) 190 | terminal.dispose() 191 | } 192 | } 193 | 194 | private createTerminal(): vscode.Terminal { 195 | let terminalName = 'Run on Save' 196 | let terminal = vscode.window.terminals.find(terminal => terminal.name === terminalName) 197 | 198 | if (!terminal) { 199 | this.context.subscriptions.push(terminal = vscode.window.createTerminal(terminalName, this.getShellPath())) 200 | } 201 | 202 | return terminal 203 | } 204 | 205 | private async runVSCodeCommand(command: VSCodeCommand) { 206 | // `finishStatusMessage` has to be hooked to exit of command execution. 207 | this.showChannelMessage(`Running "${command.command}"`) 208 | 209 | let args = this.formatVSCodeCommandArgs(command.args) 210 | await vscode.commands.executeCommand(command.command, ...args) 211 | } 212 | 213 | private formatVSCodeCommandArgs(args: string | object | string[] | undefined): any[] { 214 | if (Array.isArray(args)) { 215 | return args 216 | } 217 | 218 | if (['string', 'object'].includes(typeof args)) { 219 | return [args] 220 | } 221 | 222 | return [] 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | 4 | export interface Configuration { 5 | statusMessageTimeout: number 6 | ignoreFilesBy: string[] 7 | shell: String 8 | commands: RawCommand 9 | defaultRunIn: 'backend' | 'terminal' | 'vscode' 10 | } 11 | 12 | /** Raw command configured by user. */ 13 | export interface RawCommand { 14 | languages?: string[] 15 | match?: string 16 | notMatch?: string 17 | globMatch?: string 18 | commandBeforeSaving?: string 19 | command?: string 20 | args?: string[] | object | string 21 | forcePathSeparator?: PathSeparator 22 | runIn?: string 23 | runningStatusMessage?: string 24 | finishStatusMessage?: string 25 | async?: boolean 26 | clearOutput?: boolean 27 | doNotDisturb?: boolean 28 | } 29 | 30 | export type PathSeparator = '/' | '\\' 31 | 32 | export type VSCodeDocument = vscode.TextDocument | vscode.NotebookDocument 33 | 34 | export type VSCodeDocumentPartial = Pick & Partial> 35 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /** Format command string, quote piece part which includes white spaces. */ 2 | export async function formatCommandPieces(command: string, formatPiece: (piece: string) => Promise): Promise { 3 | if (!command) { 4 | return '' 5 | } 6 | 7 | // If white spaces exist in file name or directory name, we need to wrap them with `""`. 8 | // We do this by testing each pieces, and wrap them if needed. 9 | return replaceAsync(command, /\S+/g, async (piece: string) => { 10 | let oldPiece = piece 11 | let alreadyQuoted = false 12 | 13 | if (piece[0] === '"' && piece[piece.length - 1] === '"') { 14 | piece = decodeQuotedCommandLine(piece.slice(1, -1)) 15 | alreadyQuoted = true 16 | } 17 | 18 | // May need to be quoted after piece formatted. 19 | piece = await formatPiece(piece) 20 | 21 | // If piece includes spaces or `\\`, or be quoted before, then it must be encoded. 22 | if (piece !== oldPiece && /[\s"]|\\\\/.test(piece) || alreadyQuoted) { 23 | piece = '"' + encodeCommandLineToBeQuoted(piece) + '"' 24 | } 25 | 26 | return piece 27 | }) 28 | } 29 | 30 | /** Encode `"` to `\"`. */ 31 | export function encodeCommandLineToBeQuoted(command: string) { 32 | return command.replace(/["]/g, '\\$&') 33 | } 34 | 35 | /** If piece includes spaces, `\\`, or is quoted, then it must be encoded. */ 36 | export function encodeCommandLineToBeQuotedIf(arg: string) { 37 | if (/[\s"]|\\\\/.test(arg)) { 38 | arg = '"' + encodeCommandLineToBeQuoted(arg) + '"' 39 | } 40 | 41 | return arg 42 | } 43 | 44 | /** Decode `\"` to `"`. */ 45 | export function decodeQuotedCommandLine(command: string) { 46 | return command.replace(/\\(.)/g, '$1') 47 | } 48 | 49 | 50 | /** Replace path separators. */ 51 | export function formatPathSeparator(path: string, pathSeparator: string | undefined) { 52 | return pathSeparator ? path.replace(/[\\|\/]/g, pathSeparator) : path 53 | } 54 | 55 | 56 | /** Resolves the returned promise after `ms` millseconds. */ 57 | export function timeout(ms: number): Promise { 58 | return new Promise(resolve => setTimeout(resolve, ms)) 59 | } 60 | 61 | 62 | /** Do RegExp replacing asynchronously. */ 63 | export async function replaceAsync(str: string, re: RegExp, replacer: (...matches: string[]) => Promise): Promise { 64 | let replacements = await Promise.all( 65 | Array.from(str.matchAll(re), 66 | match => replacer(...match)) 67 | ) 68 | 69 | let i = 0 70 | return str.replace(re, () => replacements[i++]) 71 | } 72 | 73 | 74 | /** All cache values exist for more than 3 seconds, but less than 6 seconds. */ 75 | export class FleetingDoubleKeysCache { 76 | 77 | private timeoutMs: number 78 | private mapCurr: DoubleKeysMap = new DoubleKeysMap() 79 | private mapBack: DoubleKeysMap = new DoubleKeysMap() 80 | private timeout: any | null = null 81 | 82 | constructor(timeoutMs: number = 3000) { 83 | this.timeoutMs = timeoutMs 84 | } 85 | 86 | get(k1: K1, k2: K2): V | undefined { 87 | return this.mapCurr.get(k1, k2) 88 | ?? this.mapBack.get(k1, k2) 89 | } 90 | 91 | set(k1: K1, k2: K2, v: V) { 92 | this.mapCurr.set(k1, k2, v) 93 | this.setSwapTimeout() 94 | } 95 | 96 | firstKeyCount(): number { 97 | return this.mapCurr.firstKeyCount() + this.mapBack.firstKeyCount() 98 | } 99 | 100 | clear() { 101 | this.mapCurr.clear() 102 | this.mapBack.clear() 103 | } 104 | 105 | private setSwapTimeout() { 106 | if (this.timeout === null) { 107 | this.timeout = setTimeout(this.onSwapTimeout.bind(this), this.timeoutMs) 108 | } 109 | } 110 | 111 | private onSwapTimeout() { 112 | [this.mapCurr, this.mapBack] = [this.mapBack, this.mapCurr] 113 | this.mapCurr.clear() 114 | this.timeout = null 115 | 116 | // Need to swap a more time if has any values cached. 117 | if (this.firstKeyCount() > 0) { 118 | this.setSwapTimeout() 119 | } 120 | } 121 | } 122 | 123 | 124 | /** 125 | * `K1 -> K2 -> V` Map Struct. 126 | * Index each value by a pair of keys. 127 | */ 128 | class DoubleKeysMap { 129 | 130 | private map: Map> = new Map() 131 | 132 | /** Has associated value by key pair. */ 133 | has(k1: K1, k2: K2): boolean { 134 | let sub = this.map.get(k1) 135 | if (!sub) { 136 | return false 137 | } 138 | 139 | return sub.has(k2) 140 | } 141 | 142 | /** Get the count of all the first keys. */ 143 | firstKeyCount(): number { 144 | return this.map.size 145 | } 146 | 147 | /** Get associated value by key pair. */ 148 | get(k1: K1, k2: K2): V | undefined { 149 | let sub = this.map.get(k1) 150 | if (!sub) { 151 | return undefined 152 | } 153 | 154 | return sub.get(k2) 155 | } 156 | 157 | /** Set key pair and associated value. */ 158 | set(k1: K1, k2: K2, v: V) { 159 | let sub = this.map.get(k1) 160 | if (!sub) { 161 | sub = new Map() 162 | this.map.set(k1, sub) 163 | } 164 | 165 | sub.set(k2, v) 166 | } 167 | 168 | /** Delete all the associated values by key pair. */ 169 | delete(k1: K1, k2: K2) { 170 | let sub = this.map.get(k1) 171 | if (sub) { 172 | sub.delete(k2) 173 | 174 | if (sub.size === 0) { 175 | this.map.delete(k1) 176 | } 177 | } 178 | } 179 | 180 | /** Clear all the data. */ 181 | clear() { 182 | this.map = new Map() 183 | } 184 | } -------------------------------------------------------------------------------- /test/.vscode-test.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@vscode/test-cli'; 2 | 3 | export default defineConfig({ 4 | files: 'out/**/*.test.js', 5 | }); -------------------------------------------------------------------------------- /test/fixture/.gitignore: -------------------------------------------------------------------------------- 1 | should-ignore/ 2 | should-ignore.css -------------------------------------------------------------------------------- /test/src/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as path from 'path' 3 | import * as vscode from 'vscode' 4 | import {CommandProcessor} from '../../out/command-processor' 5 | import {FileIgnoreChecker} from '../../out/file-ignore-checker' 6 | import {FleetingDoubleKeysCache} from '../../out/util' 7 | import {RawCommand, VSCodeDocumentPartial} from '../../out/types' 8 | 9 | 10 | suite("Extension Tests", () => { 11 | suite('test backend command', function () { 12 | let manager = new CommandProcessor() 13 | manager.setRawCommands([{ 14 | 'match': '.*\\.scss$', 15 | 'notMatch': '[\\\\\\/]_[^\\\\\\/]*\\.scss$', 16 | 'runIn': 'backend', 17 | 'command': 'node-sass ${file} ${fileDirname}/${fileBasenameNoExtension}.css', 18 | 'runningStatusMessage': 'Compiling ${fileBasename}', 19 | 'finishStatusMessage': '${fileBasename} compiled', 20 | 'forcePathSeparator': '/', 21 | }], 'backend') 22 | 23 | test('will compile scss file in backend', async function () { 24 | let doc: VSCodeDocumentPartial = { 25 | uri: vscode.Uri.file('C:/folderName/fileName.scss') 26 | } 27 | 28 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 29 | assert.deepStrictEqual(commands, [{ 30 | 'runIn': 'backend', 31 | 'command': 'node-sass c:/folderName/fileName.scss c:/folderName/fileName.css', 32 | 'runningStatusMessage': 'Compiling fileName.scss', 33 | 'finishStatusMessage': 'fileName.scss compiled', 34 | 'async': true, 35 | 'clearOutput': false, 36 | 'doNotDisturb': false, 37 | }]) 38 | }) 39 | 40 | test('will exclude scss file that file name starts with "_"', async function () { 41 | let doc: VSCodeDocumentPartial = { 42 | uri: vscode.Uri.file('C:/folderName/_fileName.scss') 43 | } 44 | 45 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 46 | assert.deepStrictEqual(commands, []) 47 | }) 48 | 49 | test('will escape white spaces', async function () { 50 | let doc: VSCodeDocumentPartial = { 51 | uri: vscode.Uri.file('C:/folderName/fileName 1.scss') 52 | } 53 | 54 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 55 | assert.deepStrictEqual( 56 | commands[0].command, 57 | 'node-sass "c:/folderName/fileName 1.scss" "c:/folderName/fileName 1.css"' 58 | ) 59 | }) 60 | }) 61 | 62 | 63 | suite('test globMatch', function () { 64 | let manager = new CommandProcessor() 65 | manager.setRawCommands([{ 66 | 'globMatch': '**/*.scss', 67 | 'runIn': 'backend', 68 | 'command': 'node-sass ${file} ${fileDirname}/${fileBasenameNoExtension}.css', 69 | 'runningStatusMessage': 'Compiling ${fileBasename}', 70 | 'finishStatusMessage': '${fileBasename} compiled', 71 | 'async': true, 72 | 'forcePathSeparator': '/', 73 | }], 'backend') 74 | 75 | test('will compile scss file in backend', async function () { 76 | let doc: VSCodeDocumentPartial = { 77 | uri: vscode.Uri.file('C:/folderName/fileName.scss') 78 | } 79 | 80 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 81 | assert.deepStrictEqual(commands, [{ 82 | 'runIn': 'backend', 83 | 'command': 'node-sass c:/folderName/fileName.scss c:/folderName/fileName.css', 84 | 'runningStatusMessage': 'Compiling fileName.scss', 85 | 'finishStatusMessage': 'fileName.scss compiled', 86 | 'async': true, 87 | 'clearOutput': false, 88 | 'doNotDisturb': false, 89 | }]) 90 | }) 91 | }) 92 | 93 | 94 | suite('test commandBeforeSaving', function () { 95 | let manager = new CommandProcessor() 96 | manager.setRawCommands([{ 97 | 'globMatch': '**/*.scss', 98 | 'runIn': 'backend', 99 | 'commandBeforeSaving': 'node-sass ${file} ${fileDirname}/${fileBasenameNoExtension}.css', 100 | 'runningStatusMessage': 'Compiling ${fileBasename}', 101 | 'finishStatusMessage': '${fileBasename} compiled', 102 | 'async': true, 103 | 'forcePathSeparator': '/', 104 | }], 'backend') 105 | 106 | test('will compile scss file in backend', async function () { 107 | let doc: VSCodeDocumentPartial = { 108 | uri: vscode.Uri.file('C:/folderName/fileName.scss') 109 | } 110 | 111 | let commands = await manager.prepareCommandsForFileBeforeSaving(doc) 112 | assert.deepStrictEqual(commands, [{ 113 | 'runIn': 'backend', 114 | 'command': 'node-sass c:/folderName/fileName.scss c:/folderName/fileName.css', 115 | 'runningStatusMessage': 'Compiling fileName.scss', 116 | 'finishStatusMessage': 'fileName.scss compiled', 117 | 'async': true, 118 | 'clearOutput': false, 119 | 'doNotDisturb': false, 120 | }]) 121 | }) 122 | }) 123 | 124 | 125 | suite('test backend command with back slash path', function () { 126 | let manager = new CommandProcessor() 127 | manager.setRawCommands([{ 128 | 'match': '.*\\.scss$', 129 | 'notMatch': '[\\\\\\/]_[^\\\\\\/]*\\.scss$', 130 | 'runIn': 'backend', 131 | 'command': 'node-sass ${file} ${fileDirname}\\${fileBasenameNoExtension}.css', 132 | 'runningStatusMessage': 'Compiling ${fileBasename}', 133 | 'finishStatusMessage': '${fileBasename} compiled', 134 | }], 'backend') 135 | 136 | test('will escape paths starts with "\\\\"', async function () { 137 | let doc: VSCodeDocumentPartial = { 138 | uri: vscode.Uri.file('\\\\folderName\\fileName 1.scss') 139 | } 140 | 141 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 142 | assert.deepStrictEqual( 143 | commands[0].command, 144 | 'node-sass "\\\\folderName\\fileName 1.scss" "\\\\folderName\\fileName 1.css"' 145 | ) 146 | }) 147 | }) 148 | 149 | 150 | suite('test terminal command', function () { 151 | let manager = new CommandProcessor() 152 | manager.setRawCommands([{ 153 | 'match': '.*\\.scss$', 154 | 'notMatch': '[\\\\\\/]_[^\\\\\\/]*\\.scss$', 155 | 'runIn': 'terminal', 156 | 'command': 'node-sass ${file} ${fileDirname}/${fileBasenameNoExtension}.css', 157 | 'forcePathSeparator': '/', 158 | }], 'backend') 159 | 160 | test('will compile scss file in terminal', async function () { 161 | let doc: VSCodeDocumentPartial = { 162 | uri: vscode.Uri.file('C:/folderName/fileName.scss') 163 | } 164 | 165 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 166 | assert.deepStrictEqual(commands, [{ 167 | 'runIn': 'terminal', 168 | 'command': 'node-sass c:/folderName/fileName.scss c:/folderName/fileName.css', 169 | 'async': true, 170 | 'clearOutput': false, 171 | }]) 172 | }) 173 | }) 174 | 175 | 176 | suite('test for #20', function () { 177 | let manager = new CommandProcessor() 178 | manager.setRawCommands([{ 179 | "match": ".*\\.drawio$", 180 | "runIn": "backend", 181 | "command": "draw.io --crop --export -f pdf \"${file}\"", 182 | 'forcePathSeparator': '/', 183 | }], 'backend') 184 | 185 | test('will compile it right', async function () { 186 | let doc: VSCodeDocumentPartial = { 187 | uri: vscode.Uri.file('C:/test.drawio') 188 | } 189 | 190 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 191 | assert.deepStrictEqual(commands, [{ 192 | 'runIn': 'backend', 193 | 'command': 'draw.io --crop --export -f pdf "c:/test.drawio"', 194 | 'finishStatusMessage': '', 195 | 'runningStatusMessage': '', 196 | 'async': true, 197 | 'clearOutput': false, 198 | 'doNotDisturb': false, 199 | }]) 200 | }) 201 | }) 202 | 203 | 204 | suite('test #47, supports `commands[].languages`', function () { 205 | let manager = new CommandProcessor() 206 | manager.setRawCommands([{ 207 | "languages": ["typescript"], 208 | "runIn": "backend", 209 | "command": "anyCommandsToRun", 210 | }], 'backend') 211 | 212 | test('will compile it right', async function () { 213 | let doc: VSCodeDocumentPartial = { 214 | uri: vscode.Uri.file('C:/anyFileName'), 215 | languageId: 'typescript', 216 | } 217 | 218 | let commands = await manager.prepareCommandsForFileAfterSaving(doc) 219 | assert.deepStrictEqual(commands, [{ 220 | 'runIn': 'backend', 221 | 'command': 'anyCommandsToRun', 222 | 'async': true, 223 | 'clearOutput': false, 224 | 'doNotDisturb': false, 225 | "finishStatusMessage": "", 226 | "runningStatusMessage": "" 227 | }]) 228 | }) 229 | }) 230 | 231 | 232 | suite('test for #24, should ignore files follow ".gitignore"', function () { 233 | let checker = new FileIgnoreChecker({ 234 | workspaceDir: path.resolve(__dirname, '../../'), 235 | ignoreFilesBy: ['.gitignore'], 236 | }) 237 | 238 | test('will ignore file 1', async () => { 239 | assert.ok(await checker.shouldIgnore(path.resolve(__dirname, '../fixture/should-ignore/test.css'))) 240 | }) 241 | 242 | test('will ignore file 2', async () => { 243 | assert.ok(await checker.shouldIgnore(path.resolve(__dirname, '../fixture/should-ignore.css'))) 244 | }) 245 | 246 | test('will not ignore file 3', async () => { 247 | assert.ok(!await checker.shouldIgnore(path.resolve(__dirname, 'index.ts'))) 248 | }) 249 | }) 250 | 251 | 252 | suite('test for #40, class FleetingDoubleKeysCache', function () { 253 | let cache = new FleetingDoubleKeysCache(100) 254 | 255 | test('will cache item', async () => { 256 | cache.set('a', 1, 'value') 257 | assert.equal(cache.get('a', 1), 'value') 258 | cache.clear() 259 | }) 260 | 261 | test('will cache item for a while', async () => { 262 | cache.set('a', 1, 'value') 263 | await new Promise(resolve => setTimeout(resolve, 100)) 264 | assert.equal(cache.get('a', 1), 'value') 265 | await new Promise(resolve => setTimeout(resolve, 50)) 266 | assert.equal(cache.get('a', 1), 'value') 267 | cache.clear() 268 | }) 269 | 270 | test('will clear after enough time', async () => { 271 | cache.set('a', 1, 'value') 272 | await new Promise(resolve => setTimeout(resolve, 250)) 273 | assert.equal(cache.get('a', 1), undefined) 274 | cache.clear() 275 | }) 276 | }) 277 | }) -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "outDir": "out", 6 | "lib": [ 7 | "es2018" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": true, 17 | "noImplicitThis": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "outDir": "out", 6 | "lib": [ 7 | "es2018" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": true, 17 | "noImplicitThis": true, 18 | "declaration": true, 19 | "composite": true 20 | }, 21 | "exclude": [ 22 | "node_modules", 23 | ".vscode-test", 24 | "out", 25 | "test" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------