├── .github
├── FUNDING.yml
└── workflows
│ ├── release.yml
│ └── ci.yml
├── .npmrc
├── res
└── icon.png
├── .vscode
├── extensions.json
├── launch.json
├── tasks.json
└── settings.json
├── playground
├── .vscode
│ └── settings.json
├── index.vue
└── main.ts
├── pnpm-workspace.yaml
├── .gitignore
├── test
└── index.test.ts
├── eslint.config.mjs
├── tsdown.config.ts
├── src
├── utils.ts
├── parsers
│ ├── index.ts
│ ├── javascript.ts
│ └── html.ts
├── rules
│ ├── dash.ts
│ ├── html-attr.ts
│ ├── html-element.ts
│ ├── js-colon.ts
│ ├── jsx-tag-pair.ts
│ ├── js-arrow-fn.ts
│ ├── index.ts
│ ├── js-assign.ts
│ ├── bracket-pair.ts
│ ├── html-tag-pair.ts
│ └── js-block.ts
├── log.ts
├── trigger.ts
├── types.ts
├── context.ts
└── index.ts
├── tsconfig.json
├── LICENSE.md
├── CONTRIBUTION.md
├── scripts
└── docs.ts
├── README.md
└── package.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [antfu]
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | ignore-workspace-root-check=true
2 | shell-emulator=true
3 |
--------------------------------------------------------------------------------
/res/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/antfu/vscode-smart-clicks/HEAD/res/icon.png
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "amodio.tsl-problem-matcher"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/playground/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "smartClicks.rules": {
3 | "dash": true,
4 | "html-element": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - playground
3 | - examples/*
4 | onlyBuiltDependencies:
5 | - '@vscode/vsce-sign'
6 | - esbuild
7 | - keytar
8 | - vsxpub
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 | .DS_Store
3 | .idea
4 | *.log
5 | *.tgz
6 | coverage
7 | dist
8 | lib-cov
9 | logs
10 | node_modules
11 | temp
12 | *.vsix
13 | .env
14 |
15 |
--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 |
3 | describe('should', () => {
4 | it('exported', () => {
5 | expect(1).toEqual(1)
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import antfu from '@antfu/eslint-config'
2 |
3 | export default antfu({
4 | ignores: ['**/playground/**'],
5 | markdown: false,
6 | formatters: true,
7 | })
8 | .removeRules(
9 | 'node/prefer-global/process',
10 | )
11 |
--------------------------------------------------------------------------------
/tsdown.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsdown'
2 |
3 | export default defineConfig({
4 | entry: [
5 | 'src/index.ts',
6 | ],
7 | format: ['cjs'],
8 | env: {
9 | NODE_ENV: process.env.NODE_ENV || 'production',
10 | },
11 | external: [
12 | 'vscode',
13 | ],
14 | })
15 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { Range, Selection } from 'vscode'
2 |
3 | export function escapeRegExp(str: string) {
4 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
5 | }
6 |
7 | export function toSelection(range: Range | Selection) {
8 | if (range instanceof Range)
9 | return new Selection(range.start, range.end)
10 | return range
11 | }
12 |
--------------------------------------------------------------------------------
/src/parsers/index.ts:
--------------------------------------------------------------------------------
1 | import type { HandlerContext, Parser } from '../types'
2 | import { htmlParser } from './html'
3 | import { jsParser } from './javascript'
4 |
5 | export const parsers: Parser[] = [
6 | jsParser,
7 | htmlParser,
8 | ]
9 |
10 | export async function applyParser(context: HandlerContext) {
11 | for (const parser of parsers)
12 | await parser.handle(context)
13 | }
14 |
--------------------------------------------------------------------------------
/src/rules/dash.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 |
3 | /**
4 | * `-` to identifier.
5 | *
6 | * ```css
7 | * ▽
8 | * foo-bar
9 | * └─────┘
10 | * ```
11 | *
12 | * @name dash
13 | */
14 | export const dashHandler: Handler = {
15 | name: 'dash',
16 | handle({ charLeft, charRight, doc, anchor }) {
17 | if (charLeft === '-' || charRight === '-')
18 | return doc.getWordRangeAtPosition(anchor, /[\w-]+/)
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": ["esnext"],
5 | "module": "esnext",
6 | "moduleResolution": "node",
7 | "resolveJsonModule": true,
8 | "types": [
9 | "@babel/types"
10 | ],
11 | "strict": true,
12 | "strictNullChecks": true,
13 | "esModuleInterop": true,
14 | "skipDefaultLibCheck": true,
15 | "skipLibCheck": true
16 | },
17 | "exclude": [
18 | "playground/**"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Extension",
6 | "type": "extensionHost",
7 | "request": "launch",
8 | "runtimeExecutable": "${execPath}",
9 | "args": [
10 | "--extensionDevelopmentPath=${workspaceFolder}",
11 | "${workspaceFolder}/playground"
12 | ],
13 | "outFiles": [
14 | "${workspaceFolder}/dist/**/*.js"
15 | ],
16 | "preLaunchTask": "npm: dev"
17 | }
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/src/log.ts:
--------------------------------------------------------------------------------
1 | import { window } from 'vscode'
2 |
3 | export const isDebug = process.env.NODE_ENV === 'development'
4 |
5 | export const channel = window.createOutputChannel('Smart Click')
6 |
7 | export const log = {
8 | debug(...args: any[]) {
9 | if (!isDebug)
10 | return
11 | // eslint-disable-next-line no-console
12 | console.log(...args)
13 | this.log(...args)
14 | },
15 |
16 | log(...args: any[]) {
17 | channel.appendLine(args.map(i => String(i)).join(' '))
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/src/rules/html-attr.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 |
3 | /**
4 | * `=` to HTML attribute.
5 | *
6 | * ```html
7 | * ▽
8 | *
9 | * └─────────┘
10 | * ```
11 | *
12 | * @name html-attr
13 | * @category html
14 | */
15 | export const htmlAttrHandler: Handler = {
16 | name: 'html-attr',
17 | handle({ getAst, doc, anchor }) {
18 | const asts = getAst('html')
19 | if (!asts.length)
20 | return
21 |
22 | const range = doc.getWordRangeAtPosition(anchor, /=/)
23 | if (!range)
24 | return
25 |
26 | return doc.getWordRangeAtPosition(anchor, /[\w.:@-]+=(["']).*?\1|[\w.:@-]+=\{.*?\}/)
27 | },
28 | }
29 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | // See https://go.microsoft.com/fwlink/?LinkId=733558
2 | // for the documentation about the tasks.json format
3 | {
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "type": "npm",
8 | "script": "dev",
9 | "isBackground": true,
10 | "presentation": {
11 | "reveal": "never"
12 | },
13 | "problemMatcher": [
14 | {
15 | "base": "$ts-webpack-watch",
16 | "background": {
17 | "activeOnStart": true,
18 | "beginsPattern": "Build start",
19 | "endsPattern": "Build success"
20 | }
21 | }
22 | ],
23 | "group": "build"
24 | }
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/src/trigger.ts:
--------------------------------------------------------------------------------
1 | import type { Selection, TextDocument } from 'vscode'
2 | import { toArray } from '@antfu/utils'
3 | import { createContext } from './context'
4 | import { log } from './log'
5 | import { applyParser } from './parsers'
6 | import { applyHandlers } from './rules'
7 | import { toSelection } from './utils'
8 |
9 | export async function trigger(
10 | doc: TextDocument,
11 | prevSelection: Selection,
12 | selection: Selection,
13 | ) {
14 | const context = createContext(doc, prevSelection, selection)
15 |
16 | log.debug(context)
17 |
18 | await applyParser(context)
19 |
20 | const newSelection = applyHandlers(context)
21 | if (newSelection)
22 | return toArray(newSelection).map(toSelection)
23 | return undefined
24 | }
25 |
--------------------------------------------------------------------------------
/src/rules/html-element.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 | import { Selection } from 'vscode'
3 | import { traverseHTML } from '../parsers/html'
4 |
5 | /**
6 | * `<` to the entire element.
7 | *
8 | * ```html
9 | * ▽
10 | *
11 | * └────────────────────┘
12 | * ```
13 | *
14 | * @name html-element
15 | * @category html
16 | */
17 | export const htmlElementHandler: Handler = {
18 | name: 'html-element',
19 | handle({ getAst, doc, anchor }) {
20 | const asts = getAst('html')
21 | if (!asts.length)
22 | return
23 |
24 | const range = doc.getWordRangeAtPosition(anchor, /\s{0,2})
25 | if (!range)
26 | return
27 | const targetIndex = doc.offsetAt(range.end) - 1
28 |
29 | for (const ast of asts) {
30 | for (const node of traverseHTML(ast.root)) {
31 | if (node.range[0] + ast.start === targetIndex) {
32 | return new Selection(
33 | doc.positionAt(node.range[0] + ast.start),
34 | doc.positionAt(node.range[1] + ast.start),
35 | )
36 | }
37 | }
38 | }
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/src/parsers/javascript.ts:
--------------------------------------------------------------------------------
1 | import type { AstRoot, Parser } from '../types'
2 | import { parse } from '@babel/parser'
3 | import { channel } from '../log'
4 |
5 | export const jsParser: Parser = {
6 | name: 'js',
7 | handle: async ({ ast, doc, langId }) => {
8 | const id = 'js-root'
9 | if (ast.find(i => i.id === id))
10 | return
11 |
12 | if (!['javascript', 'typescript', 'javascriptreact', 'typescriptreact'].includes(langId))
13 | return
14 |
15 | try {
16 | ast.push(parseJS(doc.getText(), id, 0))
17 | }
18 | catch (e) {
19 | channel.appendLine(`Failed to parse ${doc.uri.fsPath}`)
20 | channel.appendLine(String(e))
21 | }
22 | },
23 | }
24 |
25 | export function parseJS(code: string, id: string, start = 0): AstRoot {
26 | const root = parse(
27 | code,
28 | {
29 | sourceType: 'unambiguous',
30 | plugins: [
31 | 'jsx',
32 | 'typescript',
33 | ],
34 | },
35 | )
36 | return {
37 | type: 'js',
38 | id,
39 | start,
40 | end: start + code.length,
41 | root,
42 | raw: code,
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Publish Extension
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | publish-extension:
10 | permissions:
11 | id-token: write
12 | contents: write
13 | actions: write
14 |
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 | with:
20 | fetch-depth: 0
21 |
22 | - name: Install pnpm
23 | uses: pnpm/action-setup@v4
24 |
25 | - name: Set node
26 | uses: actions/setup-node@v4
27 | with:
28 | node-version: lts/*
29 | cache: pnpm
30 |
31 | - run: npx changelogithub --no-group
32 | continue-on-error: true
33 | env:
34 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
35 |
36 | - run: pnpm install
37 |
38 | - name: Generate .vsix file
39 | run: pnpm package
40 |
41 | - name: Publish Extension
42 | run: npx vsxpub --no-dependencies
43 | env:
44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45 | VSCE_PAT: ${{secrets.VSCE_PAT}}
46 | OVSX_PAT: ${{secrets.OVSX_PAT}}
47 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Anthony Fu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { ParseResult as AstRootJS } from '@babel/parser'
2 | import type { HTMLElement as AstRootHTML } from 'node-html-parser'
3 | import type { Position, Range, Selection, TextDocument } from 'vscode'
4 |
5 | export interface HandlerContext {
6 | doc: TextDocument
7 | langId: string
8 | anchor: Position
9 | anchorIndex: number
10 | selection: Selection
11 | char: string
12 | charLeft: string
13 | charRight: string
14 | chars: string[]
15 | withOffset: (p: Position, offset: number) => Position
16 | ast: AstRoot[]
17 | getAst: (lang: T) => AstIdMap[T][]
18 | }
19 |
20 | export interface Handler {
21 | name: string
22 | handle: (context: HandlerContext) => Selection | Range | Selection[] | Range [] | void
23 | }
24 |
25 | export interface Parser {
26 | name: string
27 | handle: (context: HandlerContext) => Promise | void
28 | }
29 |
30 | export interface AstBase {
31 | start: number
32 | end: number
33 | raw: string
34 | id: string
35 | }
36 |
37 | export interface AstJS extends AstBase {
38 | type: 'js'
39 | root: AstRootJS
40 | }
41 |
42 | export interface AstHTML extends AstBase {
43 | type: 'html'
44 | root: AstRootHTML
45 | }
46 |
47 | export interface AstIdMap {
48 | js: AstJS
49 | html: AstHTML
50 | }
51 |
52 | export type AstLang = keyof AstIdMap
53 |
54 | export type AstRoot = AstHTML | AstJS
55 |
--------------------------------------------------------------------------------
/src/parsers/html.ts:
--------------------------------------------------------------------------------
1 | import type { HTMLElement } from 'node-html-parser'
2 | import type { Parser } from '../types'
3 | import { parse } from 'node-html-parser'
4 | import { workspace } from 'vscode'
5 | import { parseJS } from './javascript'
6 |
7 | export const htmlParser: Parser = {
8 | name: 'html',
9 | handle: async ({ ast, langId, doc }) => {
10 | const id = 'html-root'
11 | if (ast.find(i => i.id === id))
12 | return
13 |
14 | const config = workspace.getConfiguration('smartClicks')
15 | if (!config.get('htmlLanguageIds', []).includes(langId))
16 | return
17 |
18 | const code = doc.getText()
19 | const root = parse(code, {
20 | comment: true,
21 | })
22 |
23 | ast.push({
24 | type: 'html',
25 | id,
26 | start: 0,
27 | end: code.length,
28 | root,
29 | raw: code,
30 | })
31 |
32 | let htmlScriptCount = 0
33 | for (const node of traverseHTML(root)) {
34 | if (node.rawTagName === 'script') {
35 | const script = node.childNodes[0]
36 | const raw = node.innerHTML
37 | const start = script.range[0]
38 | const id = `html-script-${htmlScriptCount++}`
39 | ast.push(parseJS(raw, id, start))
40 | }
41 | }
42 | },
43 | }
44 |
45 | export function* traverseHTML(node: HTMLElement): Generator {
46 | yield node
47 | for (const child of node.childNodes)
48 | yield* traverseHTML(child as HTMLElement)
49 | }
50 |
--------------------------------------------------------------------------------
/src/rules/js-colon.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 | import traverse from '@babel/traverse'
3 | import { Selection } from 'vscode'
4 |
5 | /**
6 | * `:` to the value.
7 | *
8 | * ```js
9 | * ▽
10 | * { foo: { bar } }
11 | * └─────┘
12 | * ```
13 | *
14 | * @name js-colon
15 | * @category js
16 | */
17 | export const jsColonHandler: Handler = {
18 | name: 'js-colon',
19 | handle({ doc, getAst, anchor }) {
20 | const asts = getAst('js')
21 | if (!asts.length)
22 | return
23 |
24 | const range = doc.getWordRangeAtPosition(anchor, /\s*:\s*/)
25 | if (!range)
26 | return
27 |
28 | for (const ast of getAst('js')) {
29 | const relativeIndex = doc.offsetAt(range.end) - ast.start
30 |
31 | let result: Selection | undefined
32 | traverse(ast.root, {
33 | enter(path) {
34 | if (result)
35 | return path.skip()
36 | if (path.node.start == null || path.node.end == null)
37 | return
38 | if (relativeIndex > path.node.end || path.node.start > relativeIndex)
39 | return path.skip()
40 | if (path.node.start !== relativeIndex)
41 | return
42 | result = new Selection(
43 | doc.positionAt(ast.start + path.node.start),
44 | doc.positionAt(ast.start + path.node.end),
45 | )
46 | },
47 | })
48 |
49 | if (result)
50 | return result
51 | }
52 | },
53 | }
54 |
--------------------------------------------------------------------------------
/src/rules/jsx-tag-pair.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 | import traverse from '@babel/traverse'
3 | import { Selection } from 'vscode'
4 |
5 | /**
6 | * Matches JSX elements' start and end tags.
7 | *
8 | * ```jsx
9 | * ▽
10 | * (Hi)
11 | * └───────┘ └───────┘
12 | * ```
13 | *
14 | * @name jsx-tag-pair
15 | * @category js
16 | */
17 | export const jsxTagPairHandler: Handler = {
18 | name: 'jsx-tag-pair',
19 | handle({ selection, doc, getAst }) {
20 | for (const ast of getAst('js')) {
21 | const index = doc.offsetAt(selection.start)
22 |
23 | let result: Selection[] | undefined
24 | traverse(ast.root, {
25 | JSXElement(path) {
26 | if (result)
27 | return path.skip()
28 | if (path.node.start == null || path.node.end == null)
29 | return
30 |
31 | const elements = [
32 | path.node.openingElement!,
33 | path.node.closingElement!,
34 | ].filter(Boolean)
35 |
36 | if (!elements.length)
37 | return
38 |
39 | if (elements.some(e => e.name.start != null && e.name.start + ast.start === index)) {
40 | result = elements.map(e => new Selection(
41 | doc.positionAt(ast.start + e.name.start!),
42 | doc.positionAt(ast.start + e.name.end!),
43 | ))
44 | }
45 | },
46 | })
47 |
48 | if (result)
49 | return result
50 | }
51 | },
52 | }
53 |
--------------------------------------------------------------------------------
/src/context.ts:
--------------------------------------------------------------------------------
1 | import type { Position, Selection, TextDocument } from 'vscode'
2 | import type { AstIdMap, AstLang, HandlerContext } from './types'
3 | import { Range } from 'vscode'
4 | import { astCache } from './index'
5 |
6 | export function createContext(
7 | doc: TextDocument,
8 | prevSelection: Selection,
9 | selection: Selection,
10 | ) {
11 | const anchor = prevSelection.start
12 | const anchorIndex = doc.offsetAt(anchor)
13 |
14 | const charLeft = doc.getText(new Range(anchor, withOffset(anchor, -1)))
15 | const charRight = doc.getText(new Range(anchor, withOffset(anchor, 1)))
16 | const char = doc.offsetAt(selection.end) >= doc.offsetAt(anchor)
17 | ? charRight
18 | : charLeft
19 |
20 | if (!astCache.has(doc.uri.fsPath))
21 | astCache.set(doc.uri.fsPath, [])
22 | const ast = astCache.get(doc.uri.fsPath)!
23 |
24 | function withOffset(p: Position, offset: number) {
25 | if (offset === 0)
26 | return p
27 | return doc.positionAt(doc.offsetAt(p) + offset)
28 | }
29 |
30 | function getAst(lang: T): AstIdMap[T][] {
31 | return ast.filter(i => i.type === lang && i.start <= anchorIndex && i.end >= anchorIndex && i.root) as AstIdMap[T][]
32 | }
33 |
34 | const context: HandlerContext = {
35 | doc,
36 | langId: doc.languageId,
37 | anchor,
38 | anchorIndex,
39 | selection,
40 | withOffset,
41 | char,
42 | charLeft,
43 | charRight,
44 | chars: [charLeft, charRight],
45 | ast,
46 | getAst,
47 | }
48 |
49 | return context
50 | }
51 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // Enable the ESlint flat config support
3 | // (remove this if your ESLint extension above v3.0.5)
4 | "eslint.experimental.useFlatConfig": true,
5 |
6 | // Disable the default formatter, use eslint instead
7 | "prettier.enable": false,
8 | "editor.formatOnSave": false,
9 |
10 | // Auto fix
11 | "editor.codeActionsOnSave": {
12 | "source.fixAll.eslint": "explicit",
13 | "source.organizeImports": "never"
14 | },
15 |
16 | // Silent the stylistic rules in you IDE, but still auto fix them
17 | "eslint.rules.customizations": [
18 | { "rule": "style/*", "severity": "off" },
19 | { "rule": "format/*", "severity": "off" },
20 | { "rule": "*-indent", "severity": "off" },
21 | { "rule": "*-spacing", "severity": "off" },
22 | { "rule": "*-spaces", "severity": "off" },
23 | { "rule": "*-order", "severity": "off" },
24 | { "rule": "*-dangle", "severity": "off" },
25 | { "rule": "*-newline", "severity": "off" },
26 | { "rule": "*quotes", "severity": "off" },
27 | { "rule": "*semi", "severity": "off" }
28 | ],
29 |
30 | // Enable eslint for all supported languages
31 | "eslint.validate": [
32 | "javascript",
33 | "javascriptreact",
34 | "typescript",
35 | "typescriptreact",
36 | "vue",
37 | "html",
38 | "markdown",
39 | "json",
40 | "jsonc",
41 | "yaml",
42 | "toml",
43 | "xml",
44 | "gql",
45 | "graphql",
46 | "astro",
47 | "css",
48 | "less",
49 | "scss",
50 | "pcss",
51 | "postcss"
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/playground/index.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | Vitesse Lite
28 |
29 |
30 |
31 | Opinionated Vite Starter Template
32 |
33 |
34 |
35 |
36 |
51 |
52 |
53 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/src/rules/js-arrow-fn.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 | import traverse from '@babel/traverse'
3 | import { Selection } from 'vscode'
4 |
5 | const supportedNodeType = [
6 | 'ArrowFunctionExpression',
7 | ]
8 |
9 | /**
10 | * `=>` to arrow function.
11 | *
12 | * ```js
13 | * ▽
14 | * (a, b) => a + b
15 | * └─────────────┘
16 | * ```
17 | *
18 | * @name js-arrow-fn
19 | * @category js
20 | */
21 | export const jsArrowFnHandler: Handler = {
22 | name: 'js-arrow-fn',
23 | handle({ doc, getAst, anchorIndex, anchor, chars }) {
24 | if (!chars.includes('='))
25 | return
26 |
27 | const asts = getAst('js')
28 | if (!asts.length)
29 | return
30 |
31 | const range = doc.getWordRangeAtPosition(anchor, /=>/)
32 | if (!range || range.isEmpty)
33 | return
34 |
35 | for (const ast of getAst('js')) {
36 | const relativeIndex = anchorIndex - ast.start
37 |
38 | let result: Selection | undefined
39 | traverse(ast.root, {
40 | enter(path) {
41 | if (path.node.start == null || path.node.end == null)
42 | return
43 | if (relativeIndex > path.node.end || path.node.start > relativeIndex)
44 | return path.skip()
45 | if (!supportedNodeType.includes(path.node.type)) {
46 | // log.debug(`[js-arrow-fn] Unknown ${path.node.type}`)
47 | return
48 | }
49 | result = new Selection(
50 | doc.positionAt(ast.start + path.node.start),
51 | doc.positionAt(ast.start + path.node.end),
52 | )
53 | },
54 | })
55 |
56 | if (result)
57 | return result
58 | }
59 | },
60 | }
61 |
--------------------------------------------------------------------------------
/src/rules/index.ts:
--------------------------------------------------------------------------------
1 | import type { Range, Selection } from 'vscode'
2 | import type { Handler, HandlerContext } from '../types'
3 | import { toArray } from '@antfu/utils'
4 | import { workspace } from 'vscode'
5 | import { log } from '../log'
6 | import { bracketPairHandler } from './bracket-pair'
7 | import { dashHandler } from './dash'
8 | import { htmlAttrHandler } from './html-attr'
9 | import { htmlElementHandler } from './html-element'
10 | import { htmlTagPairHandler } from './html-tag-pair'
11 | import { jsArrowFnHandler } from './js-arrow-fn'
12 | import { jsAssignHandler } from './js-assign'
13 | import { jsBlockHandler } from './js-block'
14 | import { jsColonHandler } from './js-colon'
15 | import { jsxTagPairHandler } from './jsx-tag-pair'
16 |
17 | export const handlers: Handler[] = [
18 | // html
19 | htmlTagPairHandler,
20 | htmlElementHandler,
21 | htmlAttrHandler,
22 |
23 | // js
24 | jsxTagPairHandler,
25 | jsArrowFnHandler,
26 | jsBlockHandler,
27 | jsAssignHandler,
28 | jsColonHandler,
29 |
30 | // general
31 | dashHandler,
32 | bracketPairHandler,
33 | ]
34 |
35 | function stringify(range: Range | Selection) {
36 | return `${range.start.line}:${range.start.character}->${range.end.line}:${range.end.character}`
37 | }
38 |
39 | export function applyHandlers(context: HandlerContext) {
40 | const config = workspace.getConfiguration('smartClicks')
41 | const rulesOptions = config.get('rules', {}) as any
42 |
43 | for (const handler of handlers) {
44 | if (rulesOptions[handler.name] === false)
45 | continue
46 | let selection = handler.handle(context)
47 | if (selection) {
48 | selection = toArray(selection)
49 | log.log(`[${handler.name}] ${selection.map(stringify).join(', ')}`)
50 | return selection
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/CONTRIBUTION.md:
--------------------------------------------------------------------------------
1 | # Contribution
2 |
3 | ## Steps
4 |
5 | 1. Fork and clone the repo.
6 | 2. Open the `Extensions` tab of VS Code.
7 | 3. Search for `@recommended`.
8 | 4. Make sure to install the `TypeScript + Webpack Problem Matchers` extension.
9 | 5. Run `ni` (or `pnpm i`) in terminal.
10 | 6. Press F5 to start debuging.
11 |
12 | ## Troubleshooting
13 |
14 | ### Task Error
15 |
16 | **Error messages:**
17 |
18 | - In `OUTPUT` panel of VS Code 👇.
19 |
20 | ```
21 | Error: the description can't be converted into a problem matcher:
22 | {
23 | "base": "$ts-webpack-watch",
24 | "background": {
25 | "activeOnStart": true,
26 | "beginsPattern": "Build start",
27 | "endsPattern": "Build success"
28 | }
29 | }
30 | ```
31 |
32 | - A popup saying like this 👇.
33 |
34 | ```
35 | The task 'npm: dev' cannot be tracked. Make sure to have a problem matcher defined.
36 | ```
37 |
38 | **Solution:**
39 |
40 | Please make sure you follow the steps above and install the recommended extension. This extension provides the `$ts-webpack-watch` problem matcher required in `.vscode/tasks.json`.
41 |
42 | ### Terminal Starting Error
43 |
44 | After pressing F5, if there isn't a new terminal named `Task` autostart in the `TERMINAL` panel of VS Code, and recommended extension is installed correctly, it may because VS Code doesn't load your `.zshrc` or other bash profiles correctly.
45 |
46 | **Error message:**
47 |
48 | - An error tip in the bottom right corner saying like this 👇.
49 |
50 | ```
51 | The terminal process "/bin/zsh '-c', 'pnpm run dev'" terminated with exit code: 127.
52 | ```
53 |
54 | **Solutions:**
55 |
56 | - Run `pnpm run dev` (or `nr dev`) manually.
57 | - Quit the VS Code in use and restart it from your terminal app by running the `code` command.
58 |
--------------------------------------------------------------------------------
/src/rules/js-assign.ts:
--------------------------------------------------------------------------------
1 | // import { log } from '../log'
2 | import type { Handler } from '../types'
3 | import traverse from '@babel/traverse'
4 |
5 | import { Range, Selection } from 'vscode'
6 |
7 | const supportedNodeType = [
8 | 'TSTypeAliasDeclaration',
9 | 'VariableDeclaration',
10 | 'AssignmentExpression',
11 | 'ClassProperty',
12 | ]
13 |
14 | /**
15 | * `=` to assignment.
16 | *
17 | * ```js
18 | * ▽
19 | * const a = []
20 | * └──────────┘
21 | *
22 | * class B {
23 | * ▽
24 | * b = 1;
25 | * └────┘
26 | * ▽
27 | * ba = () => {};
28 | * └────────────┘
29 | * }
30 | * ```
31 | *
32 | * @name js-assign
33 | * @category js
34 | */
35 | export const jsAssignHandler: Handler = {
36 | name: 'js-assign',
37 | handle({ doc, getAst, chars, anchorIndex, withOffset, anchor }) {
38 | if (!chars.includes('='))
39 | return
40 |
41 | const asts = getAst('js')
42 | if (!asts.length)
43 | return
44 |
45 | if (doc.getText(new Range(anchor, withOffset(anchor, 2))).includes('=>'))
46 | return
47 |
48 | for (const ast of getAst('js')) {
49 | const relativeIndex = anchorIndex - ast.start
50 |
51 | let result: Selection | undefined
52 | traverse(ast.root, {
53 | enter(path) {
54 | if (path.node.start == null || path.node.end == null)
55 | return
56 | if (relativeIndex > path.node.end || path.node.start > relativeIndex)
57 | return path.skip()
58 | if (!supportedNodeType.includes(path.node.type)) {
59 | // log.debug('[js-assign] Unknown type:', path.node.type)
60 | return
61 | }
62 | result = new Selection(
63 | doc.positionAt(ast.start + path.node.start),
64 | doc.positionAt(ast.start + path.node.end),
65 | )
66 | },
67 | })
68 |
69 | if (result)
70 | return result
71 | }
72 | },
73 | }
74 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 | branches:
10 | - main
11 |
12 | jobs:
13 | lint:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: Install pnpm
19 | uses: pnpm/action-setup@v4
20 |
21 | - name: Set node
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: lts/*
25 | cache: pnpm
26 |
27 | - name: Setup
28 | run: npm i -g @antfu/ni
29 |
30 | - name: Install
31 | run: nci
32 |
33 | - name: Lint
34 | run: nr lint
35 |
36 | # typecheck:
37 | # runs-on: ubuntu-latest
38 | # steps:
39 | # - uses: actions/checkout@v2
40 |
41 | # - name: Install pnpm
42 | # uses: pnpm/action-setup@v2.2.1
43 |
44 | # - name: Set node
45 | # uses: actions/setup-node@v2
46 | # with:
47 | # node-version: 16.x
48 | # cache: pnpm
49 |
50 | # - name: Setup
51 | # run: npm i -g @antfu/ni
52 |
53 | # - name: Install
54 | # run: nci
55 |
56 | # - name: Typecheck
57 | # run: nr typecheck
58 |
59 | test:
60 | runs-on: ${{ matrix.os }}
61 |
62 | strategy:
63 | matrix:
64 | node: [lts/*]
65 | os: [ubuntu-latest]
66 | fail-fast: false
67 |
68 | steps:
69 | - uses: actions/checkout@v4
70 |
71 | - name: Install pnpm
72 | uses: pnpm/action-setup@v4
73 |
74 | - name: Set node version to ${{ matrix.node }}
75 | uses: actions/setup-node@v4
76 | with:
77 | node-version: ${{ matrix.node }}
78 | cache: pnpm
79 |
80 | - name: Setup
81 | run: npm i -g @antfu/ni
82 |
83 | - name: Install
84 | run: nci
85 |
86 | - name: Build
87 | run: nr build
88 |
89 | - name: Test
90 | run: nr test
91 |
--------------------------------------------------------------------------------
/scripts/docs.ts:
--------------------------------------------------------------------------------
1 | import { promises as fs } from 'node:fs'
2 | import fg from 'fast-glob'
3 | import pkg from '../package.json'
4 |
5 | interface Parsed {
6 | name: string
7 | category?: string
8 | content: string
9 | file: string
10 | }
11 |
12 | const GITHUB_URL = 'https://github.com/antfu/vscode-smart-clicks/blob/main/'
13 |
14 | async function run() {
15 | let readme = await fs.readFile('README.md', 'utf-8')
16 | const files = await fg('src/rules/*.ts', {
17 | ignore: ['index.ts'],
18 | })
19 |
20 | files.sort()
21 |
22 | const parsed: Parsed[] = []
23 |
24 | for (const file of files) {
25 | const content = await fs.readFile(file, 'utf-8')
26 | const match = content.match(/\/\*[\s\S]+?\*\//)?.[0]
27 | if (!match)
28 | continue
29 | const info = {
30 | file,
31 | } as Parsed
32 | const lines = match.split('\n')
33 | .map(i => i.replace(/^\s*[/*]+\s?/, '').trimEnd())
34 | .filter((i) => {
35 | if (i.startsWith('@name ')) {
36 | info.name = i.slice(6)
37 | return false
38 | }
39 | if (i.startsWith('@category ')) {
40 | info.category = i.slice('@category '.length)
41 | return false
42 | }
43 | return true
44 | })
45 | info.content = lines.join('\n').trim()
46 | parsed.push(info)
47 | }
48 |
49 | const content = parsed.map((i) => {
50 | return `#### [\`${i.name}\`](${GITHUB_URL + i.file})\n\n${i.content}`
51 | }).join('\n\n')
52 |
53 | readme = readme.replace(/[\s\S]*/, `\n${content}\n`)
54 | await fs.writeFile('README.md', readme, 'utf-8')
55 |
56 | const props: any = {}
57 | parsed.forEach((i) => {
58 | props[i.name] = {
59 | type: 'boolean',
60 | default: true,
61 | description: i.content,
62 | }
63 | })
64 |
65 | pkg.contributes.configuration.properties['smartClicks.rules'].properties = props as any
66 | await fs.writeFile('package.json', JSON.stringify(pkg, null, 2), 'utf-8')
67 | }
68 |
69 | run()
70 |
--------------------------------------------------------------------------------
/src/rules/bracket-pair.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 | import { Position, Range, Selection } from 'vscode'
3 |
4 | const bracketPairs: [left: string, right: string][] = [
5 | ['(', ')'],
6 | ['[', ']'],
7 | ['{', '}'],
8 | ['<', '>'],
9 | ['"', '"'],
10 | ['`', '`'],
11 | ['\'', '\''],
12 | ]
13 |
14 | /**
15 | * Pair to inner content of brackets.
16 | *
17 | * ```js
18 | * ▽
19 | * (foo, bar)
20 | * └──────┘
21 | * ```
22 | *
23 | * @name bracket-pair
24 | */
25 | export const bracketPairHandler: Handler = {
26 | name: 'bracket-pair',
27 | handle({ charLeft, charRight, doc, anchor: _anchor, withOffset }) {
28 | for (const DIR of [1, -1]) {
29 | const OPEN = DIR === 1 ? 0 : 1
30 | const CLOSE = DIR === 1 ? 1 : 0
31 |
32 | const bracketLeft = bracketPairs.find(i => i[OPEN] === charLeft)
33 | const bracketRight = bracketPairs.find(i => i[OPEN] === charRight)
34 | const bracket = bracketLeft || bracketRight
35 | const anchor = bracketLeft ? withOffset(_anchor, -1) : _anchor
36 |
37 | if (!bracket)
38 | continue
39 |
40 | const start = withOffset(anchor, DIR)
41 | const rest = doc.getText(
42 | DIR === 1
43 | ? new Range(start, new Position(Infinity, Infinity))
44 | : new Range(new Position(0, 0), start),
45 | )
46 |
47 | // search for the right bracket
48 | let index = -1
49 | let curly = 0
50 | for (let i = 0; i < rest.length; i += 1) {
51 | const idx = (rest.length + i * DIR) % rest.length
52 | const c = rest[idx]
53 | if (rest[idx - 1] === '\\')
54 | continue
55 | if (c === bracket[OPEN]) {
56 | curly++
57 | }
58 | else if (c === bracket[CLOSE]) {
59 | curly--
60 | if (curly < 0) {
61 | index = i
62 | break
63 | }
64 | }
65 | }
66 |
67 | if (index < 0)
68 | continue
69 |
70 | if (DIR === 1) {
71 | return new Selection(
72 | start,
73 | withOffset(start, index),
74 | )
75 | }
76 | else {
77 | return new Selection(
78 | withOffset(start, index * DIR + 1),
79 | withOffset(start, 1),
80 | )
81 | }
82 | }
83 | },
84 | }
85 |
--------------------------------------------------------------------------------
/src/rules/html-tag-pair.ts:
--------------------------------------------------------------------------------
1 | import type { Handler } from '../types'
2 | import { Range, Selection } from 'vscode'
3 | import { traverseHTML } from '../parsers/html'
4 |
5 | /**
6 | * Open and close tags of a HTML element.
7 | *
8 | * ```html
9 | * ▽
10 | *
11 | * └─┘ └─┘
12 | * ```
13 | *
14 | * @name html-tag-pair
15 | * @category html
16 | */
17 | export const htmlTagPairHandler: Handler = {
18 | name: 'html-tag-pair',
19 | handle({ getAst, selection, doc, withOffset }) {
20 | const asts = getAst('html')
21 | if (!asts.length)
22 | return
23 |
24 | const range = doc.getWordRangeAtPosition(selection.start, /[\w.\-]+/) || selection
25 | const rangeText = doc.getText(range)
26 | const preCharPos = withOffset(range.start, -1)
27 | const preChar = doc.getText(new Range(preCharPos, range.start))
28 | const postCharPos = withOffset(range.end, 1)
29 | const postChar = doc.getText(new Range(range.end, postCharPos))
30 |
31 | const preIndex = preChar === '<' ? doc.offsetAt(preCharPos) : -1
32 | const postIndex = postChar === '>' ? doc.offsetAt(postCharPos) : -1
33 |
34 | if (postIndex < 0 && preIndex < 0)
35 | return
36 |
37 | for (const ast of asts) {
38 | for (const node of traverseHTML(ast.root)) {
39 | if (node.rawTagName !== rangeText || !('isVoidElement' in node) || node.isVoidElement)
40 | continue
41 |
42 | // from start tag to end tag
43 | if (node.range[0] + ast.start === preIndex) {
44 | const body = doc.getText(new Range(
45 | preCharPos,
46 | doc.positionAt(node.range[1] + ast.start),
47 | ))
48 |
49 | if (body.trimEnd().endsWith('/>'))
50 | return range
51 |
52 | const endIndex = body.lastIndexOf(`${node.rawTagName}>`)
53 | if (endIndex) {
54 | return [
55 | range,
56 | new Selection(
57 | doc.positionAt(preIndex + endIndex + 2),
58 | doc.positionAt(preIndex + endIndex + 2 + node.rawTagName.length),
59 | ),
60 | ]
61 | }
62 | }
63 |
64 | // from end tag to start tag
65 | if (node.range[1] === postIndex) {
66 | return [
67 | new Selection(
68 | doc.positionAt(node.range[0] + 1),
69 | doc.positionAt(node.range[0] + 1 + node.rawTagName.length),
70 | ),
71 | range,
72 | ]
73 | }
74 | }
75 | }
76 | },
77 | }
78 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { ExtensionContext, Selection, TextEditor } from 'vscode'
2 | import type { AstRoot } from './types'
3 | import { commands, TextEditorSelectionChangeKind, window, workspace } from 'vscode'
4 | import { trigger } from './trigger'
5 |
6 | export const astCache = new Map()
7 |
8 | export function activate(ext: ExtensionContext) {
9 | let last = 0
10 | let prevEditor: TextEditor | undefined
11 | let prevSelection: Selection | undefined
12 | let timer: any
13 |
14 | const config = workspace.getConfiguration('smartClicks')
15 |
16 | ext.subscriptions.push(
17 | workspace.onDidChangeTextDocument((e) => {
18 | astCache.delete(e.document.uri.fsPath)
19 | }),
20 |
21 | window.onDidChangeTextEditorSelection(async (e) => {
22 | clearTimeout(timer)
23 | if (e.kind !== TextEditorSelectionChangeKind.Mouse) {
24 | last = 0
25 | return
26 | }
27 |
28 | const selection = e.selections[0]
29 | const prev = prevSelection
30 |
31 | try {
32 | if (
33 | prevEditor !== e.textEditor
34 | || !prevSelection
35 | || !prevSelection.isEmpty
36 | || e.selections.length !== 1
37 | || selection.start.line !== prevSelection.start.line
38 | || Date.now() - last > config.get('clicksInterval', 600)
39 | ) {
40 | return
41 | }
42 | }
43 | finally {
44 | prevEditor = e.textEditor
45 | prevSelection = selection
46 | last = Date.now()
47 | }
48 |
49 | timer = setTimeout(async () => {
50 | const line = Math.max(0, e.textEditor.selection.active.line - 1)
51 | const { rangeIncludingLineBreak } = e.textEditor.document.lineAt(line)
52 |
53 | if (rangeIncludingLineBreak.isEqual(selection))
54 | return
55 | const newSelection = await trigger(e.textEditor.document, prev!, selection)
56 | const newSelectionText = e.textEditor.document.getText(newSelection?.[0])
57 | // Skip empty results when selecting text like "/>", "{}", "()"
58 | if (newSelection && newSelectionText) {
59 | last = 0
60 | e.textEditor.selections = newSelection
61 | }
62 | }, config.get('triggerDelay', 150))
63 | }),
64 |
65 | commands.registerCommand(
66 | 'smartClicks.trigger',
67 | async () => {
68 | const editor = window.activeTextEditor
69 | if (!editor)
70 | return
71 |
72 | const prev = editor.selections[0]
73 | await commands.executeCommand('editor.action.smartSelect.expand')
74 | const selection = editor.selections[0]
75 |
76 | if (editor.selections.length !== 1)
77 | return
78 |
79 | const newSelection = await trigger(editor.document, prev, selection)
80 | const newSelectionText = editor.document.getText(newSelection?.[0])
81 |
82 | if (newSelection && newSelectionText)
83 | editor.selections = newSelection
84 | },
85 | ),
86 | )
87 | }
88 |
89 | export function deactivate() {
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/src/rules/js-block.ts:
--------------------------------------------------------------------------------
1 | import type { Node } from '@babel/types'
2 | // import { log } from '../log'
3 | import type { Handler } from '../types'
4 | import traverse from '@babel/traverse'
5 |
6 | import { Selection } from 'vscode'
7 |
8 | const supportedNodeType = [
9 | 'BlockStatement',
10 | 'CatchClause',
11 | 'ClassDeclaration',
12 | 'DoWhileStatement',
13 | 'ExportAllDeclaration',
14 | 'ExportDefaultDeclaration',
15 | 'ExportNamedDeclaration',
16 | 'ForStatement',
17 | 'ForInStatement',
18 | 'ForOfStatement',
19 | 'FunctionDeclaration',
20 | 'IfStatement',
21 | 'ImportDeclaration',
22 | 'SwitchStatement',
23 | 'TryStatement',
24 | 'TSInterfaceDeclaration',
25 | 'WhileStatement',
26 | ]
27 |
28 | /**
29 | * Blocks like `if`, `for`, `while`, etc. in JavaScript.
30 | *
31 | * ```js
32 | * ▽
33 | * function () { }
34 | * └─────────────────┘
35 | * ```
36 | *
37 | * ```js
38 | * ▽
39 | * import { ref } from 'vue'
40 | * └───────────────────────┘
41 | * ```
42 | *
43 | * @name js-block
44 | * @category js
45 | */
46 | export const jsBlockHandler: Handler = {
47 | name: 'js-block',
48 | handle({ selection, doc, getAst }) {
49 | const selectionText = doc.getText(selection)
50 | if (selectionText === 'async')
51 | return
52 |
53 | for (const ast of getAst('js')) {
54 | const index = doc.offsetAt(selection.start)
55 | const relativeIndex = index - ast.start
56 |
57 | let node: Node | undefined
58 | traverse(ast.root, {
59 | enter(path) {
60 | if (path.node.start == null || path.node.end == null)
61 | return
62 | if (relativeIndex > path.node.end || path.node.start > relativeIndex)
63 | return path.skip()
64 |
65 | if (!supportedNodeType.includes(path.node.type)) {
66 | // log.debug('[js-block] Unknown type:', path.node.type)
67 | return
68 | }
69 | node = path.node
70 | },
71 | })
72 |
73 | if (!node)
74 | continue
75 |
76 | let start = node.start
77 | let end = node.end
78 |
79 | // if ... else
80 | if (
81 | node.type === 'IfStatement'
82 | && node.alternate
83 | && node.consequent.end! <= relativeIndex
84 | && node.alternate.start! > relativeIndex
85 | ) {
86 | start = node.consequent.end
87 | end = node.alternate.end
88 | }
89 | // try ... finally
90 | else if (
91 | node.type === 'TryStatement'
92 | && node.finalizer
93 | && (node.handler?.end ?? node.block.end!) <= relativeIndex
94 | && node.finalizer.start! - relativeIndex > 4
95 | ) {
96 | start = (node.handler?.end || node.block.end!)
97 | end = node.finalizer.end
98 | }
99 | else if (node.start !== relativeIndex) {
100 | continue
101 | }
102 |
103 | return new Selection(
104 | doc.positionAt(ast.start + start!),
105 | doc.positionAt(ast.start + end!),
106 | )
107 | }
108 | },
109 | }
110 |
--------------------------------------------------------------------------------
/playground/main.ts:
--------------------------------------------------------------------------------
1 | import { extname, isAbsolute, resolve } from 'pathe'
2 | import { isNodeBuiltin } from 'mlly'
3 | import { normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils'
4 | import type { ModuleCache, ViteNodeRunnerOptions } from './types'
5 |
6 | export const DEFAULT_REQUEST_STUBS = {
7 | '/@vite/client': {
8 | injectQuery: (id: string) => id,
9 | createHotContext() {
10 | return {
11 | accept: () => {},
12 | prune: () => {},
13 | dispose: () => {},
14 | decline: () => {},
15 | invalidate: () => {},
16 | on: () => {},
17 | }
18 | },
19 | updateStyle() {},
20 | },
21 | }
22 |
23 | export class ModuleCacheMap extends Map {
24 | normalizePath(fsPath: string) {
25 | return normalizeModuleId(fsPath)
26 | }
27 |
28 | set(fsPath: string, mod: Partial) {
29 | fsPath = this.normalizePath(fsPath)
30 | if (!super.has(fsPath))
31 | super.set(fsPath, mod)
32 | else
33 | Object.assign(super.get(fsPath), mod)
34 | return this
35 | }
36 | }
37 |
38 | export class ViteNodeRunner {
39 | root: string
40 |
41 | /**
42 | * Holds the cache of modules
43 | * Keys of the map are filepaths, or plain package names
44 | */
45 | moduleCache: ModuleCacheMap
46 |
47 | constructor(public options: ViteNodeRunnerOptions) {
48 | this.root = options.root || process.cwd()
49 | this.moduleCache = options.moduleCache || new ModuleCacheMap()
50 | }
51 |
52 | async executeFile(file: string) {
53 | return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, [])
54 | }
55 |
56 | async executeId(id: string) {
57 | return await this.cachedRequest(id, [])
58 | }
59 |
60 | /** @internal */
61 | async cachedRequest(rawId: string, callstack: string[]) {
62 | const id = normalizeRequestId(rawId, this.options.base)
63 | const fsPath = toFilePath(id, this.root)
64 |
65 | if (this.moduleCache.get(fsPath)?.promise)
66 | return this.moduleCache.get(fsPath)?.promise
67 |
68 | const promise = this.directRequest(id, fsPath, callstack)
69 | this.moduleCache.set(fsPath, { promise })
70 |
71 | return await promise
72 | }
73 |
74 | shouldResolveId(dep: string) {
75 | if (isNodeBuiltin(dep) || dep in (this.options.requestStubs || DEFAULT_REQUEST_STUBS))
76 | return false
77 |
78 | return !isAbsolute(dep) || !extname(dep)
79 | }
80 |
81 | /**
82 | * Define if a module should be interop-ed
83 | * This function mostly for the ability to override by subclass
84 | */
85 | shouldInterop(path: string, mod: any) {
86 | if (this.options.interopDefault === false)
87 | return false
88 | // never interop ESM modules
89 | // TODO: should also skip for `.js` with `type="module"`
90 | return !path.endsWith('.mjs') && 'default' in mod
91 | }
92 |
93 | /**
94 | * Import a module and interop it
95 | */
96 | async interopedImport(path: string) {
97 | const mod = await import(path)
98 |
99 | if (this.shouldInterop(path, mod)) {
100 | const tryDefault = this.hasNestedDefault(mod)
101 | return new Proxy(mod, {
102 | get: proxyMethod('get', tryDefault),
103 | set: proxyMethod('set', tryDefault),
104 | has: proxyMethod('has', tryDefault),
105 | deleteProperty: proxyMethod('deleteProperty', tryDefault),
106 | })
107 | }
108 |
109 | return mod
110 | }
111 |
112 | hasNestedDefault(target: any) {
113 | return '__esModule' in target && target.__esModule && 'default' in target.default
114 | }
115 | }
116 |
117 | async function exportAll(exports: any, sourceModule: any) {
118 | for (const key of sourceModule) {
119 | if (key !== 'default') {
120 | try {
121 | Object.defineProperty(exports, key, {
122 | enumerable: true,
123 | configurable: true,
124 | get: () => { return sourceModule[key] },
125 | })
126 | }
127 | finally {
128 | console.log()
129 | }
130 | }
131 | else if (hi) {
132 |
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Smart Clicks VS Code
6 |
7 |
8 |
9 |
10 |
11 |
12 | Smart selection with double clicks for VS Code.
13 | GIF Demo
14 |
15 |
16 | ## Usage
17 |
18 | Double clicks on the code.
19 |
20 | ## Rules
21 |
22 |
23 |
24 |
25 | #### [`bracket-pair`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/bracket-pair.ts)
26 |
27 | Pair to inner content of brackets.
28 |
29 | ```js
30 | ▽
31 | (foo, bar)
32 | └──────┘
33 | ```
34 |
35 | #### [`dash`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/dash.ts)
36 |
37 | `-` to identifier.
38 |
39 | ```css
40 | ▽
41 | foo-bar
42 | └─────┘
43 | ```
44 |
45 | #### [`html-attr`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/html-attr.ts)
46 |
47 | `=` to HTML attribute.
48 |
49 | ```html
50 | ▽
51 |
52 | └─────────┘
53 | ```
54 |
55 | #### [`html-element`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/html-element.ts)
56 |
57 | `<` to the entire element.
58 |
59 | ```html
60 | ▽
61 |
62 | └────────────────────┘
63 | ```
64 |
65 | #### [`html-tag-pair`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/html-tag-pair.ts)
66 |
67 | Open and close tags of a HTML element.
68 |
69 | ```html
70 | ▽
71 |
72 | └─┘ └─┘
73 | ```
74 |
75 | #### [`js-arrow-fn`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-arrow-fn.ts)
76 |
77 | `=>` to arrow function.
78 |
79 | ```js
80 | ▽
81 | (a, b) => a + b
82 | └─────────────┘
83 | ```
84 |
85 | #### [`js-assign`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-assign.ts)
86 |
87 | `=` to assignment.
88 |
89 | ```js
90 | ▽
91 | const a = []
92 | └──────────┘
93 |
94 | class B {
95 | ▽
96 | b = 1;
97 | └────┘
98 | ▽
99 | ba = () => {};
100 | └────────────┘
101 | }
102 | ```
103 |
104 | #### [`js-block`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-block.ts)
105 |
106 | Blocks like `if`, `for`, `while`, etc. in JavaScript.
107 |
108 | ```js
109 | ▽
110 | function () { }
111 | └─────────────────┘
112 | ```
113 |
114 | ```js
115 | ▽
116 | import { ref } from 'vue'
117 | └───────────────────────┘
118 | ```
119 |
120 | This rule is _disabled_ by default.
121 |
122 | #### [`js-colon`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-colon.ts)
123 |
124 | `:` to the value.
125 |
126 | ```js
127 | ▽
128 | { foo: { bar } }
129 | └─────┘
130 | ```
131 |
132 | #### [`jsx-tag-pair`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/jsx-tag-pair.ts)
133 |
134 | Matches JSX elements' start and end tags.
135 |
136 | ```jsx
137 | ▽
138 | (Hi)
139 | └───────┘ └───────┘
140 | ```
141 |
142 |
143 |
144 | ## Configuration
145 |
146 | All the rules are enabled by default. To disable a specific rule, set the rule to `false` in `smartClicks.rules` of your VS Code settings:
147 |
148 | ```jsonc
149 | // settings.json
150 | {
151 | "smartClicks.rules": {
152 | "dash": false,
153 | "html-element": false,
154 | "js-block": true
155 | }
156 | }
157 | ```
158 |
159 | ## Commands
160 |
161 | | ID | Description |
162 | | --------------------- | ------------------------------------------------------------------- |
163 | | `smartClicks.trigger` | Trigger Smart Clicks in current cursor position without mouse click |
164 |
165 | Usage examples:
166 |
167 | 1. Command palette
168 |
169 | Invoke the command palette by typing `Ctrl+Shift+P` and then typing `Smart Clicks: Trigger`.
170 |
171 | 2. Keyboard shortcuts
172 |
173 | ```jsonc
174 | // keybindings.json
175 | {
176 | "key": "ctrl+alt+c",
177 | "command": "smartClicks.trigger",
178 | "when": "editorTextFocus"
179 | }
180 | ```
181 |
182 | 3. Vim keybindings ([VSCodeVim](https://marketplace.visualstudio.com/items?itemName=vscodevim.vim) is needed)
183 |
184 | ```jsonc
185 | // settings.json
186 | {
187 | "vim.normalModeKeyBindings": [
188 | {
189 | "before": ["leader", "c"],
190 | "commands": ["smartClicks.trigger"],
191 | }
192 | ]
193 | }
194 | ```
195 |
196 | ## Sponsors
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 | ## Credits
205 |
206 | Inspired by [HBuilderX](https://www.dcloud.io/hbuilderx.html), initiated by [恐粉龙](https://space.bilibili.com/432190144).
207 |
208 | ## License
209 |
210 | [MIT](./LICENSE) License © 2022 [Anthony Fu](https://github.com/antfu)
211 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "publisher": "antfu",
3 | "name": "smart-clicks",
4 | "displayName": "Smart Clicks",
5 | "version": "0.2.2",
6 | "packageManager": "pnpm@10.15.0",
7 | "description": "Smart selection with double clicks",
8 | "author": "Anthony Fu ",
9 | "license": "MIT",
10 | "funding": "https://github.com/sponsors/antfu",
11 | "homepage": "https://github.com/antfu/vscode-smart-clicks#readme",
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/antfu/vscode-smart-clicks"
15 | },
16 | "bugs": {
17 | "url": "https://github.com/antfu/vscode-smart-clicks/issues"
18 | },
19 | "categories": [
20 | "Other"
21 | ],
22 | "sideEffects": false,
23 | "main": "./dist/index.js",
24 | "icon": "res/icon.png",
25 | "files": [
26 | "LICENSE.md",
27 | "dist/*",
28 | "res/*"
29 | ],
30 | "engines": {
31 | "vscode": "^1.103.0"
32 | },
33 | "activationEvents": [
34 | "onStartupFinished"
35 | ],
36 | "contributes": {
37 | "configuration": {
38 | "properties": {
39 | "smartClicks.clicksInterval": {
40 | "type": "number",
41 | "default": 600,
42 | "description": "The interval between clicks in milliseconds."
43 | },
44 | "smartClicks.triggerDelay": {
45 | "type": "number",
46 | "default": 150,
47 | "description": "The delay after triggering the selection. To prevent conflicting with normal selection."
48 | },
49 | "smartClicks.htmlLanguageIds": {
50 | "type": "array",
51 | "items": {
52 | "type": "string"
53 | },
54 | "default": [
55 | "html",
56 | "vue",
57 | "svelte"
58 | ],
59 | "description": "Array of language IDs to enable html smartClicks"
60 | },
61 | "smartClicks.rules": {
62 | "type": "object",
63 | "properties": {
64 | "bracket-pair": {
65 | "type": "boolean",
66 | "default": true,
67 | "description": "Pair to inner content of brackets.\n\n```js\n▽\n(foo, bar)\n └──────┘\n```"
68 | },
69 | "dash": {
70 | "type": "boolean",
71 | "default": true,
72 | "description": "`-` to identifier.\n\n```css\n ▽\nfoo-bar\n└─────┘\n```"
73 | },
74 | "html-attr": {
75 | "type": "boolean",
76 | "default": true,
77 | "description": "`=` to HTML attribute.\n\n```html\n ▽\n\n └─────────┘\n```"
78 | },
79 | "html-element": {
80 | "type": "boolean",
81 | "default": true,
82 | "description": "`<` to the entire element.\n\n```html\n▽\n\n└────────────────────┘\n```"
83 | },
84 | "html-tag-pair": {
85 | "type": "boolean",
86 | "default": true,
87 | "description": "Open and close tags of a HTML element.\n\n```html\n ▽\n\n └─┘ └─┘\n```"
88 | },
89 | "js-arrow-fn": {
90 | "type": "boolean",
91 | "default": true,
92 | "description": "`=>` to arrow function.\n\n```js\n ▽\n(a, b) => a + b\n└─────────────┘\n```"
93 | },
94 | "js-assign": {
95 | "type": "boolean",
96 | "default": true,
97 | "description": "`=` to assignment.\n\n```js\n ▽\nconst a = []\n└──────────┘\n```"
98 | },
99 | "js-block": {
100 | "type": "boolean",
101 | "default": false,
102 | "description": "Blocks like `if`, `for`, `while`, etc. in JavaScript.\n\n```js\n▽\nfunction () { }\n└─────────────────┘\n```\n\n```js\n▽\nimport { ref } from 'vue'\n└───────────────────────┘\n```"
103 | },
104 | "js-colon": {
105 | "type": "boolean",
106 | "default": true,
107 | "description": "`:` to the value.\n\n```js\n ▽\n{ foo: { bar } }\n └─────┘\n```"
108 | },
109 | "jsx-tag-pair": {
110 | "type": "boolean",
111 | "default": true,
112 | "description": "Matches JSX elements' start and end tags.\n\n```jsx\n ▽\n(Hi)\n └───────┘ └───────┘\n```"
113 | }
114 | },
115 | "description": "Rule toggles"
116 | }
117 | }
118 | },
119 | "commands": [
120 | {
121 | "title": "Smart Clicks: Trigger",
122 | "command": "smartClicks.trigger"
123 | }
124 | ]
125 | },
126 | "scripts": {
127 | "build": "NODE_ENV=production tsdown",
128 | "dev": "NODE_ENV=development tsdown --watch",
129 | "lint": "eslint .",
130 | "vscode:prepublish": "nr build",
131 | "ext:publish": "vsce publish --no-dependencies",
132 | "package": "vsce package --no-dependencies",
133 | "test": "vitest",
134 | "typecheck": "tsc --noEmit",
135 | "release": "bumpp",
136 | "readme": "esno ./scripts/docs.ts"
137 | },
138 | "devDependencies": {
139 | "@antfu/eslint-config": "^5.2.1",
140 | "@antfu/ni": "^25.0.0",
141 | "@antfu/utils": "^9.2.0",
142 | "@babel/core": "^7.28.3",
143 | "@babel/parser": "^7.28.3",
144 | "@babel/traverse": "^7.28.3",
145 | "@babel/types": "^7.28.2",
146 | "@types/babel__traverse": "^7.28.0",
147 | "@types/node": "^24.3.0",
148 | "@types/vscode": "^1.103.0",
149 | "@vscode/vsce": "^3.6.0",
150 | "bumpp": "^10.2.3",
151 | "eslint": "^9.34.0",
152 | "eslint-plugin-format": "^1.0.1",
153 | "esno": "^4.8.0",
154 | "fast-glob": "^3.3.3",
155 | "node-html-parser": "^7.0.1",
156 | "ovsx": "^0.10.5",
157 | "pnpm": "^10.15.0",
158 | "rimraf": "^6.0.1",
159 | "tsdown": "^0.14.1",
160 | "typescript": "^5.9.2",
161 | "vite": "^7.1.3",
162 | "vitest": "^3.2.4",
163 | "vsxpub": "^0.1.0"
164 | }
165 | }
166 |
--------------------------------------------------------------------------------