├── .gitignore ├── test ├── fixtures │ └── ws │ │ ├── ok.md │ │ ├── bad.md │ │ └── .vscode │ │ └── settings.json ├── runTest.ts └── suite │ ├── index.ts │ └── extension.test.ts ├── tsconfig.json ├── .vscodeignore ├── .vscode ├── settings.json ├── launch.json └── tasks.json ├── CHANGELOG.md ├── src ├── linter.ts └── extension.ts ├── LICENSE.txt ├── eslint.config.mjs ├── .github └── workflows │ └── node.js.yml ├── CONTRIBUTING.md ├── AGENTS.md ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | write-good-linter-*.vsix 4 | .DS_Store 5 | test.md 6 | .vscode-test 7 | -------------------------------------------------------------------------------- /test/fixtures/ws/ok.md: -------------------------------------------------------------------------------- 1 | This text is fine. 2 | 3 | 4 | So this is very actually really just a test. 5 | So this is very actually really just a test. -------------------------------------------------------------------------------- /test/fixtures/ws/bad.md: -------------------------------------------------------------------------------- 1 | So this is very actually really just a simple piece of text. 2 | There are many many problems and it obviously clearly says nothing. 3 | 4 | -------------------------------------------------------------------------------- /test/fixtures/ws/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "write-good.debounce-time-in-ms": 0, 3 | "write-good.languages": "*", 4 | "write-good.only-lint-on-save": false 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ "es6", "dom" ], 7 | "sourceMap": true, 8 | "rootDir": "." 9 | }, 10 | "exclude": [ 11 | "node_modules", 12 | ".vscode-test" 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | typings/** 3 | out/test/** 4 | test/** 5 | src/** 6 | **/*.map 7 | .gitignore 8 | tsconfig.json 9 | vsc-extension-quickstart.md 10 | write-good-linter-*.vsix 11 | .vscode-test/** 12 | node_modules/**/test/** 13 | node_modules/**/spec/** 14 | node_modules/**/.travis.yml 15 | node_modules/**/.github/** 16 | -------------------------------------------------------------------------------- /.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 | "typescript.tsdk": "./node_modules/typescript/lib" // we want to use the TS server from our node_modules folder to control its version 10 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 0.1.7 — 2025-08-24 6 | - Added end-to-end behavioral tests using `@vscode/test-electron` + Mocha/Chai under `test/`. 7 | - New npm scripts: `test`, `test:gui`, and `test:ci`; CI runs headless tests. 8 | - CI now verifies VSIX contents (`vsce ls`) and builds the package (`vsce package`). 9 | - Documentation: added `AGENTS.md` and `CONTRIBUTING.md`; updated `README.md` with Testing section. 10 | - Packaging: updated `.vscodeignore` to exclude `test/**`, `out/test/**`, `.vscode-test/**`, and dependency `test/spec` files. 11 | - Repo hygiene: added `.vscode-test` to `.gitignore`; removed unused `value-check.js`. 12 | - Dependency updates: bumped ESLint/TypeScript toolchain, `@types/vscode`, Mocha/Chai, etc. 13 | 14 | -------------------------------------------------------------------------------- /test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { runTests } from '@vscode/test-electron'; 3 | 4 | async function main() { 5 | try { 6 | const extensionDevelopmentPath = path.resolve(__dirname, '..', '..'); 7 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 8 | // Use source workspace fixtures, not compiled output path 9 | const workspacePath = path.resolve(extensionDevelopmentPath, 'test', 'fixtures', 'ws'); 10 | 11 | await runTests({ 12 | extensionDevelopmentPath, 13 | extensionTestsPath, 14 | launchArgs: [workspacePath], 15 | version: undefined, 16 | }); 17 | } catch (err) { 18 | // eslint-disable-next-line no-console 19 | console.error('Failed to run tests', err); 20 | process.exit(1); 21 | } 22 | } 23 | 24 | main(); 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceRoot}" ], 11 | "stopOnEntry": false, 12 | "sourceMaps": true, 13 | "outDir": "${workspaceRoot}/out/src", 14 | "preLaunchTask": "npm" 15 | }, 16 | { 17 | "name": "Launch Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "runtimeExecutable": "${execPath}", 21 | "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], 22 | "stopOnEntry": false, 23 | "sourceMaps": true, 24 | "outDir": "${workspaceRoot}/out/test", 25 | "preLaunchTask": "npm" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/linter.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, DiagnosticSeverity, Range, Position } from 'vscode'; 2 | import * as WriteGood from 'write-good'; 3 | 4 | interface Suggestion { 5 | index: number, 6 | offset: number, 7 | reason: string 8 | } 9 | 10 | export function lintText(content: string, wgConfig: object, startingLine: number = 0, diagnostics: Diagnostic[] = []) { 11 | if (content == null) return; 12 | const lines = content.split(/\r?\n/g); 13 | lines.forEach((line, lineCount) => { 14 | const suggestions : Suggestion[] = WriteGood(line, wgConfig); 15 | suggestions.forEach((suggestion) => { 16 | const start = new Position(lineCount + startingLine, suggestion.index); 17 | const end = new Position(lineCount + startingLine, suggestion.index + suggestion.offset); 18 | diagnostics.push(new Diagnostic(new Range(start, end), suggestion.reason, DiagnosticSeverity.Warning)); 19 | }); 20 | }); 21 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016-2025 Travis Smith 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as Mocha from 'mocha'; 2 | import { globSync } from 'glob'; 3 | import * as path from 'node:path'; 4 | 5 | export function run(): Promise { 6 | const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 20000, reporter: 'spec' }); 7 | const testsRoot = __dirname; 8 | 9 | return new Promise((resolve, reject) => { 10 | try { 11 | const files = globSync('**/*.test.js', { cwd: testsRoot }); 12 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 13 | const runner = mocha.run(failures => { 14 | if (failures > 0) { 15 | reject(new Error(`${failures} test(s) failed.`)); 16 | } else { 17 | resolve(); 18 | } 19 | }); 20 | runner.on('fail', (test, err) => { 21 | // eslint-disable-next-line no-console 22 | console.error(`Test failed: ${test.fullTitle()}`); 23 | // eslint-disable-next-line no-console 24 | console.error(err && (err.stack || err.message || err)); 25 | }); 26 | } catch (e) { 27 | reject(e); 28 | } 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import js from "@eslint/js"; 6 | import { FlatCompat } from "@eslint/eslintrc"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all 14 | }); 15 | 16 | export default [ 17 | ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), 18 | { 19 | plugins: { 20 | "@typescript-eslint": typescriptEslint, 21 | }, 22 | 23 | languageOptions: { 24 | parser: tsParser, 25 | }, 26 | 27 | rules: { 28 | semi: [2, "always"], 29 | "@typescript-eslint/no-unused-vars": 0, 30 | "@typescript-eslint/no-explicit-any": 0, 31 | "@typescript-eslint/explicit-module-boundary-types": 0, 32 | "@typescript-eslint/no-non-null-assertion": 0, 33 | }, 34 | }, 35 | ]; -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | // A task runner that calls a custom npm script that compiles the extension. 10 | { 11 | "version": "2.0.0", 12 | 13 | // we want to run npm 14 | "command": "npm", 15 | 16 | // we run the custom script "compile" as defined in package.json 17 | "args": ["run", "compile", "--loglevel", "silent"], 18 | 19 | // The tsc compiler is started in watching mode 20 | "isBackground": true, 21 | 22 | // use the standard tsc in watch mode problem matcher to find compile problems in the output. 23 | "problemMatcher": "$tsc-watch", 24 | "tasks": [ 25 | { 26 | "label": "npm", 27 | "type": "shell", 28 | "command": "npm", 29 | "args": [ 30 | "run", 31 | "compile", 32 | "--loglevel", 33 | "silent" 34 | ], 35 | "isBackground": true, 36 | "problemMatcher": "$tsc-watch", 37 | "group": { 38 | "_id": "build", 39 | "isDefault": false 40 | } 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | pull_request: 12 | branches: [ "master" ] 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [22.x] 22 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'npm' 31 | - run: npm ci 32 | - run: npm run compile 33 | - run: npm run lint 34 | - name: Run tests (headless) 35 | shell: bash 36 | run: | 37 | if [ "$RUNNER_OS" = "Linux" ]; then 38 | xvfb-run -a npm run test:ci 39 | else 40 | npm run test:ci 41 | fi 42 | - name: Verify VSIX file list 43 | run: npx --yes @vscode/vsce ls 44 | - name: Package extension (verification only) 45 | run: npx --yes @vscode/vsce package --out write-good-linter.vsix 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for helping improve Write Good Linter! This guide is a quick start. For full contributor guidelines, see Repository Guidelines. 4 | 5 | - Read: [Repository Guidelines](./AGENTS.md) 6 | - Publishing steps: [README – Publishing](./README.md#publishing) 7 | 8 | ## Quick Setup 9 | 1. Fork and clone your fork. 10 | 2. Install Node.js (LTS or 22.x) and run: 11 | ```bash 12 | npm ci 13 | npm run compile # or: npm run watch 14 | ``` 15 | 3. Open the folder in VS Code and press F5 to launch the Extension Development Host. Open a Markdown file to see diagnostics. 16 | 17 | ## Before You Open a PR 18 | - Ensure code is in `src/` only; do not edit `out/`. 19 | - Run formatting/linting and build: 20 | ```bash 21 | npm run lint 22 | npm run compile 23 | ``` 24 | - Run tests and ensure all specs pass: 25 | ```bash 26 | npm test # headless 27 | # or for visual debugging 28 | npm run test:gui 29 | ``` 30 | - Manually verify behavior in the Dev Host (F5), including settings like `write-good.only-lint-on-save` and `write-good.debounce-time-in-ms`. 31 | 32 | ## Pull Requests 33 | - Use concise, imperative commit messages (e.g., "fix: debounce scheduling"). 34 | - PR description should include: purpose, approach, user-facing changes (settings/diagnostics), and screenshots/GIFs if behavior changes. 35 | - Link related issues (e.g., "Fixes #123"). 36 | - CI must pass (install, compile, lint). 37 | 38 | ## Issues 39 | - When filing a bug, include VS Code version, OS, sample text that reproduces the warning, relevant settings, and logs from the Extension Host if applicable. 40 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | - `src/`: TypeScript sources. `extension.ts` wires activation, diagnostics, and settings; `linter.ts` wraps the `write-good` engine. 5 | - `out/`: Compiled JavaScript. Generated by `tsc`; do not edit. 6 | - Config: `package.json` (VS Code contributions, scripts), `tsconfig.json`, `eslint.config.mjs`. 7 | - CI: `.github/workflows/node.js.yml` runs install, compile, and lint on pushes/PRs. 8 | - Workspace: `.vscode/` contains local launch/debug config for Extension Development Host. 9 | 10 | ## Build, Test, and Development Commands 11 | - `npm ci`: Clean install dependencies (used in CI). 12 | - `npm run compile`: Build TypeScript to `out/`. 13 | - `npm run watch`: Incremental rebuild during development. 14 | - `npm run lint`: Lint `src/**/*.ts` via ESLint. 15 | - Run locally: Open in VS Code and press F5 to launch the Extension Development Host; open a Markdown file to see diagnostics. 16 | - Publish (maintainers): `vsce publish` after `npm run compile` and `npm run lint`. 17 | 18 | ## Coding Style & Naming Conventions 19 | - Language: TypeScript. Indent 4 spaces; end statements with semicolons. 20 | - Names: `camelCase` for variables/functions, `PascalCase` for types/interfaces, short lowercase filenames (e.g., `extension.ts`). 21 | - Linting: ESLint with `@typescript-eslint`. Enforced rules include `semi: always`; some strict TS rules are relaxed. Run `npm run lint` and fix warnings. 22 | 23 | ## Testing Guidelines 24 | - End-to-end tests: `@vscode/test-electron` + Mocha/Chai. 25 | - Structure: `test/runTest.ts` (boot), `test/suite/*.test.ts` (specs), fixtures under `test/fixtures/ws/`. 26 | - Commands: `npm test` (headless by default), `npm run test:gui` (shows Dev Host), `npm run test:ci` (forces headless; used in CI). 27 | - Scenarios: diagnostics for Markdown, language filtering, "only lint on save", and debounce behavior. 28 | - Tips: set `write-good.debounce-time-in-ms` to `0` for deterministic checks unless testing debounce; create temp files in tests to avoid cross-test state. 29 | 30 | ## Commit & Pull Request Guidelines 31 | - Commits: Imperative, concise subject (≤72 chars). Example: `lint: fix debounce handling in onDidChange`. 32 | - Reference issues/PRs when relevant (e.g., `Fixes #123`). 33 | - PRs must: describe intent and approach, list user-facing changes (settings/diagnostics), include before/after screenshots or GIFs when behavior changes, and pass CI (install, compile, lint). 34 | 35 | ## Configuration Tips 36 | - Key settings forwarded to `write-good`: 37 | - Example `settings.json` snippet: 38 | ```json 39 | { 40 | "write-good.languages": ["markdown", "plaintext"], 41 | "write-good.only-lint-on-save": false, 42 | "write-good.debounce-time-in-ms": 200, 43 | "write-good.write-good-config": { "eprime": true } 44 | } 45 | ``` 46 | - Avoid editing `out/`; make changes in `src/` and rebuild. 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Write Good Linter for Visual Studio Code 2 | 3 | Provides a [write-good](https://github.com/btford/write-good) linter extension for [Visual Studio Code](https://code.visualstudio.com/). 4 | 5 | ## Installation 6 | 7 | Press F1 or CTRL+P (or CMD+P) and type out `> ext install travisthetechie.write-good-linter`. Check out the latest published version on the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=travisthetechie.write-good-linter). 8 | 9 | ## Settings 10 | 11 | `write-good.languages` defaults to `["markdown", "plaintext"]`, but it can be overridden to something like `["markdown"]` if you would like linting to apply to other filetypes. 12 | 13 | `write-good.write-good-config` is a direct pass through to the underlying [write-good](https://github.com/btford/write-good) engine. To enable eprime check and disable check for so at the start of sentance, add `"write-good.write-good-config": { "eprime": true, "so": false }` to your settings. 14 | 15 | `write-good.only-lint-on-save` disables linting during editing for large files. A save triggers linting. 16 | 17 | `write-good.debounce-time-in-ms` is the minimum time, in milliseconds, that must wait between linting attempts. Saving ignores the minimum time. Default is 200ms. This is useful if linting causes any performance hit and you want to limit it. 18 | 19 | ## License and acknowledgements 20 | 21 | This is licensed under the MIT open source license. Do what you want with this software, just include notice that it orginated with me. 22 | 23 | The heavy lifting of this extension is done via [Brian Ford](https://twitter.com/briantford)'s [write-good](https://www.npmjs.com/package/write-good) npm module. 24 | 25 | ## Working on this project 26 | 27 | Install Node.js & `npm install` in the project. 28 | 29 | Open up the project in Visual Studio Code and hit F5 to open up a *developement host* version of Visual Studio Code with the extension installed. Open up a Markdown file and write some bad prose to see linter in action. 30 | 31 | Check out the [Extending Visual Studio Code](https://code.visualstudio.com/Docs/extensions/overview) documentation for more information. 32 | 33 | ## Testing 34 | 35 | - Install and run: `npm ci` then `npm test`. The first run downloads a VS Code build for testing. 36 | - GUI vs headless: 37 | - Local GUI: `npm run test:gui` opens an Extension Development Host for visual debugging. 38 | - Headless CI: `npm run test:ci` forces headless mode (used in GitHub Actions). 39 | - Layout: tests live in `test/suite/*.test.ts`; fixtures in `test/fixtures/ws`. The runner is `test/runTest.ts` (uses `@vscode/test-electron`). 40 | - Covered scenarios: diagnostics on bad Markdown, language filtering, only‑lint‑on‑save, and debounce behavior. 41 | - Tips: if timing is flaky locally, re‑run `npm test` or use GUI mode to observe behavior. Tests should not modify `out/`. 42 | 43 | ## Publishing 44 | 45 | 1. `npm install -g vsce` 46 | 1. Visit https://travisthetechie.visualstudio.com/_details/security/tokens for a token (all accounts, all scopes) 47 | 1. `vsce login travisthetechie` 48 | 1. `vsce publish` 49 | 50 | ## Thank you to contributors 51 | 52 | Thank you to [James Ruskin](https://github.com/JPRuskin) for enabling settings. [PR4](https://github.com/TravisTheTechie/vscode-write-good/pull/4) 53 | 54 | Thank you to [Freed-Wu](https://github.com/Freed-Wu) for typo fixes in configuration. [PR22](https://github.com/TravisTheTechie/vscode-write-good/pull/22) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "write-good-linter", 3 | "displayName": "Write Good Linter", 4 | "description": "Applies the Write Good Linter to your Markdown, so you can write more good.", 5 | "version": "0.1.7", 6 | "publisher": "travisthetechie", 7 | "engines": { 8 | "vscode": "^1.95.0" 9 | }, 10 | "categories": [ 11 | "Linters" 12 | ], 13 | "activationEvents": [ 14 | "onStartupFinished" 15 | ], 16 | "main": "./out/src/extension", 17 | "contributes": { 18 | "configuration": { 19 | "type": "object", 20 | "title": "write-good", 21 | "properties": { 22 | "write-good.debounce-time-in-ms": { 23 | "type": [ 24 | "null", 25 | "number" 26 | ], 27 | "default": 200, 28 | "markdownDescription": "Minimum milliseconds between linting attempts. Default is 200ms, increasing this can reduce load when working with large files. Linting on save and load are not rate limited.", 29 | "scope": "resource" 30 | }, 31 | "write-good.only-lint-on-save": { 32 | "type": [ 33 | "null", 34 | "boolean" 35 | ], 36 | "default": false, 37 | "markdownDescription": "Disables linting during editing for large files. A save triggers linting.", 38 | "scope": "resource" 39 | }, 40 | "write-good.write-good-config": { 41 | "type": [ 42 | "object", 43 | "null" 44 | ], 45 | "default": null, 46 | "markdownDescription": "Configuration passed to [write-good](https://www.npmjs.com/package/write-good). Example to enable eprime check is `\"write-good.write-good-config\": { \"eprime\": true }`.", 47 | "scope": "resource" 48 | }, 49 | "write-good.languages": { 50 | "default": [ 51 | "markdown", 52 | "plaintext" 53 | ], 54 | "type": [ 55 | "string", 56 | "array" 57 | ], 58 | "items": { 59 | "type": "string" 60 | }, 61 | "description": "Languages to lint with the write-good linter. '*' to enable on all languages.", 62 | "scope": "resource" 63 | } 64 | } 65 | } 66 | }, 67 | "scripts": { 68 | "vscode:prepublish": "npm run compile", 69 | "compile": "tsc -p ./", 70 | "lint": "eslint \"src/**/*.ts\"", 71 | "watch": "tsc -watch -p ./", 72 | "pretest": "npm run compile", 73 | "test": "node ./out/test/runTest.js", 74 | "test:gui": "VSCODE_TEST_GUI=1 node ./out/test/runTest.js", 75 | "test:ci": "VSCODE_TEST_GUI=0 node ./out/test/runTest.js" 76 | }, 77 | "devDependencies": { 78 | "@eslint/eslintrc": "^3.3.1", 79 | "@eslint/js": "^9.34.0", 80 | "@types/node": "^24.3.0", 81 | "@types/chai": "^5.2.2", 82 | "@types/mocha": "^10.0.10", 83 | "@vscode/test-electron": "^2.4.0", 84 | "@types/vscode": "1.95.0", 85 | "@types/write-good": "1.0.3", 86 | "@typescript-eslint/eslint-plugin": "^8.40.0", 87 | "@typescript-eslint/parser": "^8.40.0", 88 | "eslint": "^9.34.0", 89 | "mocha": "^11.7.1", 90 | "chai": "^6.0.1", 91 | "glob": "^11.0.0", 92 | "typescript": "^5.9.2" 93 | }, 94 | "dependencies": { 95 | "write-good": "^1.0.8" 96 | }, 97 | "license": "MIT", 98 | "repository": { 99 | "url": "https://github.com/TravisTheTechie/vscode-write-good" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { workspace, ExtensionContext, TextDocument, languages, Uri, 2 | Diagnostic, DiagnosticCollection } from 'vscode'; 3 | import { lintText } from './linter'; 4 | 5 | let diagnosticCollection: DiagnosticCollection; 6 | let diagnosticMap: Map; 7 | let lastLint: Map; 8 | 9 | export function activate(context: ExtensionContext) { 10 | 11 | console.log("Write-Good Linter active..."); 12 | diagnosticCollection = languages.createDiagnosticCollection("Write-Good Lints"); 13 | diagnosticMap = new Map(); 14 | lastLint = new Map(); 15 | 16 | if (context == null) return; 17 | 18 | function isWriteGoodLanguage(languageId: string) { 19 | const wgLanguages: string = workspace.getConfiguration('write-good').get('languages'); 20 | return (wgLanguages.indexOf(languageId) > -1 || wgLanguages === '*'); 21 | } 22 | 23 | // full lint when document is saved 24 | context.subscriptions.push(workspace.onDidSaveTextDocument(document => { 25 | if (isWriteGoodLanguage(document.languageId)) { 26 | doLint(document); 27 | } 28 | })); 29 | 30 | // attempt to only lint changes on motification 31 | context.subscriptions.push(workspace.onDidChangeTextDocument(event => { 32 | if (!isWriteGoodLanguage(event.document.languageId)) { 33 | // language is unsupported. 34 | return; 35 | } 36 | 37 | const onlyLintOnSave: boolean = workspace.getConfiguration('write-good').get('only-lint-on-save'); 38 | if (onlyLintOnSave) { 39 | // not a save event, so don't bother linting 40 | return; 41 | } 42 | 43 | // debounce linting on editing 44 | const debounceMs: number = workspace.getConfiguration('write-good').get('debounce-time-in-ms'); 45 | const lastLintMs: number = lastLint.get(event.document.uri.toString()) || 0; 46 | const nowMs = (new Date()).getTime(); 47 | if (lastLintMs + debounceMs < nowMs) { 48 | // debounce time is less than now, let's do this 49 | doLint(event.document); 50 | } 51 | })); 52 | 53 | // full lint on a new document/opened document 54 | context.subscriptions.push(workspace.onDidOpenTextDocument(event => { 55 | if (isWriteGoodLanguage(event.languageId)) { 56 | doLint(event); 57 | } 58 | })); 59 | 60 | // clean up any lints when the document is closed 61 | context.subscriptions.push(workspace.onDidCloseTextDocument(event => { 62 | if (diagnosticMap.has(event.uri.toString())) { 63 | diagnosticMap.delete(event.uri.toString()); 64 | } 65 | resetDiagnostics(); 66 | })); 67 | } 68 | 69 | export function deactivate() { 70 | console.log("Write-Good Linter deactivating..."); 71 | } 72 | 73 | function resetDiagnostics() { 74 | diagnosticCollection.clear(); 75 | 76 | diagnosticMap.forEach((diags, file) => { 77 | diagnosticCollection.set(Uri.parse(file), diags); 78 | }); 79 | } 80 | 81 | function getWriteGoodConfig() : object { 82 | let wgConfig: object = workspace.getConfiguration('write-good').get('write-good-config'); 83 | if (wgConfig === undefined || wgConfig === null) { 84 | wgConfig = {}; 85 | } 86 | return wgConfig; 87 | } 88 | 89 | function doLint(document: TextDocument) { 90 | const wgConfig = getWriteGoodConfig(); 91 | const diagnostics: Diagnostic[] = []; 92 | lintText(document.getText(), wgConfig, 0, diagnostics); 93 | 94 | diagnosticMap.set(document.uri.toString(), diagnostics); 95 | lastLint.set(document.uri.toString(), (new Date()).getTime()); 96 | resetDiagnostics(); 97 | } -------------------------------------------------------------------------------- /test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'node:path'; 3 | import { expect } from 'chai'; 4 | 5 | const EXT_ID = 'travisthetechie.write-good-linter'; 6 | 7 | function sleep(ms: number) { 8 | return new Promise(resolve => setTimeout(resolve, ms)); 9 | } 10 | 11 | async function waitForDiagnostics(uri: vscode.Uri, timeoutMs = 3000) { 12 | const start = Date.now(); 13 | // Poll for diagnostics to appear 14 | while (Date.now() - start < timeoutMs) { 15 | const diags = vscode.languages.getDiagnostics(uri); 16 | if (diags.length > 0) return diags; 17 | await sleep(50); 18 | } 19 | return vscode.languages.getDiagnostics(uri); 20 | } 21 | 22 | async function waitForWorkspace(timeoutMs = 5000) { 23 | const start = Date.now(); 24 | while (Date.now() - start < timeoutMs) { 25 | if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { 26 | return vscode.workspace.workspaceFolders[0]; 27 | } 28 | await sleep(50); 29 | } 30 | throw new Error('Workspace not available for tests'); 31 | } 32 | 33 | suite('Write Good Linter (behavior)', function () { 34 | this.timeout(20000); 35 | 36 | suiteSetup(async () => { 37 | await vscode.extensions.getExtension(EXT_ID)?.activate(); 38 | await waitForWorkspace(); 39 | }); 40 | 41 | test('produces diagnostics for bad.md', async () => { 42 | // Make linting immediate and enabled during edit/open 43 | await vscode.workspace.getConfiguration('write-good') 44 | .update('debounce-time-in-ms', 0, vscode.ConfigurationTarget.Workspace); 45 | await vscode.workspace.getConfiguration('write-good') 46 | .update('only-lint-on-save', false, vscode.ConfigurationTarget.Workspace); 47 | 48 | const folder = await waitForWorkspace(); 49 | const uri = vscode.Uri.joinPath(folder.uri, 'bad.md'); 50 | let doc = await vscode.workspace.openTextDocument(uri); 51 | doc = await vscode.languages.setTextDocumentLanguage(doc, 'markdown'); 52 | await vscode.window.showTextDocument(doc); 53 | 54 | const diags = await waitForDiagnostics(uri); 55 | expect(diags.length).to.be.greaterThan(0); 56 | }); 57 | 58 | test('respects language filter', async () => { 59 | // Restrict to plaintext so Markdown should not be linted 60 | await vscode.workspace.getConfiguration('write-good') 61 | .update('languages', ['plaintext'], vscode.ConfigurationTarget.Workspace); 62 | 63 | const folder = await waitForWorkspace(); 64 | // Create a fresh markdown file so previous diagnostics don't carry over 65 | const uri = vscode.Uri.joinPath(folder.uri, 'tmp-filter.md'); 66 | await vscode.workspace.fs.writeFile(uri, Buffer.from('Really actually clearly just bad prose.')); 67 | 68 | // Open as markdown and confirm no diagnostics with filter 69 | let doc = await vscode.workspace.openTextDocument(uri); 70 | doc = await vscode.languages.setTextDocumentLanguage(doc, 'markdown'); 71 | await vscode.window.showTextDocument(doc); 72 | await sleep(200); 73 | 74 | const diags = vscode.languages.getDiagnostics(uri); 75 | expect(diags.length).to.equal(0); 76 | 77 | // Clean up temp file 78 | await vscode.workspace.fs.delete(uri); 79 | 80 | // Restore default configuration for subsequent tests (markdown + plaintext) 81 | await vscode.workspace.getConfiguration('write-good') 82 | .update('languages', ['markdown', 'plaintext'], vscode.ConfigurationTarget.Workspace); 83 | }); 84 | 85 | test('only lints on save when enabled', async () => { 86 | // Ensure linting does not run on edit, only on save 87 | await vscode.workspace.getConfiguration('write-good') 88 | .update('only-lint-on-save', true, vscode.ConfigurationTarget.Workspace); 89 | await vscode.workspace.getConfiguration('write-good') 90 | .update('debounce-time-in-ms', 0, vscode.ConfigurationTarget.Workspace); 91 | await vscode.workspace.getConfiguration('write-good') 92 | .update('languages', '*', vscode.ConfigurationTarget.Workspace); 93 | await sleep(200); 94 | 95 | const folder = await waitForWorkspace(); 96 | const uri = vscode.Uri.joinPath(folder.uri, 'tmp-save.md'); 97 | await vscode.workspace.fs.writeFile(uri, Buffer.from('This text is fine.')); 98 | 99 | const doc = await vscode.workspace.openTextDocument(uri); 100 | const editor = await vscode.window.showTextDocument(doc); 101 | 102 | // Initial open should have no diagnostics 103 | let diags = vscode.languages.getDiagnostics(uri); 104 | expect(diags.length).to.equal(0); 105 | 106 | // Insert problematic text but do not save 107 | await editor.edit(edit => { 108 | edit.insert(new vscode.Position(doc.lineCount, 0), '\nSo this is very actually really just a test.'); 109 | }); 110 | 111 | // Wait a bit to verify no diagnostics were produced by edit 112 | await sleep(400); 113 | diags = vscode.languages.getDiagnostics(uri); 114 | expect(diags.length).to.equal(0); 115 | 116 | // Save and expect diagnostics to appear 117 | await doc.save(); 118 | diags = await waitForDiagnostics(uri); 119 | expect(diags.length).to.be.greaterThan(0); 120 | 121 | // Clean up and restore default 122 | await vscode.workspace.fs.delete(uri); 123 | await vscode.workspace.getConfiguration('write-good') 124 | .update('only-lint-on-save', false, vscode.ConfigurationTarget.Workspace); 125 | }); 126 | 127 | test('debounces linting on edit', async () => { 128 | // Enable edit-time linting but set a high debounce 129 | await vscode.workspace.getConfiguration('write-good') 130 | .update('only-lint-on-save', false, vscode.ConfigurationTarget.Workspace); 131 | await vscode.workspace.getConfiguration('write-good') 132 | .update('debounce-time-in-ms', 500, vscode.ConfigurationTarget.Workspace); 133 | await vscode.workspace.getConfiguration('write-good') 134 | .update('languages', '*', vscode.ConfigurationTarget.Workspace); 135 | 136 | const folder = await waitForWorkspace(); 137 | const uri = vscode.Uri.joinPath(folder.uri, 'tmp-debounce.md'); 138 | await vscode.workspace.fs.writeFile(uri, Buffer.from('This text is fine.')); 139 | const doc = await vscode.workspace.openTextDocument(uri); 140 | const editor = await vscode.window.showTextDocument(doc); 141 | 142 | // Baseline: no diagnostics 143 | let diags = vscode.languages.getDiagnostics(uri); 144 | expect(diags.length).to.equal(0); 145 | 146 | // Introduce problematic text and force baseline via save (ensures initial lint) 147 | await editor.edit(edit => { 148 | edit.insert(new vscode.Position(doc.lineCount, 0), '\nSo this is very actually really just a test.'); 149 | }); 150 | await doc.save(); 151 | diags = await waitForDiagnostics(uri, 3000); 152 | const baseline = diags.length; 153 | expect(baseline).to.be.greaterThan(0); 154 | 155 | // Make a second edit that would normally add more issues, still within debounce window 156 | await editor.edit(edit => { 157 | edit.insert(new vscode.Position(doc.lineCount, 0), '\nObviously clearly very really.'); 158 | }); 159 | await sleep(100); 160 | // Expect diagnostics unchanged (no re-lint during debounce interval) 161 | diags = vscode.languages.getDiagnostics(uri); 162 | expect(diags.length).to.equal(baseline); 163 | 164 | // After debounce window, trigger another tiny change to force a re-lint 165 | await sleep(600); 166 | await editor.edit(edit => { 167 | edit.insert(new vscode.Position(doc.lineCount, 0), '\n'); 168 | }); 169 | diags = await waitForDiagnostics(uri, 2000); 170 | expect(diags.length).to.be.greaterThan(baseline); 171 | 172 | // Clean up and restore fast tests 173 | await vscode.workspace.fs.delete(uri); 174 | await vscode.workspace.getConfiguration('write-good') 175 | .update('debounce-time-in-ms', 0, vscode.ConfigurationTarget.Workspace); 176 | }); 177 | }); 178 | --------------------------------------------------------------------------------